diff --git a/internal/services/policy.go b/internal/services/policy.go new file mode 100644 index 0000000..fd4e1fd --- /dev/null +++ b/internal/services/policy.go @@ -0,0 +1,179 @@ +// Package services — policy.go: workflow selection. +// +// PolicyEngine answers: "given the scope of a new secret-update +// request, which WorkflowDefinition governs its approval?" +// +// Resolution algorithm: +// +// 1. Walk policy_rules in priority DESC, created_at ASC order +// (the repository's ListEnabledOrderedByPriority returns them +// pre-sorted, so the engine just iterates). +// 2. For each rule, check whether the rule's selector is a SUBSET of +// the request's scope — every key the rule specifies must match +// the same key in the scope. Keys the rule omits are wildcards. +// 3. The first rule that fully matches is the winner. +// 4. If no rule matches OR no rules exist, fall back to the workflow +// with is_default=true. The system seed migration ensures one +// exists at install time; if an admin deletes it, the engine +// returns ErrNoDefaultWorkflow and the API surface returns 500 +// loudly so the operator notices. +// +// Hot-path note: today this re-queries the DB on every Resolve call. +// PolicyEngine is designed to be wrappable by an in-memory cache +// later (Step 11 worker territory) when load demands it. The +// repository's resolution index makes the query a single seek. +package services + +import ( + "context" + "errors" + "fmt" + + "github.com/secrets-bridge/api/pkg/storage" +) + +// Scope describes the request that needs a workflow. Fields are +// nullable so the engine can match against partial information. +type Scope struct { + ProjectID string + Environment string + ProviderType string // "vault" | "aws-sm" | ... + SecretRefPrefix string + // Extras carries any additional dimensions the operator might add + // to a selector. Today only the four fixed dimensions above are + // used; Extras is reserved. + Extras map[string]any +} + +// ErrNoDefaultWorkflow signals a misconfigured deployment — no policy +// matched and the operator has removed the system default. The API +// surface should return a loud 500 so the misconfig gets noticed. +var ErrNoDefaultWorkflow = errors.New("services: no policy matched and no default workflow exists") + +// PolicyEngine resolves Scope → WorkflowDefinition. +type PolicyEngine struct { + policies storage.PolicyRepository + workflows storage.WorkflowRepository +} + +// NewPolicyEngine binds an engine to its repositories. +func NewPolicyEngine(p storage.PolicyRepository, w storage.WorkflowRepository) *PolicyEngine { + return &PolicyEngine{policies: p, workflows: w} +} + +// Resolve finds the workflow governing scope. Returns +// ErrNoDefaultWorkflow only when both the policy walk AND the default +// fallback fail. +func (e *PolicyEngine) Resolve(ctx context.Context, scope Scope) (*storage.WorkflowDefinition, *storage.PolicyRule, error) { + rules, err := e.policies.ListEnabledOrderedByPriority(ctx) + if err != nil { + return nil, nil, fmt.Errorf("services: load policies: %w", err) + } + + scopeMap := scope.asMap() + for _, rule := range rules { + if selectorMatches(rule.Selector, scopeMap) { + w, err := e.workflows.Get(ctx, rule.WorkflowID) + if err != nil { + return nil, nil, fmt.Errorf("services: load workflow %s: %w", rule.WorkflowID, err) + } + if !w.Enabled { + // Operator disabled the workflow but left the policy + // pointing at it — treat as no match and continue. + continue + } + return w, rule, nil + } + } + + // No rule matched. Fall back to the default workflow. + w, err := e.workflows.GetDefault(ctx) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return nil, nil, ErrNoDefaultWorkflow + } + return nil, nil, fmt.Errorf("services: load default workflow: %w", err) + } + if !w.Enabled { + return nil, nil, ErrNoDefaultWorkflow + } + return w, nil, nil +} + +// asMap normalises a Scope into a map shape the selector matcher can +// compare against. Empty fields are excluded so a rule that says +// `environment=prod` correctly fails to match a scope without an +// environment set (rather than treating empty as a wildcard). +func (s Scope) asMap() map[string]any { + out := make(map[string]any, 5+len(s.Extras)) + for k, v := range s.Extras { + out[k] = v + } + if s.ProjectID != "" { + out["project_id"] = s.ProjectID + } + if s.Environment != "" { + out["environment"] = s.Environment + } + if s.ProviderType != "" { + out["provider_type"] = s.ProviderType + } + if s.SecretRefPrefix != "" { + out["secret_ref_prefix"] = s.SecretRefPrefix + } + return out +} + +// selectorMatches returns true iff every key the selector specifies is +// present in scope with the same value. Keys absent from the selector +// are wildcards. +// +// Special case: when the selector key is "secret_ref_prefix", the +// scope's "secret_ref_prefix" value (the full ref) must START with the +// selector's value — that's what makes "myapp/" match +// "myapp/db-password". +func selectorMatches(selector, scope map[string]any) bool { + for key, want := range selector { + got, ok := scope[key] + if !ok { + return false + } + if key == "secret_ref_prefix" { + wantStr, _ := want.(string) + gotStr, _ := got.(string) + if wantStr == "" || gotStr == "" { + return false + } + if !startsWith(gotStr, wantStr) { + return false + } + continue + } + // Default: exact equality after JSON-trip-style normalisation. + // Numbers from JSON come back as float64; strings stay strings; + // bools stay bools. The selector values come from the DB JSONB + // column so they follow the same convention. + if !equalAny(want, got) { + return false + } + } + return true +} + +func startsWith(s, prefix string) bool { + return len(s) >= len(prefix) && s[:len(prefix)] == prefix +} + +func equalAny(a, b any) bool { + // Fast path: identical concrete types. + if a == b { + return true + } + // Strings cover almost all cases for policy selectors today. + aStr, aOK := a.(string) + bStr, bOK := b.(string) + if aOK && bOK { + return aStr == bStr + } + return false +} diff --git a/internal/services/policy_test.go b/internal/services/policy_test.go new file mode 100644 index 0000000..7f5b7db --- /dev/null +++ b/internal/services/policy_test.go @@ -0,0 +1,327 @@ +package services_test + +import ( + "errors" + "os" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/secrets-bridge/api/internal/services" + "github.com/secrets-bridge/api/pkg/storage" +) + +func bootstrapPolicy(t *testing.T) (*services.PolicyEngine, *storage.Pool, *storage.Policies, *storage.Workflows) { + t.Helper() + dbDSN := os.Getenv("TEST_DATABASE_URL") + if dbDSN == "" { + t.Skip("TEST_DATABASE_URL required; skipping") + } + ctx := t.Context() + cfg := storage.Config{DSN: dbDSN, MaxConns: 6, ConnLifetime: 5 * time.Minute} + if err := storage.Migrate(ctx, cfg); err != nil { + t.Fatalf("Migrate: %v", err) + } + pool, err := storage.Open(ctx, cfg) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(pool.Close) + + // Need to keep the system seed rows from migration 0005 (default + // workflow + match-all policy) — tests rely on them. So we + // DELETE only non-system rows from the workflow-engine tables, + // and TRUNCATE audit_events (which has a no-DELETE trigger). + const wipeWorkflow = ` + DELETE FROM policy_rules WHERE is_system = false; + DELETE FROM workflow_definitions WHERE is_system = false; + DELETE FROM user_roles; + DELETE FROM roles WHERE is_system = false;` + if _, err := pool.Exec(ctx, wipeWorkflow); err != nil { + t.Fatalf("wipe workflow tables: %v", err) + } + if _, err := pool.Exec(ctx, "TRUNCATE audit_events"); err != nil { + t.Fatalf("truncate audit_events: %v", err) + } + + policies := storage.NewPolicies(pool) + workflows := storage.NewWorkflows(pool) + engine := services.NewPolicyEngine(policies, workflows) + return engine, pool, policies, workflows +} + +// The seed rows in migration 0005 give us a working default-only +// configuration. Resolve with an empty scope must return the seed +// workflow ("standard"), via the seed policy ("match-all"). +func TestResolve_FallsBackToSystemDefault(t *testing.T) { + engine, _, _, _ := bootstrapPolicy(t) + + w, rule, err := engine.Resolve(t.Context(), services.Scope{}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if w == nil || w.Name != "standard" { + t.Fatalf("default workflow: got %+v", w) + } + if rule == nil || rule.Name != "match-all (system default)" { + t.Fatalf("matched rule: got %+v", rule) + } +} + +func TestResolve_ExactMatchPolicyTakesPrecedence(t *testing.T) { + engine, _, policies, workflows := bootstrapPolicy(t) + ctx := t.Context() + + // Create a stricter workflow for prod environments. + strict := &storage.WorkflowDefinition{ + Name: "strict-prod", Description: "Two approvers, no self-approve", + MinApprovers: 2, AllowSelfApproval: false, + WrapTTLCreated: 24 * time.Hour, WrapTTLApproved: time.Hour, + WrapTTLClaimed: 5 * time.Minute, RequestTTL: 7 * 24 * time.Hour, + RequireJustification: true, Enabled: true, + } + if err := workflows.Create(ctx, strict); err != nil { + t.Fatalf("Create workflow: %v", err) + } + + rule := &storage.PolicyRule{ + Name: "prod-only", + Selector: map[string]any{"environment": "prod"}, + WorkflowID: strict.ID, + Priority: 100, // higher than the seed match-all (priority 0) + Enabled: true, + } + if err := policies.Create(ctx, rule); err != nil { + t.Fatalf("Create policy: %v", err) + } + + // prod scope → strict workflow. + w, matched, err := engine.Resolve(ctx, services.Scope{Environment: "prod"}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if w.Name != "strict-prod" { + t.Fatalf("prod scope: got workflow %q want strict-prod", w.Name) + } + if matched.Name != "prod-only" { + t.Fatalf("matched rule: %+v", matched) + } + + // dev scope → falls through to system default. + w2, _, err := engine.Resolve(ctx, services.Scope{Environment: "dev"}) + if err != nil { + t.Fatalf("Resolve dev: %v", err) + } + if w2.Name != "standard" { + t.Fatalf("dev scope: got %q want standard", w2.Name) + } +} + +func TestResolve_HigherPriorityWins(t *testing.T) { + engine, _, policies, workflows := bootstrapPolicy(t) + ctx := t.Context() + + // Two workflows competing for the same scope. + for _, name := range []string{"low-prio-wf", "high-prio-wf"} { + if err := workflows.Create(ctx, &storage.WorkflowDefinition{ + Name: name, MinApprovers: 1, + WrapTTLCreated: 24 * time.Hour, WrapTTLApproved: time.Hour, + WrapTTLClaimed: 5 * time.Minute, RequestTTL: 7 * 24 * time.Hour, + Enabled: true, + }); err != nil { + t.Fatalf("Create %s: %v", name, err) + } + } + lowWF, _ := workflows.GetByName(ctx, "low-prio-wf") + highWF, _ := workflows.GetByName(ctx, "high-prio-wf") + + // Lower priority added FIRST so created_at ordering won't save it. + if err := policies.Create(ctx, &storage.PolicyRule{ + Name: "low-prio-match", + Selector: map[string]any{"environment": "prod"}, + WorkflowID: lowWF.ID, Priority: 50, Enabled: true, + }); err != nil { + t.Fatalf("low rule: %v", err) + } + if err := policies.Create(ctx, &storage.PolicyRule{ + Name: "high-prio-match", + Selector: map[string]any{"environment": "prod"}, + WorkflowID: highWF.ID, Priority: 500, Enabled: true, + }); err != nil { + t.Fatalf("high rule: %v", err) + } + + w, matched, err := engine.Resolve(ctx, services.Scope{Environment: "prod"}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if w.Name != "high-prio-wf" { + t.Fatalf("priority resolution: got %q want high-prio-wf", w.Name) + } + if matched.Name != "high-prio-match" { + t.Fatalf("matched rule: got %+v", matched) + } +} + +func TestResolve_PartialMatchDoesNotApply(t *testing.T) { + engine, _, policies, workflows := bootstrapPolicy(t) + ctx := t.Context() + wf := &storage.WorkflowDefinition{ + Name: "strict", MinApprovers: 2, AllowSelfApproval: false, + WrapTTLCreated: 24 * time.Hour, WrapTTLApproved: time.Hour, + WrapTTLClaimed: 5 * time.Minute, RequestTTL: 7 * 24 * time.Hour, Enabled: true, + } + _ = workflows.Create(ctx, wf) + _ = policies.Create(ctx, &storage.PolicyRule{ + Name: "needs-both", Selector: map[string]any{ + "environment": "prod", + "provider_type": "vault", + }, + WorkflowID: wf.ID, Priority: 100, Enabled: true, + }) + + // Scope matches only one of the two selector keys → rule doesn't + // apply. Falls through to default. + w, _, err := engine.Resolve(ctx, services.Scope{ + Environment: "prod", + ProviderType: "aws-sm", // ← mismatched + }) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if w.Name != "standard" { + t.Fatalf("partial match should fall through to default, got %q", w.Name) + } +} + +func TestResolve_SecretRefPrefixMatch(t *testing.T) { + engine, _, policies, workflows := bootstrapPolicy(t) + ctx := t.Context() + wf := &storage.WorkflowDefinition{ + Name: "myapp-wf", MinApprovers: 2, + WrapTTLCreated: 24 * time.Hour, WrapTTLApproved: time.Hour, + WrapTTLClaimed: 5 * time.Minute, RequestTTL: 7 * 24 * time.Hour, Enabled: true, + } + _ = workflows.Create(ctx, wf) + _ = policies.Create(ctx, &storage.PolicyRule{ + Name: "myapp-prefix", + Selector: map[string]any{ + "secret_ref_prefix": "myapp/", + }, + WorkflowID: wf.ID, Priority: 100, Enabled: true, + }) + + // Matching prefix → custom workflow. + w1, _, _ := engine.Resolve(ctx, services.Scope{SecretRefPrefix: "myapp/db-password"}) + if w1.Name != "myapp-wf" { + t.Fatalf("prefix match: got %q", w1.Name) + } + // Non-matching prefix → default. + w2, _, _ := engine.Resolve(ctx, services.Scope{SecretRefPrefix: "otherapp/api-key"}) + if w2.Name != "standard" { + t.Fatalf("prefix non-match: got %q", w2.Name) + } +} + +func TestResolve_DisabledRuleSkipped(t *testing.T) { + engine, _, policies, workflows := bootstrapPolicy(t) + ctx := t.Context() + wf := &storage.WorkflowDefinition{ + Name: "should-not-resolve", MinApprovers: 2, + WrapTTLCreated: 24 * time.Hour, WrapTTLApproved: time.Hour, + WrapTTLClaimed: 5 * time.Minute, RequestTTL: 7 * 24 * time.Hour, Enabled: true, + } + _ = workflows.Create(ctx, wf) + _ = policies.Create(ctx, &storage.PolicyRule{ + Name: "disabled-rule", Selector: map[string]any{"environment": "prod"}, + WorkflowID: wf.ID, Priority: 999, Enabled: false, + }) + + w, _, _ := engine.Resolve(ctx, services.Scope{Environment: "prod"}) + if w.Name != "standard" { + t.Fatalf("disabled rule must be skipped, got workflow %q", w.Name) + } +} + +func TestResolve_DisabledWorkflowFalsThrough(t *testing.T) { + engine, _, policies, workflows := bootstrapPolicy(t) + ctx := t.Context() + wf := &storage.WorkflowDefinition{ + Name: "soft-disabled", MinApprovers: 2, + WrapTTLCreated: 24 * time.Hour, WrapTTLApproved: time.Hour, + WrapTTLClaimed: 5 * time.Minute, RequestTTL: 7 * 24 * time.Hour, Enabled: false, + } + _ = workflows.Create(ctx, wf) + _ = policies.Create(ctx, &storage.PolicyRule{ + Name: "points-at-disabled", Selector: map[string]any{"environment": "prod"}, + WorkflowID: wf.ID, Priority: 999, Enabled: true, + }) + + w, _, _ := engine.Resolve(ctx, services.Scope{Environment: "prod"}) + if w.Name != "standard" { + t.Fatalf("rule points at disabled workflow; should fall through, got %q", w.Name) + } +} + +func TestRoles_DeleteSystemRowRejected(t *testing.T) { + _, pool, _, _ := bootstrapPolicy(t) + ctx := t.Context() + roles := storage.NewRoles(pool) + + admin, err := roles.GetByName(ctx, "admin") + if err != nil { + t.Fatalf("seed admin role missing: %v", err) + } + if err := roles.Delete(ctx, admin.ID); !errors.Is(err, storage.ErrSystemRow) { + t.Fatalf("deleting system role: got %v want ErrSystemRow", err) + } +} + +func TestUserRoles_GrantAndList(t *testing.T) { + _, pool, _, _ := bootstrapPolicy(t) + ctx := t.Context() + roles := storage.NewRoles(pool) + urs := storage.NewUserRoles(pool) + + approver, _ := roles.GetByName(ctx, "approver") + + ur := &storage.UserRole{ + UserID: "alice@example.com", + RoleID: approver.ID, + Scope: map[string]any{"environment": "prod"}, + GrantedBy: "admin@example.com", + } + if err := urs.Grant(ctx, ur); err != nil { + t.Fatalf("Grant: %v", err) + } + if ur.ID == uuid.Nil { + t.Fatal("Grant did not populate ID") + } + + listed, err := urs.ListByUser(ctx, "alice@example.com") + if err != nil { + t.Fatalf("ListByUser: %v", err) + } + if len(listed) != 1 || listed[0].RoleID != approver.ID { + t.Fatalf("ListByUser: %+v", listed) + } + if listed[0].Scope["environment"] != "prod" { + t.Fatalf("scope not preserved: %+v", listed[0].Scope) + } +} + +func TestWorkflows_OnlyOneDefault(t *testing.T) { + _, _, _, workflows := bootstrapPolicy(t) + ctx := t.Context() + // The seed inserted "standard" as default. Trying to create + // another default must fail (partial unique index). + dup := &storage.WorkflowDefinition{ + Name: "another-default", MinApprovers: 1, IsDefault: true, + WrapTTLCreated: 24 * time.Hour, WrapTTLApproved: time.Hour, + WrapTTLClaimed: 5 * time.Minute, RequestTTL: 7 * 24 * time.Hour, Enabled: true, + } + if err := workflows.Create(ctx, dup); err == nil { + t.Fatal("schema must reject a second is_default=true row") + } +} diff --git a/pkg/storage/migrations/0005_workflow_engine.down.sql b/pkg/storage/migrations/0005_workflow_engine.down.sql new file mode 100644 index 0000000..0eb570e --- /dev/null +++ b/pkg/storage/migrations/0005_workflow_engine.down.sql @@ -0,0 +1,17 @@ +BEGIN; + +DROP TRIGGER IF EXISTS policy_rules_touch_updated_at ON policy_rules; +DROP TRIGGER IF EXISTS workflow_definitions_touch_updated_at ON workflow_definitions; +DROP TRIGGER IF EXISTS roles_touch_updated_at ON roles; + +DROP INDEX IF EXISTS policy_rules_resolution_idx; +DROP INDEX IF EXISTS workflow_definitions_one_default; +DROP INDEX IF EXISTS user_roles_role_idx; +DROP INDEX IF EXISTS user_roles_user_idx; + +DROP TABLE IF EXISTS policy_rules; +DROP TABLE IF EXISTS workflow_definitions; +DROP TABLE IF EXISTS user_roles; +DROP TABLE IF EXISTS roles; + +COMMIT; diff --git a/pkg/storage/migrations/0005_workflow_engine.up.sql b/pkg/storage/migrations/0005_workflow_engine.up.sql new file mode 100644 index 0000000..2b10ef8 --- /dev/null +++ b/pkg/storage/migrations/0005_workflow_engine.up.sql @@ -0,0 +1,169 @@ +-- 0005 — Dynamic workflow + policy engine. +-- +-- Everything an operator might want to tune (roles, who approves what, +-- TTLs, separation-of-duties) is now ROWS, not constants. Admin UI +-- (Piece 5) edits these tables; PolicyEngine (this PR) resolves the +-- right workflow for a given request scope at runtime. +-- +-- Tables: +-- roles — admin-defined permission bundles +-- user_roles — RBAC assignments with optional scope narrowing +-- workflow_definitions — approval templates (TTLs, # of approvers, …) +-- policy_rules — selector → workflow mapping with priority +-- +-- A small set of system rows is seeded at the bottom so the platform +-- starts in a usable state: one admin role, one default "standard" +-- workflow, one match-all policy pointing at the default workflow. +-- is_system rows can be edited but not deleted (enforced at the +-- service layer, not the schema, so super-admins still have an out). + +BEGIN; + +-- --------------------------------------------------------------- +-- roles — permission bundles +-- --------------------------------------------------------------- +CREATE TABLE roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + -- Array of action strings, e.g. ["secret.request","secret.approve"]. + -- Wildcards may be supported later; today exact-match only. + permissions JSONB NOT NULL DEFAULT '[]', + is_system BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER roles_touch_updated_at + BEFORE UPDATE ON roles + FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); + +-- --------------------------------------------------------------- +-- user_roles — assignments with optional scope narrowing +-- --------------------------------------------------------------- +CREATE TABLE user_roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id TEXT NOT NULL, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + -- Empty {} = global grant. Narrow with project_id, environment, etc. + scope JSONB NOT NULL DEFAULT '{}', + granted_by TEXT, + granted_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX user_roles_user_idx ON user_roles (user_id); +CREATE INDEX user_roles_role_idx ON user_roles (role_id); + +-- --------------------------------------------------------------- +-- workflow_definitions — admin-defined approval templates +-- --------------------------------------------------------------- +CREATE TABLE workflow_definitions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + -- 0 = auto-approve. 1 = single approver. ≥2 = multi-stage. + min_approvers INTEGER NOT NULL DEFAULT 1 + CHECK (min_approvers >= 0), + -- Which role's members can approve this workflow's requests. + -- NULL means "any user with the secret.approve permission". + approver_role_id UUID REFERENCES roles(id) ON DELETE SET NULL, + -- TTLs for the secret_wraps lifecycle. WrapService.Refresh() is + -- called with the relevant value on each state transition. + wrap_ttl_created INTERVAL NOT NULL DEFAULT '7 days', + wrap_ttl_approved INTERVAL NOT NULL DEFAULT '1 hour', + wrap_ttl_claimed INTERVAL NOT NULL DEFAULT '5 minutes', + -- How long the whole request lives in the queue before it + -- auto-expires (no approval action). + request_ttl INTERVAL NOT NULL DEFAULT '14 days', + require_justification BOOLEAN NOT NULL DEFAULT true, + -- Separation of duties: requester ≠ approver. Off only for + -- low-risk workflows (dev environments, etc.). + allow_self_approval BOOLEAN NOT NULL DEFAULT false, + -- e.g. ["slack:#sec-approvals","email:approvers@..."] + notification_channels JSONB NOT NULL DEFAULT '[]', + -- Exactly one workflow can carry is_default = true. Used when + -- no policy_rule matches a request's scope. + is_default BOOLEAN NOT NULL DEFAULT false, + enabled BOOLEAN NOT NULL DEFAULT true, + is_system BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Partial unique index: only one row can be the default. +CREATE UNIQUE INDEX workflow_definitions_one_default + ON workflow_definitions ((is_default)) + WHERE is_default = true; + +CREATE TRIGGER workflow_definitions_touch_updated_at + BEFORE UPDATE ON workflow_definitions + FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); + +-- --------------------------------------------------------------- +-- policy_rules — selector → workflow mapping with priority +-- --------------------------------------------------------------- +CREATE TABLE policy_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + -- JSON object whose keys are scope dimensions: + -- {"project_id": "...", "environment": "prod", + -- "provider_type": "vault", "secret_ref_prefix": "myapp/"} + -- Every present key must match the incoming request for the rule + -- to apply. Absent keys are wildcards. + selector JSONB NOT NULL DEFAULT '{}', + workflow_id UUID NOT NULL REFERENCES workflow_definitions(id) ON DELETE CASCADE, + -- Higher priority wins. The system seed rule uses priority 0 so + -- any operator-added rule (priority 100+) takes precedence. + priority INTEGER NOT NULL DEFAULT 100, + enabled BOOLEAN NOT NULL DEFAULT true, + is_system BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX policy_rules_resolution_idx + ON policy_rules (priority DESC, created_at ASC) + WHERE enabled = true; + +CREATE TRIGGER policy_rules_touch_updated_at + BEFORE UPDATE ON policy_rules + FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); + +-- --------------------------------------------------------------- +-- System seed rows +-- --------------------------------------------------------------- +INSERT INTO roles (name, description, permissions, is_system) VALUES + ('admin', 'Platform administrators', + '["role.edit","user_role.edit","workflow.edit","policy.edit","agent.mint","agent.revoke","secret.request","secret.approve","audit.read"]'::jsonb, + true), + ('approver', 'Can approve or reject secret update requests', + '["secret.approve","audit.read"]'::jsonb, + true), + ('developer', 'Can submit secret update requests', + '["secret.request","audit.read"]'::jsonb, + true); + +INSERT INTO workflow_definitions + (name, description, min_approvers, approver_role_id, + wrap_ttl_created, wrap_ttl_approved, wrap_ttl_claimed, + request_ttl, require_justification, allow_self_approval, + notification_channels, is_default, is_system) +SELECT + 'standard', + 'Default workflow — single approver, 7-day wrap, 1h post-approval.', + 1, + (SELECT id FROM roles WHERE name = 'approver'), + '7 days'::interval, '1 hour'::interval, '5 minutes'::interval, + '14 days'::interval, true, false, + '[]'::jsonb, true, true; + +INSERT INTO policy_rules + (name, selector, workflow_id, priority, is_system) +SELECT + 'match-all (system default)', + '{}'::jsonb, + (SELECT id FROM workflow_definitions WHERE name = 'standard'), + 0, -- lowest priority; operator rules at 100+ take precedence + true; + +COMMIT; diff --git a/pkg/storage/policies.go b/pkg/storage/policies.go new file mode 100644 index 0000000..294caf0 --- /dev/null +++ b/pkg/storage/policies.go @@ -0,0 +1,184 @@ +package storage + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// PolicyRule maps a request scope to the workflow that should govern +// it. Resolution: PolicyEngine walks enabled rules in priority DESC +// order; the first whose selector fully matches the scope wins. +type PolicyRule struct { + ID uuid.UUID + Name string + Selector map[string]any + WorkflowID uuid.UUID + Priority int + Enabled bool + IsSystem bool + CreatedAt time.Time + UpdatedAt time.Time +} + +// PolicyRepository is the read/write surface for policy_rules. +type PolicyRepository interface { + Create(ctx context.Context, p *PolicyRule) error + Get(ctx context.Context, id uuid.UUID) (*PolicyRule, error) + List(ctx context.Context) ([]*PolicyRule, error) + // ListEnabledOrderedByPriority returns enabled rules ordered for + // resolution: highest priority first, then oldest first as + // tiebreaker. PolicyEngine.Resolve iterates this list. + ListEnabledOrderedByPriority(ctx context.Context) ([]*PolicyRule, error) + Update(ctx context.Context, p *PolicyRule) error + Delete(ctx context.Context, id uuid.UUID) error +} + +// Policies is the Postgres implementation. +type Policies struct { + pool *Pool +} + +// NewPolicies binds a Policies repository to the given pool. +func NewPolicies(pool *Pool) *Policies { return &Policies{pool: pool} } + +func (r *Policies) Create(ctx context.Context, p *PolicyRule) error { + if p.Name == "" { + return errors.New("storage: policy Name is required") + } + if p.WorkflowID == uuid.Nil { + return errors.New("storage: policy WorkflowID is required") + } + if p.Selector == nil { + p.Selector = map[string]any{} + } + selector, err := json.Marshal(p.Selector) + if err != nil { + return fmt.Errorf("storage: marshal policy selector: %w", err) + } + const q = ` + INSERT INTO policy_rules (name, selector, workflow_id, priority, enabled, is_system) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, created_at, updated_at` + return r.pool.QueryRow(ctx, q, + p.Name, selector, p.WorkflowID, p.Priority, p.Enabled, p.IsSystem, + ).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt) +} + +func (r *Policies) Get(ctx context.Context, id uuid.UUID) (*PolicyRule, error) { + return scanPolicy(r.pool.QueryRow(ctx, policySelect+` WHERE id = $1`, id)) +} + +func (r *Policies) List(ctx context.Context) ([]*PolicyRule, error) { + rows, err := r.pool.Query(ctx, policySelect+` ORDER BY priority DESC, created_at ASC`) + if err != nil { + return nil, fmt.Errorf("storage: list policies: %w", err) + } + defer rows.Close() + + var out []*PolicyRule + for rows.Next() { + p, err := scanPolicy(rows) + if err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +func (r *Policies) ListEnabledOrderedByPriority(ctx context.Context) ([]*PolicyRule, error) { + rows, err := r.pool.Query(ctx, policySelect+` + WHERE enabled = true + ORDER BY priority DESC, created_at ASC`) + if err != nil { + return nil, fmt.Errorf("storage: list enabled policies: %w", err) + } + defer rows.Close() + + var out []*PolicyRule + for rows.Next() { + p, err := scanPolicy(rows) + if err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +func (r *Policies) Update(ctx context.Context, p *PolicyRule) error { + if p.Selector == nil { + p.Selector = map[string]any{} + } + selector, err := json.Marshal(p.Selector) + if err != nil { + return fmt.Errorf("storage: marshal policy selector: %w", err) + } + const q = ` + UPDATE policy_rules + SET name = $2, selector = $3, workflow_id = $4, priority = $5, enabled = $6 + WHERE id = $1` + tag, err := r.pool.Exec(ctx, q, p.ID, p.Name, selector, p.WorkflowID, p.Priority, p.Enabled) + if err != nil { + return fmt.Errorf("storage: update policy: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (r *Policies) Delete(ctx context.Context, id uuid.UUID) error { + const q = `DELETE FROM policy_rules WHERE id = $1 AND is_system = false` + tag, err := r.pool.Exec(ctx, q, id) + if err != nil { + return fmt.Errorf("storage: delete policy: %w", err) + } + if tag.RowsAffected() == 1 { + return nil + } + p, getErr := r.Get(ctx, id) + if getErr != nil { + return getErr + } + if p.IsSystem { + return ErrSystemRow + } + return ErrNotFound +} + +const policySelect = ` + SELECT id, name, selector, workflow_id, priority, enabled, is_system, + created_at, updated_at + FROM policy_rules` + +func scanPolicy(row interface { + Scan(dest ...any) error +}) (*PolicyRule, error) { + var ( + p PolicyRule + selectorRaw []byte + ) + err := row.Scan( + &p.ID, &p.Name, &selectorRaw, &p.WorkflowID, &p.Priority, &p.Enabled, &p.IsSystem, + &p.CreatedAt, &p.UpdatedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("storage: scan policy: %w", err) + } + if len(selectorRaw) > 0 { + if err := json.Unmarshal(selectorRaw, &p.Selector); err != nil { + return nil, fmt.Errorf("storage: unmarshal policy selector: %w", err) + } + } + return &p, nil +} diff --git a/pkg/storage/roles.go b/pkg/storage/roles.go new file mode 100644 index 0000000..a4f9e83 --- /dev/null +++ b/pkg/storage/roles.go @@ -0,0 +1,176 @@ +package storage + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// Role is a named bundle of permission strings. +type Role struct { + ID uuid.UUID + Name string + Description string + Permissions []string + IsSystem bool + CreatedAt time.Time + UpdatedAt time.Time +} + +// HasPermission returns true when p is in the role's permission list. +// Exact-match only today; wildcard support can come later. +func (r *Role) HasPermission(p string) bool { + for _, perm := range r.Permissions { + if perm == p { + return true + } + } + return false +} + +// RoleRepository is the read/write surface for the roles table. +type RoleRepository interface { + Create(ctx context.Context, r *Role) error + Get(ctx context.Context, id uuid.UUID) (*Role, error) + GetByName(ctx context.Context, name string) (*Role, error) + List(ctx context.Context) ([]*Role, error) + UpdatePermissions(ctx context.Context, id uuid.UUID, perms []string) error + Delete(ctx context.Context, id uuid.UUID) error +} + +// ErrSystemRow is returned by Delete (on any of the policy-engine +// tables) when the caller tries to delete a row that is_system=true. +// System rows can be edited but not removed — they're seeded so the +// platform starts in a usable state and removing them would lock the +// admin out. +var ErrSystemRow = errors.New("storage: cannot delete system row") + +// Roles is the Postgres implementation of RoleRepository. +type Roles struct { + pool *Pool +} + +// NewRoles binds a Roles repository to the given pool. +func NewRoles(pool *Pool) *Roles { return &Roles{pool: pool} } + +func (r *Roles) Create(ctx context.Context, role *Role) error { + if role.Name == "" { + return errors.New("storage: role Name is required") + } + if role.Permissions == nil { + role.Permissions = []string{} + } + perms, err := json.Marshal(role.Permissions) + if err != nil { + return fmt.Errorf("storage: marshal role permissions: %w", err) + } + const q = ` + INSERT INTO roles (name, description, permissions, is_system) + VALUES ($1, $2, $3, $4) + RETURNING id, created_at, updated_at` + return r.pool.QueryRow(ctx, q, + role.Name, role.Description, perms, role.IsSystem, + ).Scan(&role.ID, &role.CreatedAt, &role.UpdatedAt) +} + +func (r *Roles) Get(ctx context.Context, id uuid.UUID) (*Role, error) { + const q = ` + SELECT id, name, description, permissions, is_system, created_at, updated_at + FROM roles WHERE id = $1` + return scanRole(r.pool.QueryRow(ctx, q, id)) +} + +func (r *Roles) GetByName(ctx context.Context, name string) (*Role, error) { + const q = ` + SELECT id, name, description, permissions, is_system, created_at, updated_at + FROM roles WHERE name = $1` + return scanRole(r.pool.QueryRow(ctx, q, name)) +} + +func (r *Roles) List(ctx context.Context) ([]*Role, error) { + const q = ` + SELECT id, name, description, permissions, is_system, created_at, updated_at + FROM roles ORDER BY name ASC` + rows, err := r.pool.Query(ctx, q) + if err != nil { + return nil, fmt.Errorf("storage: list roles: %w", err) + } + defer rows.Close() + + var out []*Role + for rows.Next() { + role, err := scanRole(rows) + if err != nil { + return nil, err + } + out = append(out, role) + } + return out, rows.Err() +} + +func (r *Roles) UpdatePermissions(ctx context.Context, id uuid.UUID, perms []string) error { + if perms == nil { + perms = []string{} + } + raw, err := json.Marshal(perms) + if err != nil { + return fmt.Errorf("storage: marshal role permissions: %w", err) + } + const q = `UPDATE roles SET permissions = $2 WHERE id = $1` + tag, err := r.pool.Exec(ctx, q, id, raw) + if err != nil { + return fmt.Errorf("storage: update role permissions: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (r *Roles) Delete(ctx context.Context, id uuid.UUID) error { + const q = `DELETE FROM roles WHERE id = $1 AND is_system = false` + tag, err := r.pool.Exec(ctx, q, id) + if err != nil { + return fmt.Errorf("storage: delete role: %w", err) + } + if tag.RowsAffected() == 1 { + return nil + } + // Distinguish ErrNotFound from ErrSystemRow so the API can return + // 404 vs 409 appropriately. + role, getErr := r.Get(ctx, id) + if getErr != nil { + return getErr + } + if role.IsSystem { + return ErrSystemRow + } + return ErrNotFound +} + +func scanRole(row interface { + Scan(dest ...any) error +}) (*Role, error) { + var ( + r Role + permsRaw []byte + ) + err := row.Scan(&r.ID, &r.Name, &r.Description, &permsRaw, &r.IsSystem, &r.CreatedAt, &r.UpdatedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("storage: scan role: %w", err) + } + if len(permsRaw) > 0 { + if err := json.Unmarshal(permsRaw, &r.Permissions); err != nil { + return nil, fmt.Errorf("storage: unmarshal role permissions: %w", err) + } + } + return &r, nil +} diff --git a/pkg/storage/user_roles.go b/pkg/storage/user_roles.go new file mode 100644 index 0000000..281a377 --- /dev/null +++ b/pkg/storage/user_roles.go @@ -0,0 +1,135 @@ +package storage + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// UserRole is one RBAC assignment. +type UserRole struct { + ID uuid.UUID + UserID string + RoleID uuid.UUID + Scope map[string]any // empty = global; e.g. {"project_id":"...","environment":"prod"} + GrantedBy string + GrantedAt time.Time +} + +// UserRoleRepository is the read/write surface for the user_roles table. +type UserRoleRepository interface { + Grant(ctx context.Context, ur *UserRole) error + Revoke(ctx context.Context, id uuid.UUID) error + ListByUser(ctx context.Context, userID string) ([]*UserRole, error) + ListByRole(ctx context.Context, roleID uuid.UUID) ([]*UserRole, error) +} + +// UserRoles is the Postgres implementation. +type UserRoles struct { + pool *Pool +} + +// NewUserRoles binds a UserRoles repository to the given pool. +func NewUserRoles(pool *Pool) *UserRoles { return &UserRoles{pool: pool} } + +func (r *UserRoles) Grant(ctx context.Context, ur *UserRole) error { + if ur.UserID == "" { + return errors.New("storage: UserID is required") + } + if ur.RoleID == uuid.Nil { + return errors.New("storage: RoleID is required") + } + if ur.Scope == nil { + ur.Scope = map[string]any{} + } + scope, err := json.Marshal(ur.Scope) + if err != nil { + return fmt.Errorf("storage: marshal user_role scope: %w", err) + } + const q = ` + INSERT INTO user_roles (user_id, role_id, scope, granted_by) + VALUES ($1, $2, $3, NULLIF($4, '')) + RETURNING id, granted_at` + return r.pool.QueryRow(ctx, q, ur.UserID, ur.RoleID, scope, ur.GrantedBy).Scan(&ur.ID, &ur.GrantedAt) +} + +func (r *UserRoles) Revoke(ctx context.Context, id uuid.UUID) error { + const q = `DELETE FROM user_roles WHERE id = $1` + tag, err := r.pool.Exec(ctx, q, id) + if err != nil { + return fmt.Errorf("storage: revoke user_role: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (r *UserRoles) ListByUser(ctx context.Context, userID string) ([]*UserRole, error) { + const q = ` + SELECT id, user_id, role_id, scope, COALESCE(granted_by, ''), granted_at + FROM user_roles WHERE user_id = $1 ORDER BY granted_at ASC` + rows, err := r.pool.Query(ctx, q, userID) + if err != nil { + return nil, fmt.Errorf("storage: list user_roles by user: %w", err) + } + defer rows.Close() + + var out []*UserRole + for rows.Next() { + ur, err := scanUserRole(rows) + if err != nil { + return nil, err + } + out = append(out, ur) + } + return out, rows.Err() +} + +func (r *UserRoles) ListByRole(ctx context.Context, roleID uuid.UUID) ([]*UserRole, error) { + const q = ` + SELECT id, user_id, role_id, scope, COALESCE(granted_by, ''), granted_at + FROM user_roles WHERE role_id = $1 ORDER BY granted_at ASC` + rows, err := r.pool.Query(ctx, q, roleID) + if err != nil { + return nil, fmt.Errorf("storage: list user_roles by role: %w", err) + } + defer rows.Close() + + var out []*UserRole + for rows.Next() { + ur, err := scanUserRole(rows) + if err != nil { + return nil, err + } + out = append(out, ur) + } + return out, rows.Err() +} + +func scanUserRole(row interface { + Scan(dest ...any) error +}) (*UserRole, error) { + var ( + ur UserRole + scopeRaw []byte + ) + err := row.Scan(&ur.ID, &ur.UserID, &ur.RoleID, &scopeRaw, &ur.GrantedBy, &ur.GrantedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("storage: scan user_role: %w", err) + } + if len(scopeRaw) > 0 { + if err := json.Unmarshal(scopeRaw, &ur.Scope); err != nil { + return nil, fmt.Errorf("storage: unmarshal user_role scope: %w", err) + } + } + return &ur, nil +} diff --git a/pkg/storage/workflows.go b/pkg/storage/workflows.go new file mode 100644 index 0000000..50c76f6 --- /dev/null +++ b/pkg/storage/workflows.go @@ -0,0 +1,225 @@ +package storage + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// WorkflowDefinition is an admin-defined approval template. +type WorkflowDefinition struct { + ID uuid.UUID + Name string + Description string + MinApprovers int + ApproverRoleID *uuid.UUID + WrapTTLCreated time.Duration + WrapTTLApproved time.Duration + WrapTTLClaimed time.Duration + RequestTTL time.Duration + RequireJustification bool + AllowSelfApproval bool + NotificationChannels []string + IsDefault bool + Enabled bool + IsSystem bool + CreatedAt time.Time + UpdatedAt time.Time +} + +// WorkflowRepository is the read/write surface for workflow_definitions. +type WorkflowRepository interface { + Create(ctx context.Context, w *WorkflowDefinition) error + Get(ctx context.Context, id uuid.UUID) (*WorkflowDefinition, error) + GetByName(ctx context.Context, name string) (*WorkflowDefinition, error) + GetDefault(ctx context.Context) (*WorkflowDefinition, error) + List(ctx context.Context) ([]*WorkflowDefinition, error) + Update(ctx context.Context, w *WorkflowDefinition) error + Delete(ctx context.Context, id uuid.UUID) error +} + +// Workflows is the Postgres implementation. +type Workflows struct { + pool *Pool +} + +// NewWorkflows binds a Workflows repository to the given pool. +func NewWorkflows(pool *Pool) *Workflows { return &Workflows{pool: pool} } + +func (r *Workflows) Create(ctx context.Context, w *WorkflowDefinition) error { + if w.Name == "" { + return errors.New("storage: workflow Name is required") + } + if w.NotificationChannels == nil { + w.NotificationChannels = []string{} + } + channels, err := json.Marshal(w.NotificationChannels) + if err != nil { + return fmt.Errorf("storage: marshal notification channels: %w", err) + } + + const q = ` + INSERT INTO workflow_definitions ( + name, description, min_approvers, approver_role_id, + wrap_ttl_created, wrap_ttl_approved, wrap_ttl_claimed, + request_ttl, require_justification, allow_self_approval, + notification_channels, is_default, enabled, is_system + ) VALUES ( + $1, $2, $3, $4, + $5::interval, $6::interval, $7::interval, + $8::interval, $9, $10, + $11, $12, $13, $14 + ) + RETURNING id, created_at, updated_at` + return r.pool.QueryRow(ctx, q, + w.Name, w.Description, w.MinApprovers, w.ApproverRoleID, + intervalString(w.WrapTTLCreated), intervalString(w.WrapTTLApproved), + intervalString(w.WrapTTLClaimed), + intervalString(w.RequestTTL), w.RequireJustification, w.AllowSelfApproval, + channels, w.IsDefault, w.Enabled, w.IsSystem, + ).Scan(&w.ID, &w.CreatedAt, &w.UpdatedAt) +} + +func (r *Workflows) Get(ctx context.Context, id uuid.UUID) (*WorkflowDefinition, error) { + return scanWorkflow(r.pool.QueryRow(ctx, workflowSelect+` WHERE id = $1`, id)) +} + +func (r *Workflows) GetByName(ctx context.Context, name string) (*WorkflowDefinition, error) { + return scanWorkflow(r.pool.QueryRow(ctx, workflowSelect+` WHERE name = $1`, name)) +} + +func (r *Workflows) GetDefault(ctx context.Context) (*WorkflowDefinition, error) { + return scanWorkflow(r.pool.QueryRow(ctx, workflowSelect+` WHERE is_default = true`)) +} + +func (r *Workflows) List(ctx context.Context) ([]*WorkflowDefinition, error) { + rows, err := r.pool.Query(ctx, workflowSelect+` ORDER BY name ASC`) + if err != nil { + return nil, fmt.Errorf("storage: list workflows: %w", err) + } + defer rows.Close() + + var out []*WorkflowDefinition + for rows.Next() { + w, err := scanWorkflow(rows) + if err != nil { + return nil, err + } + out = append(out, w) + } + return out, rows.Err() +} + +func (r *Workflows) Update(ctx context.Context, w *WorkflowDefinition) error { + if w.NotificationChannels == nil { + w.NotificationChannels = []string{} + } + channels, err := json.Marshal(w.NotificationChannels) + if err != nil { + return fmt.Errorf("storage: marshal notification channels: %w", err) + } + const q = ` + UPDATE workflow_definitions SET + description = $2, + min_approvers = $3, + approver_role_id = $4, + wrap_ttl_created = $5::interval, + wrap_ttl_approved = $6::interval, + wrap_ttl_claimed = $7::interval, + request_ttl = $8::interval, + require_justification = $9, + allow_self_approval = $10, + notification_channels = $11, + enabled = $12 + WHERE id = $1` + tag, err := r.pool.Exec(ctx, q, + w.ID, w.Description, w.MinApprovers, w.ApproverRoleID, + intervalString(w.WrapTTLCreated), intervalString(w.WrapTTLApproved), + intervalString(w.WrapTTLClaimed), + intervalString(w.RequestTTL), w.RequireJustification, w.AllowSelfApproval, + channels, w.Enabled, + ) + if err != nil { + return fmt.Errorf("storage: update workflow: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (r *Workflows) Delete(ctx context.Context, id uuid.UUID) error { + const q = `DELETE FROM workflow_definitions WHERE id = $1 AND is_system = false` + tag, err := r.pool.Exec(ctx, q, id) + if err != nil { + return fmt.Errorf("storage: delete workflow: %w", err) + } + if tag.RowsAffected() == 1 { + return nil + } + w, getErr := r.Get(ctx, id) + if getErr != nil { + return getErr + } + if w.IsSystem { + return ErrSystemRow + } + return ErrNotFound +} + +const workflowSelect = ` + SELECT id, name, description, min_approvers, approver_role_id, + EXTRACT(EPOCH FROM wrap_ttl_created)::bigint, + EXTRACT(EPOCH FROM wrap_ttl_approved)::bigint, + EXTRACT(EPOCH FROM wrap_ttl_claimed)::bigint, + EXTRACT(EPOCH FROM request_ttl)::bigint, + require_justification, allow_self_approval, + notification_channels, is_default, enabled, is_system, + created_at, updated_at + FROM workflow_definitions` + +func scanWorkflow(row interface { + Scan(dest ...any) error +}) (*WorkflowDefinition, error) { + var ( + w WorkflowDefinition + approverRoleID *uuid.UUID + wrapCreated, wrapApproved, wrapClaim int64 + requestTTL int64 + channelsRaw []byte + ) + err := row.Scan( + &w.ID, &w.Name, &w.Description, &w.MinApprovers, &approverRoleID, + &wrapCreated, &wrapApproved, &wrapClaim, &requestTTL, + &w.RequireJustification, &w.AllowSelfApproval, + &channelsRaw, &w.IsDefault, &w.Enabled, &w.IsSystem, + &w.CreatedAt, &w.UpdatedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("storage: scan workflow: %w", err) + } + w.ApproverRoleID = approverRoleID + w.WrapTTLCreated = time.Duration(wrapCreated) * time.Second + w.WrapTTLApproved = time.Duration(wrapApproved) * time.Second + w.WrapTTLClaimed = time.Duration(wrapClaim) * time.Second + w.RequestTTL = time.Duration(requestTTL) * time.Second + if len(channelsRaw) > 0 { + if err := json.Unmarshal(channelsRaw, &w.NotificationChannels); err != nil { + return nil, fmt.Errorf("storage: unmarshal notification channels: %w", err) + } + } + return &w, nil +} + +// intervalString renders a Go duration as Postgres interval input. +func intervalString(d time.Duration) string { + return fmt.Sprintf("%d seconds", int64(d.Seconds())) +}