You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Adopt the same RLS-style policy model that table-policies (feat/table-access) is shipping for tables, applied to file storage. Goal: apps can read and write files directly via the web SDK without proxying through workflows, with row-level-equivalent access control expressed as JSON-AST policies.
This is a deliberate parallel to table-policies. It is dependent on that work landing first, both architecturally (we extract a shared policy engine from api/shared/policies/) and conceptually (the editor UX, the manifest shape, the function registry, and the operator semantics should match so authors learn one model).
Status
Experimental. RFC, not a committed milestone.
This is a significant undertaking. We are knowingly building toward a Firestore/SharePoint-style direct-access model on top of the existing files surface. The expressive ceiling is high (JSON AST, multiple operators, functions, default-deny rule lists) and the implementation surface includes a UI (file browser + rule editor + effective-access tester), a metadata sidecar, and shared engine code. The cost is non-trivial for an OSS project.
We are pursuing it because the alternative — apps continuing to proxy file reads through workflows — caps what apps can practically do. Galleries, document viewers, attachment libraries, and anything resembling a content app all hit the same wall today. Without this, the app platform stays a tier below what users expect from comparable platforms.
We accept that this may grow beyond what we can sustainably maintain. If that happens, the design has explicit fallback positions:
The reduced v1 operator set (~6 AST nodes) is a viable shipping subset on its own.
The shared engine extraction is independently valuable for tables.
The static-three-scope model (Everyone / Role / Creator), which we considered first, remains a reasonable fallback expressible inside the policy AST as a small, well-defined set of templates.
Dependencies
feat/table-access (table policies, in-flight). Provides the policy engine (api/shared/policies/: evaluate.py, compile.py, functions.py, probe.py, subscription.py), the manifest+CLI plumbing, the websocket subscribe path, and the role-cache. Also provides the editor pattern (Monaco JSON + templates + reference panel) and the useTable hook design we'd mirror.
feat(files): unified {location}/{scope}/{path} model (feat(files): unified {location}/{scope}/{path} model + signed-url location fix #155, merged). Provides resolve_s3_key and the (location, scope, path) decomposition. File policies operate on the user-meaningful (location, path) identity; scope is a structural segment owned by the resolver and never appears in policy ASTs.
Sketch
A FilePolicy row is (location, path, name, actions, when, ...) — same skeleton as TablePolicy. actions ⊆ {read, write, delete, list}. when is a JSON AST.
The expression model is a deliberately reduced subset of table-policies' operator set for v1:
Boolean: and, or, not
Comparison: eq
Functions: call (allow-listed; v1 = has_role)
References: {user: ...}, {file: ...}
Literals: bool, string, null
That's six AST node types, sufficient to express Everyone / Role / Creator and any combination thereof. Future operators (lt, gt, in, is_null, etc.) are additive — they do not change the data shape.
{file: ...} references resolve against a small, deliberate set:
Field
Source
created_by
sidecar (file_index-style)
created_at
sidecar
path
the user-meaningful path (no scope)
location
the location component
Any field beyond this set requires a sidecar-schema decision and is out of scope for v1.
Default deny. Admin bypass is a seeded rule, not a special case in the evaluator — same pattern table-policies adopted.
Shared engine extraction
When we land file policies, we also refactor api/shared/policies/ so the engine is domain-agnostic and the table-specific bits move to a table_policies binding. Concretely:
Functions are shared from day one — no duplication of has_role. Per-domain bindings stay separate. This refactor is a precondition for file policies, not a separate project, and lands as part of this work.
UI surfaces
Three new admin surfaces, modeled on the table-policies admin editor:
File browser. Tree view of locations + folders, scope segment hidden by default (visible via "Show raw scopes" admin toggle). Rules are attached to folders; right-click → manage rules.
Rule editor. Same Monaco-JSON editor + templates + reference panel as table-policies, with the file-specific reference set.
Effective access tester. Pick a user (or synthesize a hypothetical user with extra roles), pick a path, pick an action — see the resolution trail: which rule matched, what the AST evaluated to, what the resolved S3 key is. Runs the real evaluator, not a UI re-implementation.
Web SDK
files.signedUrl(path) / files.signedUrls(paths) (batch presigning), files.list(prefix), files.upload(path, blob), files.delete(path). Same shape as the workflow files SDK; access enforced server-side via the policy evaluator. Unblocks galleries and direct-from-browser file flows that bounce through the workflow engine today.
Non-goals
Reaching parity with table-policies' operator surface in v1. The reduced set is intentional. Add operators on demand.
Arbitrary S3 metadata predicates. Tags, content-type, size — all out of v1. The reference set is small and deliberate.
Cross-prefix queries. Like S3 itself, list operations are prefix-bound. No WHERE location IN (...)-style policies.
SQL pushdown. Tables push predicates into Postgres. Files don't have a SQL surface; list returns the prefix and the evaluator filters per-row in Python (acceptable at v1 scale; revisit if a tenant runs into list latency).
Open questions
file_index sidecar scope. What metadata does it track, and on what write path? Form uploads, SDK uploads, workflow files.write, and direct S3 (admin) all need to converge on a single "this file exists, here's its created_by" record.
Rename UX. Renaming a folder = S3 prefix copy+delete + updating every FilePolicy row whose (location, path) falls under the renamed prefix. Single user action, surfaced explicitly in the dialog ("this will move 47 files and update 3 policies").
Creator-scope filtering on list. If a policy grants read only via {eq: [{file: created_by}, {user: user_id}]}, list operations need to filter by created_by. This requires the sidecar to be queryable per prefix — a real implementation cost worth flagging.
Migration of legacy unscoped uploads. PR feat(files): unified {location}/{scope}/{path} model + signed-url location fix #155 made the call that pre-scoped form uploads (under uploads/{form_id}/... with no scope segment) are unreachable through the resolver. File policies inherit that decision; legacy keys remain accessible only via direct S3 (admin) or workflows that hit the bucket directly.
Closes
This issue closes when file policies ship into main with the shared engine extraction, web SDK, admin UI, and an effective-access tester. The table-policies tracking work stays separate.
Summary
Adopt the same RLS-style policy model that table-policies (
feat/table-access) is shipping for tables, applied to file storage. Goal: apps can read and write files directly via the web SDK without proxying through workflows, with row-level-equivalent access control expressed as JSON-AST policies.This is a deliberate parallel to table-policies. It is dependent on that work landing first, both architecturally (we extract a shared policy engine from
api/shared/policies/) and conceptually (the editor UX, the manifest shape, the function registry, and the operator semantics should match so authors learn one model).Status
Experimental. RFC, not a committed milestone.
This is a significant undertaking. We are knowingly building toward a Firestore/SharePoint-style direct-access model on top of the existing files surface. The expressive ceiling is high (JSON AST, multiple operators, functions, default-deny rule lists) and the implementation surface includes a UI (file browser + rule editor + effective-access tester), a metadata sidecar, and shared engine code. The cost is non-trivial for an OSS project.
We are pursuing it because the alternative — apps continuing to proxy file reads through workflows — caps what apps can practically do. Galleries, document viewers, attachment libraries, and anything resembling a content app all hit the same wall today. Without this, the app platform stays a tier below what users expect from comparable platforms.
We accept that this may grow beyond what we can sustainably maintain. If that happens, the design has explicit fallback positions:
Dependencies
feat/table-access(table policies, in-flight). Provides the policy engine (api/shared/policies/:evaluate.py,compile.py,functions.py,probe.py,subscription.py), the manifest+CLI plumbing, the websocket subscribe path, and the role-cache. Also provides the editor pattern (Monaco JSON + templates + reference panel) and theuseTablehook design we'd mirror.feat(files): unified {location}/{scope}/{path} model(feat(files): unified {location}/{scope}/{path} model + signed-url location fix #155, merged). Providesresolve_s3_keyand the(location, scope, path)decomposition. File policies operate on the user-meaningful(location, path)identity; scope is a structural segment owned by the resolver and never appears in policy ASTs.Sketch
A
FilePolicyrow is(location, path, name, actions, when, ...)— same skeleton asTablePolicy.actions⊆{read, write, delete, list}.whenis a JSON AST.The expression model is a deliberately reduced subset of table-policies' operator set for v1:
and,or,noteqcall(allow-listed; v1 =has_role){user: ...},{file: ...}That's six AST node types, sufficient to express Everyone / Role / Creator and any combination thereof. Future operators (
lt,gt,in,is_null, etc.) are additive — they do not change the data shape.{file: ...}references resolve against a small, deliberate set:created_byfile_index-style)created_atpathlocationAny field beyond this set requires a sidecar-schema decision and is out of scope for v1.
Default deny. Admin bypass is a seeded rule, not a special case in the evaluator — same pattern table-policies adopted.
Shared engine extraction
When we land file policies, we also refactor
api/shared/policies/so the engine is domain-agnostic and the table-specific bits move to atable_policiesbinding. Concretely:Functions are shared from day one — no duplication of
has_role. Per-domain bindings stay separate. This refactor is a precondition for file policies, not a separate project, and lands as part of this work.UI surfaces
Three new admin surfaces, modeled on the table-policies admin editor:
Web SDK
files.signedUrl(path)/files.signedUrls(paths)(batch presigning),files.list(prefix),files.upload(path, blob),files.delete(path). Same shape as the workflow files SDK; access enforced server-side via the policy evaluator. Unblocks galleries and direct-from-browser file flows that bounce through the workflow engine today.Non-goals
WHERE location IN (...)-style policies.Open questions
file_indexsidecar scope. What metadata does it track, and on what write path? Form uploads, SDK uploads, workflowfiles.write, and direct S3 (admin) all need to converge on a single "this file exists, here's itscreated_by" record.FilePolicyrow whose(location, path)falls under the renamed prefix. Single user action, surfaced explicitly in the dialog ("this will move 47 files and update 3 policies").{eq: [{file: created_by}, {user: user_id}]}, list operations need to filter bycreated_by. This requires the sidecar to be queryable per prefix — a real implementation cost worth flagging.uploads/{form_id}/...with no scope segment) are unreachable through the resolver. File policies inherit that decision; legacy keys remain accessible only via direct S3 (admin) or workflows that hit the bucket directly.Closes
This issue closes when file policies ship into
mainwith the shared engine extraction, web SDK, admin UI, and an effective-access tester. The table-policies tracking work stays separate.cc: tracks
feat/table-access(table policies) and #155 (unified file path resolution).