feat(workflow): dynamic policy + workflow engine#14
Merged
Conversation
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.
5 tasks
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.
This was referenced May 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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;
PolicyEngineresolves the right workflow for a given request scope at runtime.Schema (migration 0005)
rolesuser_rolesworkflow_definitionsmin_approvers,wrap_ttl_*,allow_self_approval,notification_channels, ...)policy_rulesPlus seed rows so the platform starts in a usable state:
admin/approver/developerrolesstandardworkflow asis_defaultmatch-allpolicy at priority 0 (any operator-added rule wins)Resolution algorithm
Hard rules
workflow_definitions_one_default+is_system=trueon the seed row.DeletereturnsErrSystemRow.Resolvecontinues the walk; tests pin both cases.ListEnabledOrderedByPriorityreturns pre-sorted.Decisions worth flagging
secret_ref_prefix) when presentis_defaultenforced by partial unique indexis_systemrows editable but not deletablekubectl deletethe row.secret.*) is future.Resolvequeries DB on every callTRUNCATEnotDELETEforaudit_eventsin test setupVerification
Offline:
go build / vet / test -race ./...clean.Live: 10 new tests pass; full suite (
-p 1across all packages) green.Test plan
-p 1cleaninternal/services/policy.go::Resolveand confirms the walk + fall-through logicpkg/storage/migrations/0005_workflow_engine.up.sqland confirms the seed rows fit the platform-starts-usable principleis_defaultand confirms it can't admit two defaults at onceWhat's NOT in this PR (next pieces)
ProviderExecutorconsuming wraps + writing to providers