Skip to content

RFC: File Policies — RLS-style rules for direct file reads/writes from apps #170

@jackmusick

Description

@jackmusick

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:

  • 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:

api/shared/policies/
├── ast.py              # AST node Pydantic models (shared)
├── evaluate.py         # Generic walker, takes a resolver (shared)
├── compile.py          # Generic SQL/predicate compiler, takes a binding (shared)
├── functions.py        # has_role, has_permission, ... (shared)
└── probe.py            # Static analysis (shared)

api/shared/table_policies.py   # Row resolver + JSONB binding
api/shared/file_policies.py    # File-metadata resolver + file_index binding

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:

  1. 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.
  2. Rule editor. Same Monaco-JSON editor + templates + reference panel as table-policies, with the file-specific reference set.
  3. 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

  1. 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.
  2. 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").
  3. 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.
  4. 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.


cc: tracks feat/table-access (table policies) and #155 (unified file path resolution).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions