Design: policy as a managed, composable surface — not only files
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 9
- Forks
- 0
- Avg merge
- 3h 3m
- Merged PRs (30d)
- 509
Description
Flowstate now has three policy surfaces, and all three arrived as files: the trust policy (--auth-policy), the egress policy (--egress-policy, #101), and the secret-access section riding inside the trust policy. Files were the right first move — reviewable, versionable in git, fail-closed at load — and they will stop being enough the moment a second team shares a deployment. This issue is the design space for what comes after, before each policy surface invents its own answer.
The tensions to design against, not around
Granularity — down to the step, because identity already is. Today every policy is deployment-wide: one egress policy for every tenant, one trust policy for every issuer. The scoping ladder runs further than namespace: deployment → namespace → workflow → step or step-group. The identity system already lives at the bottom rung — an outbound assertion is minted per StepRef (#94), audience-scoped to the one step's purpose — so policy stopping at the worker means the credential is step-granular while the network it can use is fleet-granular, which is the mismatch an attacker lives in. The end state: a step that talks to GitHub gets GitHub egress and nothing else; the fan-out steps around it get no egress at all; a step running exec (#100) gets a sandbox profile scoped to it, not to the worker.
The composition rule that keeps this readable: narrower scopes only tighten — worker ∩ workflow ∩ step, never widen. And the Flowfile-side spelling must respect the validator rule CLAUDE.md already states ("report what is a property of the file, stay silent about what a deployment decides"): a step names a policy by reference (policy: restricted-egress), which is the file's property and validates everywhere; what that name resolves to is the deployment's answer, resolved at submit or at the worker — exactly how secret references already split the two. An inline CEL rule on a step would put deployment configuration inside the file and break the editor-diagnostic contract; a named reference keeps both halves honest.
Composition. A base policy plus overlays is how every mature policy system ends up (sudoers drop-ins, Kubernetes ValidatingAdmissionPolicy + bindings, OPA bundles). The egress PR deliberately chose replacement over merging for one file — "a policy assembled from two places is a policy nobody can read in either" — and that reasoning holds for peer files while pointing at the actual requirement: composition needs to be ordered and directional (a deny fragment may always be added; an allow may only be added by a scope that holds the authority). Deny-only fragments compose safely by construction; that asymmetry should be the composition rule rather than a general merge. The same rule composes the kinds: compute (sandbox profile), network (egress), storage (filesystem scope), and secrets (scheme/name access) are four independently statable policies attached at any rung of the ladder, so granting a step network without filesystem is expressible rather than an all-or-nothing trust decision — #100's separation axis and this issue's granularity axis are one grid.
Proto-first, which these are currently not. Both policy configs are YAML-tagged Go structs (auth.Policy, netpolicy.Config) — reasonable while a policy's whole life is one file read at startup, and a violation of the project's own first principle the moment a policy travels: fetched over an RPC, attached to a namespace, recorded in an audit trail, diffed by a UI. If policies get a management surface, the message shapes belong in the schema (flowstate.v1.EgressPolicy etc.), with the YAML file becoming one projection of the message the way a Flowfile is a projection of Workflow. That also makes policy validation an RPC for free (the same shape Validate/Compile took in #89), and flow policy check a projection of it.
Management and audit. Who changed the policy, when, from what to what? A file's answer is git, which is a good answer when the file is in a repo and no answer when it was scp'd to a worker. If policies become schema messages, the management surface is a handful of RPCs (get/set/list per scope) with the actor's authenticated identity recorded — the same plan/apply discipline docs/DSL.md already prescribes for mutating operations against runs, applied to configuration. The operator API is authenticated by the same OIDC/JWKS machinery the workload path uses — one trust model, not a second admin credential system — and authorized by CEL over the operator's verified claims, the same language as everything else. Content-addressing the active policy (a digest in run records) would let a run answer "which egress policy governed this step" after the fact, which is the audit question that matters in an incident.
Adaptability without restart. Every policy today loads at startup and holds for process lifetime, which is correct for fail-closed and wrong for operations: rotating a deny rule onto a fleet should not mean rolling every worker. A managed policy with a version/digest gives a safe reload seam: workers poll or are pushed the new digest, compile and type-check it before swapping (the same at-load compilation the file path does), and refuse the swap on any error while continuing under the old policy — fail-closed applied to updates, not just to loads.
The CEL thread that keeps this coherent. Every policy surface here is CEL over typed attributes — workflow expressions, egress rules, assume rules, secret rules, and eventually operator authorization. Whatever management shape lands must keep that single-language property; a policy store that introduces its own condition syntax would undo the central cohesion bet.
Local parity, held the whole way. flow run local --egress-policy already rehearses the worker's policy (invariant 3); step-scoped policy must keep that — a named policy reference resolves locally from the same file the worker would read, so a rehearsal under policy: restricted-egress rehearses the restriction and not a lie. Local and remote are the same policy model at different resolution points, never two models.
What this issue is not
Not a proposal to build a policy server now. Files remain the right surface for a single-team deployment and must keep working forever (invariant 8: self-hosted first, no infrastructure prerequisites). This is the design record so that per-namespace egress (#92 follow-on), plugin-level policy (#97), the secret-access wiring (#91), and step-level sandboxing (#100) grow toward one management model instead of four.
Related: #100 (the sandboxing umbrella — its compute/network/storage separation and this issue's scope ladder are one grid), #97 (plugin-level CEL policy), #99 (proto-first generation — a schema-typed policy gets its reference docs for free), #94 (per-StepRef identity, the granularity precedent policy should match).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Read CLAUDE.md's validator rule, docs/DSL.md's plan/apply guidance, and the auth.Policy and netpolicy.Config definitions. Map the related issues and existing local-policy path before proposing a unified scope, composition, schema, management, and audit model. Done means a coherent design record that preserves file-based operation, local parity, fail-closed updates, and the stated invariants.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- git, go
- Domain
- authorization, backend, backend-api-design, security
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100