* feat(security): encrypt server-owned credentials at rest Introduce AES-256-GCM at-rest encryption (HKDF-derived from a required SECRET_KEY) for server-owned credentials, with row-bound AAD, a versioned enc:v1: envelope, and an idempotent startup backfill. - internal/secret: cipher + RowAAD/SettingsAAD + the startup backfill engine. - SECRET_KEY required at bootstrap; cipher threaded as an explicit dependency. - server_settings: EncryptedSettingsRepo decorator over the audited SensitiveSettingKeys (also drives admin redaction); the config watcher and watch-sync settings reads decrypt too. - Arr keys inline-encrypted; the ambiguous SecretResolver indirection removed from requests/autoscan. - Per-table columns encrypted: subtitles, watch-sync, webhook-sync (not webhook_secret), history-import, and the jellycompat session's bridged Silo access/refresh tokens. - Startup backfill (resolve-then-encrypt for arr refs) is best-effort and primary-node gated. Equality-looked-up secrets and plugin_runtime_configs.config_value are out of scope (need hashing / cross-repo design) — see docs/architecture/secret-encryption.md. Refs #45 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(compose): require SECRET_KEY in docker-compose The server now fatals without SECRET_KEY, so the integrated service (and the commented distributed proxy/transcode examples) pass it through with a fail-fast guard matching the existing MEDIA_ROOT pattern. Distributed worker nodes must use the SAME key as the primary to decrypt shared data. Generate with: openssl rand -base64 48. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(security): encrypt history import session credentials --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
385 lines
15 KiB
Go
385 lines
15 KiB
Go
package webhooksync
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/secret"
|
|
)
|
|
|
|
var ErrConnectionNotFound = errors.New("webhook sync connection not found")
|
|
|
|
type Repository struct {
|
|
pool *pgxpool.Pool
|
|
cipher *secret.Cipher
|
|
}
|
|
|
|
func NewRepository(pool *pgxpool.Pool, cipher *secret.Cipher) *Repository {
|
|
return &Repository{pool: pool, cipher: cipher}
|
|
}
|
|
|
|
// webhookTokenAAD binds a webhook_sync_connections access_token ciphertext to
|
|
// its row. Defined at package scope so the secret package reference is isolated
|
|
// from GetConnectionBySecret, whose parameter shadows the import name.
|
|
// (webhook_secret is NOT encrypted — it is matched by exact value in
|
|
// GetConnectionBySecret and is carved out for a separate hashing follow-up.)
|
|
func webhookTokenAAD(id string) string {
|
|
return secret.RowAAD("webhook_sync_connections", "access_token", id)
|
|
}
|
|
|
|
func (r *Repository) ProfileExistsForUser(ctx context.Context, userID int, profileID string) (bool, error) {
|
|
var exists bool
|
|
if err := r.pool.QueryRow(ctx,
|
|
`SELECT EXISTS(SELECT 1 FROM user_profiles WHERE user_id = $1 AND id = $2)`,
|
|
userID, profileID,
|
|
).Scan(&exists); err != nil {
|
|
return false, fmt.Errorf("checking profile ownership: %w", err)
|
|
}
|
|
return exists, nil
|
|
}
|
|
|
|
func (r *Repository) CreateConnection(ctx context.Context, conn Connection) (*Connection, error) {
|
|
accessToken, err := r.cipher.Encrypt(conn.AccessToken, webhookTokenAAD(conn.ID))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encrypt webhook access token: %w", err)
|
|
}
|
|
row := r.pool.QueryRow(ctx, `
|
|
INSERT INTO webhook_sync_connections (
|
|
id, user_id, provider, server_id, server_name, base_url, access_token, default_profile_id, webhook_secret
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING id, user_id, provider, server_id, server_name, base_url, access_token, default_profile_id,
|
|
webhook_secret, account_discovery_available,
|
|
last_webhook_received_at, last_webhook_error_at, COALESCE(last_webhook_error_message, ''),
|
|
created_at, updated_at`,
|
|
conn.ID, conn.UserID, conn.Provider, conn.ServerID, conn.ServerName, conn.BaseURL, accessToken, conn.DefaultProfileID, conn.WebhookSecret,
|
|
)
|
|
return r.scanConnection(row)
|
|
}
|
|
|
|
func (r *Repository) ListConnections(ctx context.Context, userID int) ([]Connection, error) {
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT c.id, c.user_id, c.provider, c.server_id, c.server_name, c.base_url, c.access_token, c.default_profile_id,
|
|
c.webhook_secret, c.account_discovery_available,
|
|
c.last_webhook_received_at, c.last_webhook_error_at, COALESCE(c.last_webhook_error_message, ''),
|
|
c.created_at, c.updated_at,
|
|
COUNT(m.id)::integer AS user_count
|
|
FROM webhook_sync_connections c
|
|
LEFT JOIN webhook_sync_profile_mappings m ON m.connection_id = c.id
|
|
WHERE c.user_id = $1
|
|
GROUP BY c.id
|
|
ORDER BY c.created_at DESC`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing webhook sync connections: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []Connection
|
|
for rows.Next() {
|
|
var c Connection
|
|
if err := rows.Scan(
|
|
&c.ID, &c.UserID, &c.Provider, &c.ServerID, &c.ServerName, &c.BaseURL, &c.AccessToken, &c.DefaultProfileID,
|
|
&c.WebhookSecret, &c.AccountDiscoveryAvailable,
|
|
&c.LastWebhookReceivedAt, &c.LastWebhookErrorAt, &c.LastWebhookErrorMessage,
|
|
&c.CreatedAt, &c.UpdatedAt, &c.UserCount,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scanning webhook sync connection: %w", err)
|
|
}
|
|
if c.AccessToken, err = r.cipher.DecryptIfEncrypted(c.AccessToken, webhookTokenAAD(c.ID)); err != nil {
|
|
return nil, fmt.Errorf("decrypt webhook access token: %w", err)
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating webhook sync connections: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *Repository) GetConnection(ctx context.Context, userID int, id string) (*Connection, error) {
|
|
row := r.pool.QueryRow(ctx, `
|
|
SELECT id, user_id, provider, server_id, server_name, base_url, access_token, default_profile_id,
|
|
webhook_secret, account_discovery_available,
|
|
last_webhook_received_at, last_webhook_error_at, COALESCE(last_webhook_error_message, ''),
|
|
created_at, updated_at
|
|
FROM webhook_sync_connections
|
|
WHERE id = $1 AND user_id = $2`, id, userID)
|
|
return r.scanConnection(row)
|
|
}
|
|
|
|
func (r *Repository) GetConnectionBySecret(ctx context.Context, secret string) (*Connection, error) {
|
|
row := r.pool.QueryRow(ctx, `
|
|
SELECT id, user_id, provider, server_id, server_name, base_url, access_token, default_profile_id,
|
|
webhook_secret, account_discovery_available,
|
|
last_webhook_received_at, last_webhook_error_at, COALESCE(last_webhook_error_message, ''),
|
|
created_at, updated_at
|
|
FROM webhook_sync_connections
|
|
WHERE webhook_secret = $1`, secret)
|
|
return r.scanConnection(row)
|
|
}
|
|
|
|
func (r *Repository) scanConnection(row pgx.Row) (*Connection, error) {
|
|
var c Connection
|
|
if err := row.Scan(
|
|
&c.ID, &c.UserID, &c.Provider, &c.ServerID, &c.ServerName, &c.BaseURL, &c.AccessToken, &c.DefaultProfileID,
|
|
&c.WebhookSecret, &c.AccountDiscoveryAvailable,
|
|
&c.LastWebhookReceivedAt, &c.LastWebhookErrorAt, &c.LastWebhookErrorMessage,
|
|
&c.CreatedAt, &c.UpdatedAt,
|
|
); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrConnectionNotFound
|
|
}
|
|
return nil, fmt.Errorf("scanning webhook sync connection: %w", err)
|
|
}
|
|
// Decrypt the access token (read-path). webhook_secret is intentionally left
|
|
// as-is (equality-looked-up; carved out for the hashing follow-up).
|
|
token, err := r.cipher.DecryptIfEncrypted(c.AccessToken, webhookTokenAAD(c.ID))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decrypt webhook access token: %w", err)
|
|
}
|
|
c.AccessToken = token
|
|
return &c, nil
|
|
}
|
|
|
|
func (r *Repository) UpdateConnection(ctx context.Context, userID int, id string, input UpdateConnectionInput) (*Connection, error) {
|
|
row := r.pool.QueryRow(ctx, `
|
|
UPDATE webhook_sync_connections
|
|
SET server_name = COALESCE($3, server_name),
|
|
default_profile_id = COALESCE($4, default_profile_id),
|
|
updated_at = NOW()
|
|
WHERE id = $1 AND user_id = $2
|
|
RETURNING id, user_id, provider, server_id, server_name, base_url, access_token, default_profile_id,
|
|
webhook_secret, account_discovery_available,
|
|
last_webhook_received_at, last_webhook_error_at, COALESCE(last_webhook_error_message, ''),
|
|
created_at, updated_at`,
|
|
id, userID, input.ServerName, input.DefaultProfileID,
|
|
)
|
|
return r.scanConnection(row)
|
|
}
|
|
|
|
func (r *Repository) DeleteConnection(ctx context.Context, userID int, id string) error {
|
|
result, err := r.pool.Exec(ctx, `DELETE FROM webhook_sync_connections WHERE id = $1 AND user_id = $2`, id, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("deleting webhook sync connection: %w", err)
|
|
}
|
|
if result.RowsAffected() == 0 {
|
|
return ErrConnectionNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) UpdateWebhookSecret(ctx context.Context, userID int, id, secret string) error {
|
|
result, err := r.pool.Exec(ctx, `
|
|
UPDATE webhook_sync_connections
|
|
SET webhook_secret = $3, updated_at = NOW()
|
|
WHERE id = $1 AND user_id = $2`, id, userID, secret)
|
|
if err != nil {
|
|
return fmt.Errorf("updating webhook secret: %w", err)
|
|
}
|
|
if result.RowsAffected() == 0 {
|
|
return ErrConnectionNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) SetDiscoveryAvailable(ctx context.Context, connectionID string, available bool) error {
|
|
_, err := r.pool.Exec(ctx, `
|
|
UPDATE webhook_sync_connections
|
|
SET account_discovery_available = $2, updated_at = NOW()
|
|
WHERE id = $1`, connectionID, available)
|
|
if err != nil {
|
|
return fmt.Errorf("updating discovery availability: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) ListMappings(ctx context.Context, connectionID string) ([]ProfileMapping, error) {
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT id, connection_id, external_user_id, external_user_name, silo_profile_id,
|
|
last_seen_at, created_at, updated_at
|
|
FROM webhook_sync_profile_mappings
|
|
WHERE connection_id = $1
|
|
ORDER BY external_user_name ASC, id ASC`, connectionID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing webhook sync mappings: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []ProfileMapping
|
|
for rows.Next() {
|
|
var m ProfileMapping
|
|
if err := rows.Scan(
|
|
&m.ID, &m.ConnectionID, &m.ExternalUserID, &m.ExternalUserName, &m.SiloProfileID,
|
|
&m.LastSeenAt, &m.CreatedAt, &m.UpdatedAt,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scanning webhook sync mapping: %w", err)
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating webhook sync mappings: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *Repository) GetMappingByUser(ctx context.Context, connectionID, externalUserID string) (*ProfileMapping, error) {
|
|
row := r.pool.QueryRow(ctx, `
|
|
SELECT id, connection_id, external_user_id, external_user_name, silo_profile_id,
|
|
last_seen_at, created_at, updated_at
|
|
FROM webhook_sync_profile_mappings
|
|
WHERE connection_id = $1 AND external_user_id = $2`, connectionID, externalUserID)
|
|
var m ProfileMapping
|
|
if err := row.Scan(
|
|
&m.ID, &m.ConnectionID, &m.ExternalUserID, &m.ExternalUserName, &m.SiloProfileID,
|
|
&m.LastSeenAt, &m.CreatedAt, &m.UpdatedAt,
|
|
); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("getting webhook sync mapping: %w", err)
|
|
}
|
|
return &m, nil
|
|
}
|
|
|
|
func (r *Repository) ReplaceMappings(ctx context.Context, connectionID string, mappings []UpdateProfileMapping) ([]ProfileMapping, error) {
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin replace webhook mappings: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx) //nolint:errcheck
|
|
|
|
if _, err := tx.Exec(ctx, `DELETE FROM webhook_sync_profile_mappings WHERE connection_id = $1`, connectionID); err != nil {
|
|
return nil, fmt.Errorf("deleting webhook sync mappings: %w", err)
|
|
}
|
|
|
|
out := make([]ProfileMapping, 0, len(mappings))
|
|
for _, input := range mappings {
|
|
row := tx.QueryRow(ctx, `
|
|
INSERT INTO webhook_sync_profile_mappings (
|
|
connection_id, external_user_id, external_user_name, silo_profile_id, last_seen_at
|
|
) VALUES ($1, $2, $3, $4, NOW())
|
|
RETURNING id, connection_id, external_user_id, external_user_name, silo_profile_id,
|
|
last_seen_at, created_at, updated_at`,
|
|
connectionID, input.ExternalUserID, input.ExternalUserName, input.SiloProfileID,
|
|
)
|
|
var m ProfileMapping
|
|
if err := row.Scan(
|
|
&m.ID, &m.ConnectionID, &m.ExternalUserID, &m.ExternalUserName, &m.SiloProfileID,
|
|
&m.LastSeenAt, &m.CreatedAt, &m.UpdatedAt,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("creating webhook sync mapping: %w", err)
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, fmt.Errorf("commit replace webhook mappings: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *Repository) CreateDefaultMapping(ctx context.Context, connectionID, externalUserID, externalUserName, profileID string) (*ProfileMapping, error) {
|
|
row := r.pool.QueryRow(ctx, `
|
|
INSERT INTO webhook_sync_profile_mappings (
|
|
connection_id, external_user_id, external_user_name, silo_profile_id, last_seen_at
|
|
) VALUES ($1, $2, $3, $4, NOW())
|
|
RETURNING id, connection_id, external_user_id, external_user_name, silo_profile_id,
|
|
last_seen_at, created_at, updated_at`,
|
|
connectionID, externalUserID, externalUserName, &profileID,
|
|
)
|
|
var m ProfileMapping
|
|
if err := row.Scan(
|
|
&m.ID, &m.ConnectionID, &m.ExternalUserID, &m.ExternalUserName, &m.SiloProfileID,
|
|
&m.LastSeenAt, &m.CreatedAt, &m.UpdatedAt,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("creating default webhook mapping: %w", err)
|
|
}
|
|
return &m, nil
|
|
}
|
|
|
|
func (r *Repository) UpsertSeenUser(ctx context.Context, connectionID, externalUserID, externalUserName string) error {
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO webhook_sync_profile_mappings (
|
|
connection_id, external_user_id, external_user_name, last_seen_at
|
|
) VALUES ($1, $2, $3, NOW())
|
|
ON CONFLICT (connection_id, external_user_id) DO UPDATE SET
|
|
external_user_name = EXCLUDED.external_user_name,
|
|
last_seen_at = NOW(),
|
|
updated_at = NOW()`,
|
|
connectionID, externalUserID, externalUserName,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("upserting seen external user: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) GetItemState(ctx context.Context, connectionID, externalUserID, externalItemID string) (*ItemState, error) {
|
|
row := r.pool.QueryRow(ctx, `
|
|
SELECT connection_id, external_user_id, external_item_id, COALESCE(media_item_id, ''),
|
|
last_event_at, last_completed, last_position_seconds, updated_at
|
|
FROM webhook_sync_item_state
|
|
WHERE connection_id = $1 AND external_user_id = $2 AND external_item_id = $3`,
|
|
connectionID, externalUserID, externalItemID,
|
|
)
|
|
var state ItemState
|
|
if err := row.Scan(
|
|
&state.ConnectionID, &state.ExternalUserID, &state.ExternalItemID, &state.MediaItemID,
|
|
&state.LastEventAt, &state.LastCompleted, &state.LastPositionSecond, &state.UpdatedAt,
|
|
); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("getting webhook item state: %w", err)
|
|
}
|
|
return &state, nil
|
|
}
|
|
|
|
func (r *Repository) UpsertItemState(ctx context.Context, state ItemState) error {
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO webhook_sync_item_state (
|
|
connection_id, external_user_id, external_item_id, media_item_id,
|
|
last_event_at, last_completed, last_position_seconds, updated_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
|
ON CONFLICT (connection_id, external_user_id, external_item_id) DO UPDATE SET
|
|
media_item_id = EXCLUDED.media_item_id,
|
|
last_event_at = EXCLUDED.last_event_at,
|
|
last_completed = EXCLUDED.last_completed,
|
|
last_position_seconds = EXCLUDED.last_position_seconds,
|
|
updated_at = NOW()`,
|
|
state.ConnectionID, state.ExternalUserID, state.ExternalItemID, state.MediaItemID,
|
|
state.LastEventAt, state.LastCompleted, state.LastPositionSecond,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("upserting webhook item state: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) MarkWebhookReceived(ctx context.Context, connectionID string) error {
|
|
_, err := r.pool.Exec(ctx, `
|
|
UPDATE webhook_sync_connections
|
|
SET last_webhook_received_at = NOW(),
|
|
last_webhook_error_at = NULL,
|
|
last_webhook_error_message = NULL,
|
|
updated_at = NOW()
|
|
WHERE id = $1`, connectionID)
|
|
if err != nil {
|
|
return fmt.Errorf("marking webhook receipt: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) MarkWebhookError(ctx context.Context, connectionID, message string) error {
|
|
_, err := r.pool.Exec(ctx, `
|
|
UPDATE webhook_sync_connections
|
|
SET last_webhook_error_at = NOW(), last_webhook_error_message = $2, updated_at = NOW()
|
|
WHERE id = $1`, connectionID, message)
|
|
if err != nil {
|
|
return fmt.Errorf("marking webhook error: %w", err)
|
|
}
|
|
return nil
|
|
}
|