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-alternative
  • https://zenstack.dev/blog/orm-2026
CrateStack is not trying to claim ZenStack feature parity. The goal is to make it obvious which policy patterns are supported today, which are partial, and which are still out of scope.

Current Semantics

Model policies:
  • @@allow(...) and @@deny(...) are supported
  • action names support list, detail, read, create, update, delete, and all
  • 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
Procedure policies:
  • @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
Auth-derived defaults:
  • create-time @default(auth().field) is supported
  • defaults are applied before create policy evaluation
  • nested auth paths like auth().organization.id are supported
  • defaults still do not allow arbitrary expressions or function calls

Matrix

CapabilityZenStack-style expectationCrateStack 2026 statusNotes
Model @@allowSupportedSupportedlist, detail, read, create, update, delete
Model @@denySupportedSupportedDeny precedence implemented
Action alias allSupportedSupportedExpands to list/detail/create/update/delete
Read action splitSupported in richer enginesSupportedlist scopes find_many, detail scopes find_unique, read applies to both
auth() != nullSupportedSupportedModel and procedure policies
auth() == nullSupportedSupportedModel and procedure policies
field == literalSupportedSupportedBoolean, Int, String subset
field != literalSupportedSupportedBoolean, Int, String subset
field == auth().fieldSupportedSupportedModel and procedure subset
field != auth().fieldSupportedSupportedModel and procedure subset
auth().field == modelFieldSupportedSupportedModel and procedure subset
auth().field != modelFieldSupportedSupportedModel and procedure subset
field == otherFieldSupported in richer enginesSupportedProcedure policies only
field != otherFieldSupported in richer enginesSupportedProcedure policies only
auth().field == literalSupportedSupportedModel and procedure subset
auth().field != literalSupportedSupportedModel and procedure subset
&& / || groupingSupportedSupportedParenthesized grouping supported in parser/lowering
Row-level read scopingSupportedSupportedSQL-scoped on find_many / find_unique
Row-level update scopingSupportedSupportedSQL-scoped
Row-level delete scopingSupportedSupportedSQL-scoped
Create-time policy checksSupportedPartialScalar/auth checks run in-memory; relation checks use DB lookups when join columns are present in create input/defaults
Create-time auth defaultsSupportedPartialOnly @default(auth().field)
Procedure @allowSupportedSupportedRuntime wrappers + routes
Procedure @denySupportedSupportedDeny precedence implemented
Procedure input field checksSupportedSupportedDirect args and args.<field> paths, with input/auth/input comparisons
DB-backed procedure authSupported in richer enginesPartial@authorize(Model, action, args.path) delegates to model detail/update/delete auth by id
Structured principal contextSupported in richer enginesPartialCoolContext now carries principal.actor/session/tenant/claims plus legacy auth() compatibility
Relation-based auth like auth() == authorSupportedSupportedSingle-column to-one relations that reference id
Nested auth paths like auth().org.idSupported in richer enginesSupportedExact auth keys still win; dotted paths traverse nested auth maps
Relation traversal inside policiesSupported in richer enginesPartialRecursive to-one and quantified to-many traversal are supported across model policies
Collection predicates in policiesSupported in richer enginesPartialSupports dotted some / every / none relation segments inside model policies
Built-in policy functionsSupported in richer enginesPartialhasRole('...') and inTenant('...') are supported as boolean terms in model and procedure policies
Arbitrary functions in policiesSupported in richer enginesNot supportedNo custom policy function plugin layer beyond the built-in term set
Forced server-owned fieldsSometimes supported with richer semanticsNot supported@default(auth().field) is fallback-only, not override-enforcement
Field-level read maskingSometimes supported in richer stacksNot supportedModel-level access only
Field-level write blockingSometimes supported in richer stacksNot supportedModel-level access only
Post-update input-aware policiesSometimes supported in richer stacksPartialCurrent update/delete checks are row-scoped SQL predicates
Durable external auth plugin engineSometimes supported via plugin/runtime systemsNot supportedCurrent engine is built-in and macro/runtime-local

Supported Examples

Ownership + published read

model Post {
  id String @id @default(cuid())
  title String
  published Boolean @default(false)
  authorId String

  @@allow('all', auth() != null && auth().id == authorId)
  @@allow('read', auth() != null && published)
}

List/detail split

model Post {
  id Int @id
  title String
  published Boolean
  authorId Int

  @@allow('list', published)
  @@allow('detail', published || authorId == auth().id)
}
Notes:
  • list applies to find_many
  • detail applies to find_unique
  • read remains the umbrella action when the same rule should apply to both

Organization scope + role allowlist

model Todo {
  id String @id @default(cuid())
  ownerId String
  title String
  organizationId String? @default(auth().organization.id)

  @@deny('all', auth().organization.id != organizationId)
  @@allow('all', auth().userId == ownerId || auth().organizationRole == 'owner' || auth().organizationRole == 'admin')
}

Recursive relation-aware read

model User {
  id Int @id
  email String
  banned Boolean
}

model Post {
  id Int @id
  published Boolean
  authorId Int
  author User @relation(fields:[authorId], references:[id])

  @@deny('read', author.banned)
  @@allow('read', auth() != null && published)
  @@allow('read', author.email == auth().email)
}

Moderation with deny override

auth SessionUser {
  id Int
  email String
  role String
}

model User {
  id Int @id
  email String
  suspended Boolean
}

model Post {
  id Int @id
  title String
  published Boolean
  flagged Boolean
  authorId Int
  author User @relation(fields:[authorId], references:[id])

  @@deny('read', author.suspended)
  @@deny('update', flagged && auth().role != 'admin')
  @@allow('read', published)
  @@allow('read', author.email == auth().email)
  @@allow('update', auth() == author)
}
Notes:
  • author.suspended is a relation-aware boolean deny
  • author.email == auth().email is a relation-aware ownership read rule
  • deny still overrides matching allow rules

Membership-scoped access

auth SessionUser {
  id Int
  email String
  role String
}

model User {
  id Int @id
  email String
  banned Boolean
}

model Membership {
  id Int @id
  active Boolean
  role String
  userId Int
  user User @relation(fields:[userId], references:[id])

  @@deny('read', user.banned)
  @@allow('read', auth() != null && user.email == auth().email && active)
  @@allow('update', auth().role == 'admin' && role != 'owner')
}
Notes:
  • combines relation-aware read checks with ordinary scalar checks
  • user.email == auth().email stays inside the supported recursive relation boundary
  • update remains row-scoped against the current record

Quantified to-many traversal

model Task {
  id Int @id
  projectId Int
  project Project @relation(fields:[projectId], references:[id])

  @@deny("read", project.memberships.some.user.banned)
  @@allow("read", project.organization.slug == auth().orgSlug && project.memberships.some.user.email == auth().email)
  @@allow("delete", project.memberships.every.active)
  @@allow("create", project.memberships.none.blocked)
}
Notes:
  • supports mixed recursive to-one and quantified to-many segments
  • some lowers to EXISTS, none lowers to NOT EXISTS, and every lowers to NOT EXISTS ... NOT (...)
  • create-time relation checks work when the traversed root join columns are available from create input/default expansion

Vendor catalog visibility

auth SessionUser {
  id Int
  email String
  role String
}

model Vendor {
  id Int @id
  contactEmail String
  blocked Boolean
}

model Product {
  id Int @id
  name String
  published Boolean
  vendorId Int
  vendor Vendor @relation(fields:[vendorId], references:[id])

  @@deny('read', vendor.blocked)
  @@allow('read', published)
  @@allow('read', vendor.contactEmail == auth().email)
  @@allow('update', vendor.contactEmail == auth().email)
  @@allow('delete', auth().role == 'admin')
}
Notes:
  • useful when ownership lives on the related row rather than the base model row
  • vendor.contactEmail == auth().email works for read and row-scoped update
  • admin delete stays a plain auth-field check

Procedure allow + deny

mutation procedure approvePost(args: ApprovePostInput): Post
  @allow(auth() != null && auth().role == 'admin' && publishNow)
  @deny(postId == 2)

Procedure auth with nested input paths

type ReviewPostInput {
  postId Int
  publishNow Boolean
  dryRun Boolean
  ownerEmail String
  mirrorEmail String
}

mutation procedure reviewPost(args: ReviewPostInput): Post
  @allow((auth() == null && args.dryRun) || (auth().role == 'admin' && args.publishNow && args.ownerEmail == auth().email))
  @deny(args.postId == 2 || args.ownerEmail != auth().email || args.ownerEmail != args.mirrorEmail)
Notes:
  • 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

type OrganizationScope {
  id String
  slug String
}

auth SessionUser {
  userId String
  organization OrganizationScope
}

model Todo {
  id String @id @default(cuid())
  ownerId String
  organizationId String @default(auth().organization.id)

  @@deny('all', auth().organization.id != organizationId)
  @@allow('all', auth().userId == ownerId)
}
Notes:
  • nested auth lookups traverse structured auth objects carried in CoolContext
  • CoolContext now carries a first-class principal.actor/session/tenant/claims shape 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

model AdminPanel {
  id String @id @default(cuid())
  title String

  @@allow('read', hasRole('admin') && inTenant('tenant_1'))
}

mutation procedure adminPulse(args: InspectPostInput): Post
  @allow(hasRole('admin') && inTenant('tenant_1'))
Notes:
  • hasRole('...') checks the top-level role claim and falls back to actor.role
  • inTenant('...') checks the structured tenant.id claim
  • both functions are boolean terms that can participate in grouped && / || expressions
  • only a single string literal argument is supported today

DB-backed procedure delegation

type InspectPostInput {
  postId String
}

query procedure inspectPost(args: InspectPostInput): Post
  @allow(auth() != null)
  @authorize(Post, detail, args.postId)
Notes:
  • @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, and delete
  • the delegated check returns forbidden when the referenced row is missing or not visible under the caller context

Not Supported Yet

These should be rejected or treated as future work:
@@allow('read', members?[userId == auth().id])
@@allow('read', members.some.user.role == hasRole('admin'))
ownerId String @default(lower(auth().email))

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 / none segments
  • 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:
  • cratestack/crates/cratestack/tests/include_schema.rs
  • cratestack/crates/cratestack/tests/policy_db.rs
  • cratestack/crates/cratestack/tests/policy_db_advanced.rs
  • cratestack/crates/cratestack/tests/policy_db_auth_engine.rs
  • cratestack/crates/cratestack/tests/policy_db_recursive.rs
Those tests cover:
  • 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 / none model policy segments
  • create-time DB-backed relation policy checks when root join values are present in the create input
  • built-in hasRole('...') and inTenant('...') 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
  • CoolContext now carries a first-class principal.actor/session/tenant/claims model, but explicit impersonation, acting-as, and delegated-session semantics are still unsupported
  • arbitrary policy functions beyond hasRole('...') and inTenant('...') are still unsupported
  • field-level read masking and field-level write blocking are still unsupported