Auth Support Matrix
This document records the current executable CrateStack auth and policy surface. The matrix categories are intentionally modeled after the public ZenStack 2025/2026 access-policy surface described in:https://zenstack.dev/blog/prisma-alternativehttps://zenstack.dev/blog/orm-2026
Current Semantics
Model policies:@@allow(...)and@@deny(...)are supported- action names support
list,detail,read,create,update,delete, andall - deny wins over allow
- if no matching allow rule exists, access is denied
- canonical model-policy literals, predicates, and expressions now live in
cratestack-policy
@allow(...)and@deny(...)are supported- deny wins over allow
- if no allow rule exists, invocation is denied
- canonical procedure-policy literals, predicates, and expressions now live in
cratestack-policy
- a field-level
@allow(...)/@deny(...)is a compile error, on all five field-bearing declaration kinds:model,view,mixin,typeand theauthblock. The error names the offending field - it used to parse, report
schema OK, sit in the IR, and be read by nothing — an annotation that reads as access control and enforces none. The field reached every caller the model-level read policy admitted, exactly as if it were absent - what to reach for instead: model/view-level
@@allow/@@denyfor row visibility,@readonlyto keep a field out of generated inputs, and@server_onlyto keep it out of client responses - this targets the field-position, single-
@case only — procedure-level@allow/@denyand model/view-level@@allow/@@denyare untouched
Only half of #679 is
closed. Unknown field attributes are still accepted generally, so a
misspelled
@raedonly silently drops @readonly and leaves the field
writable. Catching that needs a generic unknown-attribute pass, which is
an intentional non-choice today — so don’t rely on the parser to catch a
typo’d exposure attribute.- create-time
@default(auth().field)is supported - defaults are applied before create policy evaluation
- nested auth paths like
auth().organization.idare supported - defaults still do not allow arbitrary expressions or function calls
Matrix
Supported Examples
Ownership + published read
List/detail split
listapplies tofind_manydetailapplies tofind_uniquereadremains the umbrella action when the same rule should apply to both
Organization scope + role allowlist
Recursive relation-aware read
Moderation with deny override
author.suspendedis a relation-aware boolean denyauthor.email == auth().emailis a relation-aware ownership read rule- deny still overrides matching allow rules
Membership-scoped access
- combines relation-aware read checks with ordinary scalar checks
user.email == auth().emailstays inside the supported recursive relation boundaryupdateremains row-scoped against the current record
Quantified to-many traversal
- supports mixed recursive to-one and quantified to-many segments
somelowers toEXISTS,nonelowers toNOT EXISTS, andeverylowers toNOT EXISTS ... NOT (...)- create-time relation checks work when the traversed root join columns are available from create input/default expansion
Vendor catalog visibility
- useful when ownership lives on the related row rather than the base model row
vendor.contactEmail == auth().emailworks for read and row-scoped update- admin delete stays a plain auth-field check
Procedure allow + deny
Procedure auth with nested input paths
args.<field>works for nested object input checks- procedure policies now support input-vs-auth and input-vs-input equality/inequality
- deny still overrides allow
Nested auth context paths
- nested auth lookups traverse structured auth objects carried in
CratestackContext CratestackContextnow carries a first-classprincipal.actor/session/tenant/claimsshape internally- canonical policy types are shared through
cratestack-policy; model and procedure auth now lower onto the same runtime policy surface - an exact auth key still wins before dotted traversal, so existing flat claims stay backward compatible
Built-in role and tenant checks
hasRole('...')checks the top-levelroleclaim and falls back toactor.roleinTenant('...')checks the structuredtenant.idclaim- both functions are boolean terms that can participate in grouped
&&/||expressions - only a single string literal argument is supported today
DB-backed procedure delegation
@authorize(Model, action, args.path)performs an extra DB-backed model auth check before invoking the procedure body- current delegated actions are
detail/read,update, anddelete - the delegated check returns forbidden when the referenced row is missing or not visible under the caller context
Not Supported Yet
Treat these as future work — not all of them fail the same way:?[...] collection-predicate syntax isn’t part of this grammar — use dotted some/every/none instead; comparing a boolean-returning term like hasRole(...) with == isn’t a supported shape). The third is not cleanly rejected: @default(...) recognition only matches the exact auth().<path> shape, so lower(auth().email) isn’t classified as an auth default at all — it falls through to cratestack-migrate’s generic function-call default handling (ColumnDefault::Function) and gets emitted verbatim into the DDL as DEFAULT lower(auth().email). Since auth() isn’t a real SQL function, that fails at migrate diff/apply time as invalid Postgres, not at schema-compile time. Don’t rely on any static guarantee here — this shape reaches the database before it’s caught.
Security Notes
Current security posture is intentionally conservative:- unsupported policy shapes fail generation instead of silently degrading
- missing allow rules deny by default
- deny rules override allow rules
- built-in policy functions remain intentionally narrow and deterministic
- unauthenticated creates that depend on required auth-derived defaults fail cleanly as forbidden
- relation-aware model policies support recursive to-one traversal plus dotted
some/every/nonesegments - create-time relation checks only succeed when the root relation join values are known from create input/defaults; otherwise the relation predicate evaluates false and the create is denied
Test Coverage
Current coverage for the supported matrix lives primarily in:crates/cratestack-pg/tests/include_schema.rscrates/cratestack-pg/tests/policy_db.rscrates/cratestack-pg/tests/policy_db_advanced.rscrates/cratestack-pg/tests/policy_db_auth_engine.rscrates/cratestack-pg/tests/policy_db_recursive.rs
- model allow/deny precedence
- procedure allow/deny precedence
- route-level forbidden vs hidden behavior
- auth-derived defaults across direct and HTTP paths
- recursive relation traversal plus dotted
some/every/nonemodel policy segments - create-time DB-backed relation policy checks when root join values are present in the create input
- built-in
hasRole('...')andinTenant('...')checks across direct, SQL-scoped, and procedure authorization paths - non-invocation of denied procedures
Remaining Limits
Current limits that still matter in practice:- create-time relation checks are partial: they only work when the root relation join values are known from create input or auth-derived default expansion
- procedure DB-backed auth is partial:
@authorize(Model, action, args.path)delegates to model auth by referenced id, but there is still no general DB-querying procedure policy language - CratestackContext now carries a first-class
principal.actor/session/tenant/claimsmodel, but explicit impersonation, acting-as, and delegated-session semantics are still unsupported - arbitrary policy functions beyond
hasRole('...')andinTenant('...')are still unsupported - field-level read masking and field-level write blocking are still unsupported