537 lines
15 KiB
Go
537 lines
15 KiB
Go
package plugins
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/secret"
|
|
)
|
|
|
|
var (
|
|
ErrAuthBindingNotFound = errors.New("plugin auth binding not found")
|
|
ErrTaskBindingNotFound = errors.New("plugin task binding not found")
|
|
)
|
|
|
|
type RuntimeConfig struct {
|
|
InstallationID int
|
|
Key string
|
|
Value map[string]any
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type AuthBinding struct {
|
|
InstallationID int
|
|
CapabilityID string
|
|
Enabled bool
|
|
DisplayOrder int
|
|
AutoProvision bool
|
|
DefaultLogin bool
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type TaskBinding struct {
|
|
InstallationID int
|
|
CapabilityID string
|
|
Enabled bool
|
|
Trigger map[string]any
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type RuntimeConfigStore struct {
|
|
pool *pgxpool.Pool
|
|
cipher *secret.Cipher
|
|
}
|
|
|
|
// plugin_runtime_configs.config_value is intentionally an opaque, whole-row
|
|
// encrypted envelope. Plugins may write undeclared keys and manifest secret
|
|
// annotations can drift or be unavailable during startup backfill, so field
|
|
// classification is an Admin redaction concern, not the at-rest boundary.
|
|
// Runtime code must use RuntimeConfigStore rather than querying JSON members.
|
|
const encryptedRuntimeConfigField = "__silo_encrypted_runtime_config_v1"
|
|
|
|
// NewRuntimeConfigStore creates the plugin config store. Production callers
|
|
// pass the server data cipher; the variadic form keeps DB-only tests concise.
|
|
func NewRuntimeConfigStore(pool *pgxpool.Pool, ciphers ...*secret.Cipher) *RuntimeConfigStore {
|
|
var cipher *secret.Cipher
|
|
if len(ciphers) > 0 {
|
|
cipher = ciphers[0]
|
|
}
|
|
return &RuntimeConfigStore{pool: pool, cipher: cipher}
|
|
}
|
|
|
|
func (s *RuntimeConfigStore) PutGlobalConfig(
|
|
ctx context.Context,
|
|
installationID int,
|
|
key string,
|
|
value map[string]any,
|
|
) error {
|
|
if value == nil {
|
|
value = map[string]any{}
|
|
}
|
|
valueJSON, err := encodeRuntimeConfigValue(s.cipher, installationID, key, value)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling plugin runtime config: %w", err)
|
|
}
|
|
|
|
_, err = s.pool.Exec(ctx, `
|
|
INSERT INTO plugin_runtime_configs (plugin_installation_id, config_key, config_value)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (plugin_installation_id, config_key) DO UPDATE SET
|
|
config_value = EXCLUDED.config_value,
|
|
updated_at = NOW()
|
|
`, installationID, key, valueJSON)
|
|
if err != nil {
|
|
return fmt.Errorf("upserting plugin runtime config: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CompareAndSwapGlobalConfig persists value only when the row still matches
|
|
// the version the caller merged. A nil expectedUpdatedAt creates the row only
|
|
// when it does not already exist.
|
|
func (s *RuntimeConfigStore) CompareAndSwapGlobalConfig(
|
|
ctx context.Context,
|
|
installationID int,
|
|
key string,
|
|
value map[string]any,
|
|
expectedUpdatedAt *time.Time,
|
|
) (bool, error) {
|
|
if value == nil {
|
|
value = map[string]any{}
|
|
}
|
|
valueJSON, err := encodeRuntimeConfigValue(s.cipher, installationID, key, value)
|
|
if err != nil {
|
|
return false, fmt.Errorf("marshaling plugin runtime config: %w", err)
|
|
}
|
|
|
|
var tag pgconn.CommandTag
|
|
if expectedUpdatedAt == nil {
|
|
tag, err = s.pool.Exec(ctx, `
|
|
INSERT INTO plugin_runtime_configs (plugin_installation_id, config_key, config_value)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (plugin_installation_id, config_key) DO NOTHING
|
|
`, installationID, key, valueJSON)
|
|
} else {
|
|
tag, err = s.pool.Exec(ctx, `
|
|
UPDATE plugin_runtime_configs
|
|
SET config_value = $3, updated_at = NOW()
|
|
WHERE plugin_installation_id = $1
|
|
AND config_key = $2
|
|
AND updated_at = $4
|
|
`, installationID, key, valueJSON, *expectedUpdatedAt)
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("compare-and-swap plugin runtime config: %w", err)
|
|
}
|
|
return tag.RowsAffected() > 0, nil
|
|
}
|
|
|
|
func (s *RuntimeConfigStore) ListGlobalConfigs(ctx context.Context, installationID int) ([]*RuntimeConfig, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT plugin_installation_id, config_key, config_value, created_at, updated_at
|
|
FROM plugin_runtime_configs
|
|
WHERE plugin_installation_id = $1
|
|
ORDER BY config_key ASC
|
|
`, installationID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing plugin runtime configs: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var configs []*RuntimeConfig
|
|
for rows.Next() {
|
|
var config RuntimeConfig
|
|
var valueJSON []byte
|
|
if err := rows.Scan(
|
|
&config.InstallationID,
|
|
&config.Key,
|
|
&valueJSON,
|
|
&config.CreatedAt,
|
|
&config.UpdatedAt,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scanning plugin runtime config: %w", err)
|
|
}
|
|
config.Value, err = decodeRuntimeConfigValue(s.cipher, config.InstallationID, config.Key, valueJSON)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decoding plugin runtime config %d/%s: %w", config.InstallationID, config.Key, err)
|
|
}
|
|
configs = append(configs, &config)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating plugin runtime configs: %w", err)
|
|
}
|
|
return configs, nil
|
|
}
|
|
|
|
func runtimeConfigAAD(installationID int, key string) string {
|
|
return secret.RowAAD(
|
|
"plugin_runtime_configs",
|
|
"config_value",
|
|
strconv.Itoa(installationID)+":"+key,
|
|
)
|
|
}
|
|
|
|
func encodeRuntimeConfigValue(
|
|
cipher *secret.Cipher,
|
|
installationID int,
|
|
key string,
|
|
value map[string]any,
|
|
) ([]byte, error) {
|
|
if value == nil {
|
|
value = map[string]any{}
|
|
}
|
|
plaintext, err := json.Marshal(value)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshaling plugin runtime config: %w", err)
|
|
}
|
|
return encodeRuntimeConfigJSON(cipher, installationID, key, plaintext)
|
|
}
|
|
|
|
func encodeRuntimeConfigJSON(
|
|
cipher *secret.Cipher,
|
|
installationID int,
|
|
key string,
|
|
plaintext []byte,
|
|
) ([]byte, error) {
|
|
if !json.Valid(plaintext) {
|
|
return nil, errors.New("plugin runtime config is not valid JSON")
|
|
}
|
|
if cipher == nil {
|
|
return append([]byte(nil), plaintext...), nil
|
|
}
|
|
ciphertext, err := cipher.Encrypt(string(plaintext), runtimeConfigAAD(installationID, key))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encrypting plugin runtime config: %w", err)
|
|
}
|
|
wrapped, err := json.Marshal(map[string]string{encryptedRuntimeConfigField: ciphertext})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshaling encrypted plugin runtime config: %w", err)
|
|
}
|
|
return wrapped, nil
|
|
}
|
|
|
|
func decodeRuntimeConfigValue(
|
|
cipher *secret.Cipher,
|
|
installationID int,
|
|
key string,
|
|
valueJSON []byte,
|
|
) (map[string]any, error) {
|
|
if len(valueJSON) == 0 {
|
|
return map[string]any{}, nil
|
|
}
|
|
var wrapped map[string]json.RawMessage
|
|
if err := json.Unmarshal(valueJSON, &wrapped); err != nil {
|
|
return nil, fmt.Errorf("unmarshaling plugin runtime config: %w", err)
|
|
}
|
|
if rawCiphertext, ok := wrapped[encryptedRuntimeConfigField]; ok && len(wrapped) == 1 {
|
|
if cipher == nil {
|
|
return nil, errors.New("encrypted plugin runtime config requires the server data cipher")
|
|
}
|
|
var ciphertext string
|
|
if err := json.Unmarshal(rawCiphertext, &ciphertext); err != nil || !secret.IsEncrypted(ciphertext) {
|
|
return nil, errors.New("invalid encrypted plugin runtime config envelope")
|
|
}
|
|
plaintext, err := cipher.Decrypt(ciphertext, runtimeConfigAAD(installationID, key))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decrypting plugin runtime config: %w", err)
|
|
}
|
|
valueJSON = []byte(plaintext)
|
|
}
|
|
var value map[string]any
|
|
decoder := json.NewDecoder(bytes.NewReader(valueJSON))
|
|
decoder.UseNumber()
|
|
if err := decoder.Decode(&value); err != nil {
|
|
return nil, fmt.Errorf("unmarshaling plugin runtime config value: %w", err)
|
|
}
|
|
if value == nil {
|
|
value = map[string]any{}
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
// BackfillEncryptedConfigs wraps legacy plaintext JSON objects with the same
|
|
// row-bound encryption used by PutGlobalConfig. It is idempotent and may be
|
|
// rerun after a partial failure.
|
|
func (s *RuntimeConfigStore) BackfillEncryptedConfigs(ctx context.Context) (int, error) {
|
|
if s == nil || s.pool == nil || s.cipher == nil {
|
|
return 0, nil
|
|
}
|
|
return backfillEncryptedConfigs(ctx, s.pool, s.cipher)
|
|
}
|
|
|
|
func backfillEncryptedConfigs(
|
|
ctx context.Context,
|
|
db secret.Executor,
|
|
cipher *secret.Cipher,
|
|
) (int, error) {
|
|
rows, err := db.Query(ctx, `
|
|
SELECT id, plugin_installation_id, config_key, config_value
|
|
FROM plugin_runtime_configs
|
|
ORDER BY id ASC
|
|
`)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("listing plugin runtime configs for encryption backfill: %w", err)
|
|
}
|
|
type rowValue struct {
|
|
id int64
|
|
installationID int
|
|
key string
|
|
valueJSON []byte
|
|
}
|
|
var pending []rowValue
|
|
for rows.Next() {
|
|
var row rowValue
|
|
if err := rows.Scan(&row.id, &row.installationID, &row.key, &row.valueJSON); err != nil {
|
|
rows.Close()
|
|
return 0, fmt.Errorf("scanning plugin runtime config for encryption backfill: %w", err)
|
|
}
|
|
var wrapped map[string]json.RawMessage
|
|
if err := json.Unmarshal(row.valueJSON, &wrapped); err != nil {
|
|
rows.Close()
|
|
return 0, fmt.Errorf("decode plugin runtime config %d for encryption backfill: %w", row.id, err)
|
|
}
|
|
if raw, ok := wrapped[encryptedRuntimeConfigField]; ok && len(wrapped) == 1 {
|
|
var ciphertext string
|
|
if json.Unmarshal(raw, &ciphertext) == nil && secret.IsEncrypted(ciphertext) {
|
|
continue
|
|
}
|
|
}
|
|
pending = append(pending, row)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return 0, fmt.Errorf("iterating plugin runtime configs for encryption backfill: %w", err)
|
|
}
|
|
rows.Close()
|
|
|
|
updated := 0
|
|
for _, row := range pending {
|
|
encoded, err := encodeRuntimeConfigJSON(
|
|
cipher,
|
|
row.installationID,
|
|
row.key,
|
|
row.valueJSON,
|
|
)
|
|
if err != nil {
|
|
return updated, fmt.Errorf("encrypt plugin runtime config %d: %w", row.id, err)
|
|
}
|
|
tag, err := db.Exec(ctx,
|
|
`UPDATE plugin_runtime_configs
|
|
SET config_value = $2, updated_at = updated_at
|
|
WHERE id = $1 AND config_value = $3::jsonb`,
|
|
row.id,
|
|
encoded,
|
|
row.valueJSON,
|
|
)
|
|
if err != nil {
|
|
return updated, fmt.Errorf("update plugin runtime config %d encryption backfill: %w", row.id, err)
|
|
}
|
|
if tag.RowsAffected() > 0 {
|
|
updated++
|
|
}
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
func (s *RuntimeConfigStore) UpsertAuthBinding(ctx context.Context, binding AuthBinding) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO plugin_auth_bindings (
|
|
plugin_installation_id, capability_id, enabled, display_order, auto_provision, default_login
|
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
|
ON CONFLICT (plugin_installation_id, capability_id) DO UPDATE SET
|
|
enabled = EXCLUDED.enabled,
|
|
display_order = EXCLUDED.display_order,
|
|
auto_provision = EXCLUDED.auto_provision,
|
|
default_login = EXCLUDED.default_login,
|
|
updated_at = NOW()
|
|
`,
|
|
binding.InstallationID,
|
|
binding.CapabilityID,
|
|
binding.Enabled,
|
|
binding.DisplayOrder,
|
|
binding.AutoProvision,
|
|
binding.DefaultLogin,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("upserting plugin auth binding: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *RuntimeConfigStore) GetAuthBinding(
|
|
ctx context.Context,
|
|
installationID int,
|
|
capabilityID string,
|
|
) (*AuthBinding, error) {
|
|
var binding AuthBinding
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT plugin_installation_id, capability_id, enabled, display_order, auto_provision, default_login, created_at, updated_at
|
|
FROM plugin_auth_bindings
|
|
WHERE plugin_installation_id = $1 AND capability_id = $2
|
|
`, installationID, capabilityID).Scan(
|
|
&binding.InstallationID,
|
|
&binding.CapabilityID,
|
|
&binding.Enabled,
|
|
&binding.DisplayOrder,
|
|
&binding.AutoProvision,
|
|
&binding.DefaultLogin,
|
|
&binding.CreatedAt,
|
|
&binding.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrAuthBindingNotFound
|
|
}
|
|
return nil, fmt.Errorf("getting plugin auth binding: %w", err)
|
|
}
|
|
return &binding, nil
|
|
}
|
|
|
|
func (s *RuntimeConfigStore) ListAuthBindings(ctx context.Context) ([]*AuthBinding, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT plugin_installation_id, capability_id, enabled, display_order, auto_provision, default_login, created_at, updated_at
|
|
FROM plugin_auth_bindings
|
|
ORDER BY display_order ASC, plugin_installation_id ASC, capability_id ASC
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing plugin auth bindings: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var bindings []*AuthBinding
|
|
for rows.Next() {
|
|
var binding AuthBinding
|
|
if err := rows.Scan(
|
|
&binding.InstallationID,
|
|
&binding.CapabilityID,
|
|
&binding.Enabled,
|
|
&binding.DisplayOrder,
|
|
&binding.AutoProvision,
|
|
&binding.DefaultLogin,
|
|
&binding.CreatedAt,
|
|
&binding.UpdatedAt,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scanning plugin auth binding: %w", err)
|
|
}
|
|
bindings = append(bindings, &binding)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating plugin auth bindings: %w", err)
|
|
}
|
|
return bindings, nil
|
|
}
|
|
|
|
func (s *RuntimeConfigStore) UpsertTaskBinding(ctx context.Context, binding TaskBinding) error {
|
|
trigger := binding.Trigger
|
|
if trigger == nil {
|
|
trigger = map[string]any{}
|
|
}
|
|
triggerJSON, err := json.Marshal(trigger)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling plugin task binding trigger: %w", err)
|
|
}
|
|
|
|
_, err = s.pool.Exec(ctx, `
|
|
INSERT INTO plugin_task_bindings (plugin_installation_id, capability_id, enabled, trigger)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (plugin_installation_id, capability_id) DO UPDATE SET
|
|
enabled = EXCLUDED.enabled,
|
|
trigger = EXCLUDED.trigger,
|
|
updated_at = NOW()
|
|
`,
|
|
binding.InstallationID,
|
|
binding.CapabilityID,
|
|
binding.Enabled,
|
|
triggerJSON,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("upserting plugin task binding: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *RuntimeConfigStore) GetTaskBinding(
|
|
ctx context.Context,
|
|
installationID int,
|
|
capabilityID string,
|
|
) (*TaskBinding, error) {
|
|
var binding TaskBinding
|
|
var triggerJSON []byte
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT plugin_installation_id, capability_id, enabled, trigger, created_at, updated_at
|
|
FROM plugin_task_bindings
|
|
WHERE plugin_installation_id = $1 AND capability_id = $2
|
|
`, installationID, capabilityID).Scan(
|
|
&binding.InstallationID,
|
|
&binding.CapabilityID,
|
|
&binding.Enabled,
|
|
&triggerJSON,
|
|
&binding.CreatedAt,
|
|
&binding.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrTaskBindingNotFound
|
|
}
|
|
return nil, fmt.Errorf("getting plugin task binding: %w", err)
|
|
}
|
|
binding.Trigger = map[string]any{}
|
|
if len(triggerJSON) > 0 {
|
|
if err := json.Unmarshal(triggerJSON, &binding.Trigger); err != nil {
|
|
return nil, fmt.Errorf("unmarshaling plugin task binding trigger: %w", err)
|
|
}
|
|
}
|
|
return &binding, nil
|
|
}
|
|
|
|
func (s *RuntimeConfigStore) ListTaskBindings(ctx context.Context) ([]*TaskBinding, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT plugin_installation_id, capability_id, enabled, trigger, created_at, updated_at
|
|
FROM plugin_task_bindings
|
|
ORDER BY plugin_installation_id ASC, capability_id ASC
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing plugin task bindings: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var bindings []*TaskBinding
|
|
for rows.Next() {
|
|
var binding TaskBinding
|
|
var triggerJSON []byte
|
|
if err := rows.Scan(
|
|
&binding.InstallationID,
|
|
&binding.CapabilityID,
|
|
&binding.Enabled,
|
|
&triggerJSON,
|
|
&binding.CreatedAt,
|
|
&binding.UpdatedAt,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scanning plugin task binding: %w", err)
|
|
}
|
|
binding.Trigger = map[string]any{}
|
|
if len(triggerJSON) > 0 {
|
|
if err := json.Unmarshal(triggerJSON, &binding.Trigger); err != nil {
|
|
return nil, fmt.Errorf("unmarshaling plugin task binding trigger: %w", err)
|
|
}
|
|
}
|
|
bindings = append(bindings, &binding)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating plugin task bindings: %w", err)
|
|
}
|
|
return bindings, nil
|
|
}
|