279 lines
10 KiB
Go
279 lines
10 KiB
Go
package autoscan
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/hex"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"github.com/jackc/pgx/v5"
|
||
|
|
"github.com/jackc/pgx/v5/pgconn"
|
||
|
|
|
||
|
|
"github.com/Silo-Server/silo-server/internal/secret"
|
||
|
|
)
|
||
|
|
|
||
|
|
// webhookSecretAAD binds an autoscan_webhook_endpoints secret_ref ciphertext to
|
||
|
|
// its owning source id.
|
||
|
|
func webhookSecretAAD(sourceID string) string {
|
||
|
|
return secret.RowAAD("autoscan_webhook_endpoints", "secret_ref", sourceID)
|
||
|
|
}
|
||
|
|
|
||
|
|
// webhookSecretSuffixLen is how many trailing token characters are stored for
|
||
|
|
// UI/debug display.
|
||
|
|
const webhookSecretSuffixLen = 6
|
||
|
|
|
||
|
|
// newWebhookToken generates a URL-safe bearer token plus its SHA-256 lookup
|
||
|
|
// hash and display suffix. The token is 32 random bytes; plain SHA-256 is
|
||
|
|
// sufficient for lookup because the preimage is high-entropy random data.
|
||
|
|
func newWebhookToken() (token, hash, suffix string, err error) {
|
||
|
|
raw := make([]byte, 32)
|
||
|
|
if _, err := rand.Read(raw); err != nil {
|
||
|
|
return "", "", "", fmt.Errorf("generate webhook token: %w", err)
|
||
|
|
}
|
||
|
|
token = base64.RawURLEncoding.EncodeToString(raw)
|
||
|
|
hash = hashWebhookToken(token)
|
||
|
|
return token, hash, token[len(token)-webhookSecretSuffixLen:], nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// hashWebhookToken maps a raw bearer token to its secret_hash lookup value.
|
||
|
|
func hashWebhookToken(token string) string {
|
||
|
|
sum := sha256.Sum256([]byte(token))
|
||
|
|
return hex.EncodeToString(sum[:])
|
||
|
|
}
|
||
|
|
|
||
|
|
const webhookEndpointColumns = `source_id, secret_suffix, created_at, rotated_at,
|
||
|
|
last_received_at, last_error_at, last_error_message`
|
||
|
|
|
||
|
|
func scanWebhookEndpoint(row interface{ Scan(...any) error }) (WebhookEndpoint, error) {
|
||
|
|
var e WebhookEndpoint
|
||
|
|
if err := row.Scan(&e.SourceID, &e.SecretSuffix, &e.CreatedAt, &e.RotatedAt,
|
||
|
|
&e.LastReceivedAt, &e.LastErrorAt, &e.LastErrorMessage); err != nil {
|
||
|
|
return WebhookEndpoint{}, err
|
||
|
|
}
|
||
|
|
return e, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// CreateWebhookEndpoint creates a source's webhook endpoint and returns it with
|
||
|
|
// the plaintext bearer token. When the endpoint already exists it is returned
|
||
|
|
// unchanged with an empty token (the token is only handed out on create/rotate;
|
||
|
|
// redisplay goes through RevealWebhookToken). An unknown source maps to
|
||
|
|
// ErrNotFound.
|
||
|
|
func (r *Repository) CreateWebhookEndpoint(ctx context.Context, sourceID string) (WebhookEndpoint, string, error) {
|
||
|
|
token, hash, suffix, err := newWebhookToken()
|
||
|
|
if err != nil {
|
||
|
|
return WebhookEndpoint{}, "", err
|
||
|
|
}
|
||
|
|
ref, err := r.cipher.Encrypt(token, webhookSecretAAD(sourceID))
|
||
|
|
if err != nil {
|
||
|
|
return WebhookEndpoint{}, "", fmt.Errorf("encrypt webhook token: %w", err)
|
||
|
|
}
|
||
|
|
row := r.pool.QueryRow(ctx, `
|
||
|
|
INSERT INTO autoscan_webhook_endpoints (source_id, secret_hash, secret_ref, secret_suffix)
|
||
|
|
VALUES ($1, $2, $3, $4)
|
||
|
|
ON CONFLICT (source_id) DO NOTHING
|
||
|
|
RETURNING `+webhookEndpointColumns,
|
||
|
|
sourceID, hash, ref, suffix)
|
||
|
|
endpoint, err := scanWebhookEndpoint(row)
|
||
|
|
if err == nil {
|
||
|
|
return endpoint, token, nil
|
||
|
|
}
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
// Conflict: the endpoint already exists — return it, discard the
|
||
|
|
// unused freshly generated token.
|
||
|
|
existing, gerr := r.GetWebhookEndpoint(ctx, sourceID)
|
||
|
|
if gerr != nil {
|
||
|
|
return WebhookEndpoint{}, "", gerr
|
||
|
|
}
|
||
|
|
return existing, "", nil
|
||
|
|
}
|
||
|
|
var pgErr *pgconn.PgError
|
||
|
|
if errors.As(err, &pgErr) && pgErr.Code == "23503" {
|
||
|
|
return WebhookEndpoint{}, "", fmt.Errorf("%w: source %s", ErrNotFound, sourceID)
|
||
|
|
}
|
||
|
|
return WebhookEndpoint{}, "", fmt.Errorf("create autoscan webhook endpoint: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// RotateWebhookEndpoint replaces the endpoint's bearer token, invalidating the
|
||
|
|
// old URL immediately, and returns the new plaintext token. An unknown
|
||
|
|
// source/endpoint maps to ErrNotFound.
|
||
|
|
func (r *Repository) RotateWebhookEndpoint(ctx context.Context, sourceID string) (WebhookEndpoint, string, error) {
|
||
|
|
token, hash, suffix, err := newWebhookToken()
|
||
|
|
if err != nil {
|
||
|
|
return WebhookEndpoint{}, "", err
|
||
|
|
}
|
||
|
|
ref, err := r.cipher.Encrypt(token, webhookSecretAAD(sourceID))
|
||
|
|
if err != nil {
|
||
|
|
return WebhookEndpoint{}, "", fmt.Errorf("encrypt webhook token: %w", err)
|
||
|
|
}
|
||
|
|
row := r.pool.QueryRow(ctx, `
|
||
|
|
UPDATE autoscan_webhook_endpoints
|
||
|
|
SET secret_hash = $2, secret_ref = $3, secret_suffix = $4, rotated_at = now()
|
||
|
|
WHERE source_id = $1
|
||
|
|
RETURNING `+webhookEndpointColumns,
|
||
|
|
sourceID, hash, ref, suffix)
|
||
|
|
endpoint, err := scanWebhookEndpoint(row)
|
||
|
|
if err != nil {
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return WebhookEndpoint{}, "", fmt.Errorf("%w: webhook endpoint for source %s", ErrNotFound, sourceID)
|
||
|
|
}
|
||
|
|
return WebhookEndpoint{}, "", fmt.Errorf("rotate autoscan webhook endpoint: %w", err)
|
||
|
|
}
|
||
|
|
return endpoint, token, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// DeleteWebhookEndpoint removes a source's webhook endpoint; future deliveries
|
||
|
|
// to the old URL return not found. An unknown endpoint maps to ErrNotFound.
|
||
|
|
func (r *Repository) DeleteWebhookEndpoint(ctx context.Context, sourceID string) error {
|
||
|
|
tag, err := r.pool.Exec(ctx, `DELETE FROM autoscan_webhook_endpoints WHERE source_id = $1`, sourceID)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("delete autoscan webhook endpoint: %w", err)
|
||
|
|
}
|
||
|
|
if tag.RowsAffected() == 0 {
|
||
|
|
return fmt.Errorf("%w: webhook endpoint for source %s", ErrNotFound, sourceID)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetWebhookEndpoint loads a source's webhook endpoint state. An unknown
|
||
|
|
// endpoint maps to ErrNotFound.
|
||
|
|
func (r *Repository) GetWebhookEndpoint(ctx context.Context, sourceID string) (WebhookEndpoint, error) {
|
||
|
|
row := r.pool.QueryRow(ctx, `SELECT `+webhookEndpointColumns+`
|
||
|
|
FROM autoscan_webhook_endpoints WHERE source_id = $1`, sourceID)
|
||
|
|
endpoint, err := scanWebhookEndpoint(row)
|
||
|
|
if err != nil {
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return WebhookEndpoint{}, fmt.Errorf("%w: webhook endpoint for source %s", ErrNotFound, sourceID)
|
||
|
|
}
|
||
|
|
return WebhookEndpoint{}, fmt.Errorf("get autoscan webhook endpoint: %w", err)
|
||
|
|
}
|
||
|
|
return endpoint, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ListWebhookEndpoints returns every webhook endpoint (one batched query for
|
||
|
|
// admin source listings).
|
||
|
|
func (r *Repository) ListWebhookEndpoints(ctx context.Context) ([]WebhookEndpoint, error) {
|
||
|
|
rows, err := r.pool.Query(ctx, `SELECT `+webhookEndpointColumns+`
|
||
|
|
FROM autoscan_webhook_endpoints`)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("list autoscan webhook endpoints: %w", err)
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
var out []WebhookEndpoint
|
||
|
|
for rows.Next() {
|
||
|
|
endpoint, err := scanWebhookEndpoint(rows)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
out = append(out, endpoint)
|
||
|
|
}
|
||
|
|
return out, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
// RevealWebhookToken decrypts the stored bearer token for admin redisplay.
|
||
|
|
func (r *Repository) RevealWebhookToken(ctx context.Context, sourceID string) (string, error) {
|
||
|
|
var ref string
|
||
|
|
err := r.pool.QueryRow(ctx, `SELECT secret_ref
|
||
|
|
FROM autoscan_webhook_endpoints WHERE source_id = $1`, sourceID).Scan(&ref)
|
||
|
|
if err != nil {
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return "", fmt.Errorf("%w: webhook endpoint for source %s", ErrNotFound, sourceID)
|
||
|
|
}
|
||
|
|
return "", fmt.Errorf("reveal autoscan webhook token: %w", err)
|
||
|
|
}
|
||
|
|
token, err := r.cipher.Decrypt(ref, webhookSecretAAD(sourceID))
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("decrypt webhook token: %w", err)
|
||
|
|
}
|
||
|
|
return token, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ResolveWebhookToken maps a raw delivery token to its owning source and
|
||
|
|
// endpoint. Lookup is constant-shape: the token is hashed and matched against
|
||
|
|
// secret_hash; unknown, deleted, and never-existed tokens are indistinguishable
|
||
|
|
// and all map to ErrNotFound.
|
||
|
|
func (r *Repository) ResolveWebhookToken(ctx context.Context, token string) (Source, WebhookEndpoint, error) {
|
||
|
|
token = strings.TrimSpace(token)
|
||
|
|
if token == "" {
|
||
|
|
return Source{}, WebhookEndpoint{}, fmt.Errorf("%w: webhook token", ErrNotFound)
|
||
|
|
}
|
||
|
|
row := r.pool.QueryRow(ctx, `
|
||
|
|
SELECT s.id, s.plugin_id, s.capability_id, s.connection_id, s.enabled, s.delivery_mode,
|
||
|
|
s.poll_interval_seconds, s.path_rewrites, s.source_config, s.label, s.marker, s.last_run_at, s.last_error,
|
||
|
|
e.source_id, e.secret_suffix, e.created_at, e.rotated_at,
|
||
|
|
e.last_received_at, e.last_error_at, e.last_error_message
|
||
|
|
FROM autoscan_webhook_endpoints e
|
||
|
|
JOIN autoscan_sources s ON s.id = e.source_id
|
||
|
|
WHERE e.secret_hash = $1`,
|
||
|
|
hashWebhookToken(token))
|
||
|
|
var (
|
||
|
|
src Source
|
||
|
|
endpoint WebhookEndpoint
|
||
|
|
pathRewrites []byte
|
||
|
|
sourceConfig []byte
|
||
|
|
)
|
||
|
|
if err := row.Scan(&src.ID, &src.PluginID, &src.CapabilityID, &src.ConnectionID,
|
||
|
|
&src.Enabled, &src.DeliveryMode, &src.PollIntervalSeconds, &pathRewrites, &sourceConfig,
|
||
|
|
&src.Label, &src.Marker, &src.LastRunAt, &src.LastError,
|
||
|
|
&endpoint.SourceID, &endpoint.SecretSuffix, &endpoint.CreatedAt, &endpoint.RotatedAt,
|
||
|
|
&endpoint.LastReceivedAt, &endpoint.LastErrorAt, &endpoint.LastErrorMessage); err != nil {
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return Source{}, WebhookEndpoint{}, fmt.Errorf("%w: webhook token", ErrNotFound)
|
||
|
|
}
|
||
|
|
return Source{}, WebhookEndpoint{}, fmt.Errorf("resolve autoscan webhook token: %w", err)
|
||
|
|
}
|
||
|
|
rewrites, err := unmarshalPathRewrites(pathRewrites)
|
||
|
|
if err != nil {
|
||
|
|
return Source{}, WebhookEndpoint{}, err
|
||
|
|
}
|
||
|
|
src.PathRewrites = rewrites
|
||
|
|
config, err := unmarshalSourceConfig(sourceConfig)
|
||
|
|
if err != nil {
|
||
|
|
return Source{}, WebhookEndpoint{}, err
|
||
|
|
}
|
||
|
|
src.SourceConfig = config
|
||
|
|
return src, endpoint, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// TouchWebhookReceived stamps the endpoint's last valid delivery time.
|
||
|
|
func (r *Repository) TouchWebhookReceived(ctx context.Context, sourceID string) error {
|
||
|
|
_, err := r.pool.Exec(ctx, `
|
||
|
|
UPDATE autoscan_webhook_endpoints
|
||
|
|
SET last_received_at = now()
|
||
|
|
WHERE source_id = $1`, sourceID)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("touch autoscan webhook received: %w", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// RecordWebhookError stores the endpoint's most recent parse/ingest failure
|
||
|
|
// (length-bounded; the caller sanitizes payload-derived content).
|
||
|
|
func (r *Repository) RecordWebhookError(ctx context.Context, sourceID, msg string) error {
|
||
|
|
_, err := r.pool.Exec(ctx, `
|
||
|
|
UPDATE autoscan_webhook_endpoints
|
||
|
|
SET last_error_at = now(), last_error_message = $2
|
||
|
|
WHERE source_id = $1`, sourceID, truncateUTF8(msg, maxLastErrorLen))
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("record autoscan webhook error: %w", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ClearWebhookError removes stale delivery failure state after a queued retry
|
||
|
|
// succeeds. last_received_at remains the time the provider actually delivered
|
||
|
|
// the webhook rather than being rewritten as a processing timestamp.
|
||
|
|
func (r *Repository) ClearWebhookError(ctx context.Context, sourceID string) error {
|
||
|
|
_, err := r.pool.Exec(ctx, `
|
||
|
|
UPDATE autoscan_webhook_endpoints
|
||
|
|
SET last_error_at = NULL, last_error_message = ''
|
||
|
|
WHERE source_id = $1`, sourceID)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("clear autoscan webhook error: %w", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|