Skip to content

feat(workflow): dynamic policy + workflow engine#14

Merged
haydercyber merged 1 commit into
mainfrom
feat/workflow-policy-engine
May 28, 2026
Merged

feat(workflow): dynamic policy + workflow engine#14
haydercyber merged 1 commit into
mainfrom
feat/workflow-policy-engine

Conversation

@haydercyber

Copy link
Copy Markdown
Contributor

Summary

Piece 2 of the dynamic-workflow vertical slice. 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 resolves the right workflow for a given request scope at runtime.

Schema (migration 0005)

Table Purpose
roles Admin-defined permission bundles
user_roles RBAC assignments with optional scope narrowing (project, environment)
workflow_definitions Approval templates (min_approvers, wrap_ttl_*, allow_self_approval, notification_channels, ...)
policy_rules Selector → workflow mapping with priority-based resolution

Plus seed rows so the platform starts in a usable state:

  • System admin / approver / developer roles
  • System standard workflow as is_default
  • System match-all policy at priority 0 (any operator-added rule wins)

Resolution algorithm

PolicyEngine.Resolve(ctx, scope) → (*Workflow, *PolicyRule, error)

1. Walk enabled rules in priority DESC, created_at ASC order.
2. First rule whose selector is a SUBSET of the scope wins.
   (Every present selector key must match; absent keys are wildcards.
    `secret_ref_prefix` is prefix-match; everything else exact.)
3. Disabled rule OR disabled target workflow → continue walking.
4. No rule matched → fall back to is_default=true workflow.
5. No default → ErrNoDefaultWorkflow (operator misconfig — 500 at the API).

Hard rules

Invariant How enforced
Operator can't accidentally delete the default workflow Partial unique index workflow_definitions_one_default + is_system=true on the seed row. Delete returns ErrSystemRow.
Disabled rule / workflow falls through, doesn't fail Resolve continues the walk; tests pin both cases.
Higher priority wins on overlapping selectors Repository's ListEnabledOrderedByPriority returns pre-sorted.
Single default workflow Partial unique index — can't be bypassed by misbehaving service-layer caller.

Decisions worth flagging

Decision Why
Selector keys are wildcards when absent, exact-match (or prefix-match for secret_ref_prefix) when present Operator intuition: "this rule applies when env=prod" — they don't list everything they don't care about.
Match-all policy at priority 0 Any operator-added rule at default priority 100 wins immediately.
is_default enforced by partial unique index Schema guarantees exactly one default.
is_system rows editable but not deletable Protects the seed without making it totally immutable. Operator can change the default workflow's TTLs but can't kubectl delete the row.
Permissions are opaque strings Lets new permissions land without a Go-side type change. Wildcard (secret.*) is future.
Resolve queries DB on every call Simple, correct. In-memory cache with invalidation-on-edit is a follow-up.
TRUNCATE not DELETE for audit_events in test setup Append-only trigger from Step 5 rejects DELETE; TRUNCATE bypasses row triggers. Pattern documented.

Verification

Offline: go build / vet / test -race ./... clean.
Live: 10 new tests pass; full suite (-p 1 across all packages) green.

=== RUN   TestResolve_FallsBackToSystemDefault           PASS
=== RUN   TestResolve_ExactMatchPolicyTakesPrecedence    PASS
=== RUN   TestResolve_HigherPriorityWins                 PASS
=== RUN   TestResolve_PartialMatchDoesNotApply           PASS
=== RUN   TestResolve_SecretRefPrefixMatch               PASS
=== RUN   TestResolve_DisabledRuleSkipped                PASS
=== RUN   TestResolve_DisabledWorkflowFalsThrough        PASS
=== RUN   TestRoles_DeleteSystemRowRejected              PASS
=== RUN   TestUserRoles_GrantAndList                     PASS
=== RUN   TestWorkflows_OnlyOneDefault                   PASS

Test plan

  • CI green (4 jobs)
  • Local full suite with -p 1 clean
  • Reviewer reads internal/services/policy.go::Resolve and confirms the walk + fall-through logic
  • Reviewer reads pkg/storage/migrations/0005_workflow_engine.up.sql and confirms the seed rows fit the platform-starts-usable principle
  • Reviewer reads the partial unique index on is_default and confirms it can't admit two defaults at once

What's NOT in this PR (next pieces)

Piece Adds
3 HTTP endpoints — admin role/workflow/policy CRUD, dev request submission, approver queue, agent wrap retrieval
4 Agent ProviderExecutor consuming wraps + writing to providers
5 UI scaffold + admin pages
6 UI dev + approver pages

Second piece of the dynamic-workflow vertical slice. 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 resolves the right workflow for a
given request scope at runtime.

What landed
-----------

  pkg/storage/migrations/0005_workflow_engine.{up,down}.sql
    Four new tables:
      roles                 — admin-defined permission bundles
      user_roles            — RBAC assignments with optional scope
                              narrowing (project, environment, ...)
      workflow_definitions  — approval templates (min_approvers,
                              wrap_ttl_*, allow_self_approval, ...)
      policy_rules          — selector → workflow mapping with
                              priority-based resolution
    Plus seed rows so the platform starts in a usable state:
      - System "admin" / "approver" / "developer" roles
      - System "standard" workflow as is_default
      - System "match-all" policy at priority 0

  pkg/storage/{roles,user_roles,workflows,policies}.go
    Repositories for all four tables. New sentinel ErrSystemRow;
    Delete on a row with is_system=true returns it (rows can still
    be edited via UpdatePermissions / Update). Workflow row's TTL
    columns are Postgres INTERVAL — repository converts to/from
    time.Duration at scan/insert.

  internal/services/policy.go
    PolicyEngine.Resolve(ctx, scope) → (*Workflow, *PolicyRule, error)

    Resolution algorithm:
      1. Walk enabled rules in priority DESC, created_at ASC order.
      2. First rule whose selector is a SUBSET of the scope (every
         present key matches; absent keys are wildcards) wins.
      3. secret_ref_prefix is prefix-match; everything else is exact.
      4. Disabled rule or disabled target workflow → continue.
      5. No rule matched → fall back to is_default=true workflow.
      6. No default → ErrNoDefaultWorkflow (operator misconfig).

  internal/services/policy_test.go
    10 integration tests:
      - FallsBackToSystemDefault
      - ExactMatchPolicyTakesPrecedence
      - HigherPriorityWins
      - PartialMatchDoesNotApply
      - SecretRefPrefixMatch
      - DisabledRuleSkipped
      - DisabledWorkflowFalsThrough
      - Roles_DeleteSystemRowRejected
      - UserRoles_GrantAndList
      - Workflows_OnlyOneDefault (verifies partial unique index)

Hard rules
----------

  - Operator can't accidentally delete the default workflow.
    Partial unique index workflow_definitions_one_default +
    is_system=true on the seed row.
  - Disabled rule or disabled workflow falls through cleanly to
    the default. Two tests pin this.
  - Higher priority wins on overlapping selectors. Repository's
    ListEnabledOrderedByPriority returns pre-sorted by
    (priority DESC, created_at ASC).
  - audit_events truncation needs TRUNCATE (not DELETE) because
    of the append-only trigger from Step 5.

Decisions worth flagging in review
----------------------------------

  - Selector keys are wildcards when absent, exact-match (or
    prefix-match for secret_ref_prefix) when present. Operator
    intuition matches: "I want this rule to apply when env=prod" —
    they don't list everything they don't care about.
  - Match-all policy at priority 0. Any operator-added rule at the
    default 100 wins immediately. The fallback is the policy walk
    reaching priority 0, not "no policy matched".
  - is_default enforced by partial unique index, not application
    logic. Schema guarantees exactly one default at any time.
  - is_system on roles, workflows, policies. Editable but not
    deletable; protects the seed without making it immutable.
  - Permissions are opaque strings. Wildcard support is a future
    addition.
  - PolicyEngine queries DB on every Resolve. Simple, correct,
    easy to reason about. In-memory cache (with invalidation on
    policy edit) is a follow-up optimization.

Verification
------------

  Offline: go build / vet / test -race ./... clean.
  Live: 10 new tests pass; full suite across all packages green
  with -p 1.
@haydercyber
haydercyber merged commit e013433 into main May 28, 2026
4 checks passed
@haydercyber
haydercyber deleted the feat/workflow-policy-engine branch May 28, 2026 15:26
haydercyber added a commit that referenced this pull request May 28, 2026
…cies (#15)

* feat(admin): HTTP endpoints for roles + user-roles + workflows + policies

Piece 3a of the dynamic-workflow vertical slice (Piece 3 split into
three sub-PRs: 3a admin CRUD, 3b request flow, 3c agent wrap retrieval).

This PR is pure HTTP over the four tables from Piece 2 (#14). Admin UI
(Piece 5) calls these endpoints to manage roles, role assignments,
workflow templates, and policy rules.

Endpoints added under /api/v1
-----------------------------

  POST   /roles                       create
  GET    /roles                       list (includes system)
  GET    /roles/:id                   get
  PUT    /roles/:id/permissions       replace permission list
  DELETE /roles/:id                   404 missing, 409 system

  POST   /user-roles                  grant
  DELETE /user-roles/:id              revoke
  GET    /users/:userID/roles         list a user's assignments

  POST   /workflows                   create
  GET    /workflows                   list
  GET    /workflows/:id               get
  PUT    /workflows/:id               update (excludes is_default flip)
  DELETE /workflows/:id               404 missing, 409 system

  POST   /policies                    create
  GET    /policies                    list (priority DESC)
  GET    /policies/:id                get
  PUT    /policies/:id                update
  DELETE /policies/:id                404 missing, 409 system

Decisions worth flagging in review
----------------------------------

  - All admin endpoints under the existing admin route group. Real
    RBAC (RequirePermission middleware) layers on top when the auth
    design ships; no route reshape needed.
  - is_system rows are 404-aware (missing) vs 409-aware (system row)
    on Delete. ErrSystemRow → 409 with a clear message.
  - TTLs exposed as integer seconds in JSON — Helm-friendly, matches
    typical REST conventions, no parsing on the client side.
  - All TTLs validated > 0 at the handler layer (not just at the DB
    CHECK). Zero TTLs would silently never expire.
  - is_default flip NOT included in Update. Atomic swap needs a
    transaction; defer to its own endpoint to avoid the partial
    unique index admitting zero defaults briefly during the swap.
  - Admin handler struct holds all four repositories — single
    constructor call in main; no risk of forgetting to wire one.
  - Bodies use snake_case JSON tags (matches existing conventions
    like agent_secret, wrap_id, correlation_id).

Tests
-----

  13 handler tests using Fiber's app.Test helper:
    TestRoles_CreateGetUpdateDelete
    TestRoles_DeleteSystemRoleReturns409
    TestRoles_ListReturnsAllIncludingSystem
    TestRoles_ListAfterManyCreates
    TestUserRoles_GrantRevoke
    TestWorkflows_CreateGetUpdateDelete
    TestWorkflows_RejectsZeroTTL
    TestWorkflows_DeleteSystemReturns409
    TestPolicies_CreateListDelete
    TestPolicies_DeleteSystemReturns409
    TestAdmin_NotFoundReturns404
    TestAdmin_InvalidUUIDReturns400
    TestAdmin_MalformedJSONBodyReturns400

Verification
------------

  Offline: go build / vet / test -race ./... clean.
  Live: 13 new tests + full suite green with -p 1.

  curl walkthrough demonstrates:
    - List seed roles (admin / approver / developer)
    - Create custom strict-prod workflow
    - Create matching prod-only policy at priority 500
    - List policies in priority DESC order (ours first, seed match-all last)
    - Grant alice@example.com the approver role scoped to environment=prod
    - DELETE the seed admin role → HTTP 409 (is_system protection)

* lint: drop unused resp assignment in admin test (errcheck SA4006)

The GET after PUT only uses the body; the response status isn't checked.
Switching the assignment to _ silences staticcheck SA4006.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant