Guides

Add an access policy

Row filters, field masks, attribute checks. The full AccessModel surface explained.

Vadyl's access model is part of your product model — not a middleware you bolt on, not a library you wire up per route. It lives on the entity, gets compiled into native row-level security where the provider supports it, and is enforced uniformly across REST, GraphQL, gRPC, the SDK, and authored runtime.

The five facets

An AccessModel defines five canonical facets:

  • readFilter — predicate a row must satisfy to be returned.
  • insertCheck — predicate a row must satisfy on create.
  • updateTargetFilter — predicate the existing row must satisfy to be eligible for update.
  • updateResultCheck — predicate the new row must satisfy after update.
  • deleteFilter — predicate the row must satisfy to be deleted.
access.model({
  readFilter:         access.where("ownerId", "==", ctx.userId),
  insertCheck:        access.where("ownerId", "==", ctx.userId),
  updateTargetFilter: access.where("ownerId", "==", ctx.userId),
  updateResultCheck:  access.where("ownerId", "==", ctx.userId),
  deleteFilter:       access.deny(),
});

Field-level rules

Beyond row-level checks, you can specify per-field read/write rules with masking:

access.model({
  readFilter: access.allow(),
  fieldRules: {
    ssn: {
      canRead:     access.where("role", "in", ["owner", "admin"]),
      maskWhen:    access.deny(),         // mask if cannot read
      maskingMode: "redacted",            // show "***" instead of null
    },
    notes: {
      canWrite: access.where("role", "in", ["owner", "support"]),
    },
  },
});

Masking modes: omit (drop the field), null (return null), redacted (return a placeholder string).

Attribute predicates

Predicates can reference the actor context — typed primitives that fail closed if not bound:

  • ctx.userId — canonical subject id
  • ctx.hasRole(role) — role membership
  • ctx.hasClaim(name, value) — JWT-style claim check
  • ctx.inContextSet(set) — context-set membership (org membership, project membership, etc.)
  • ctx.contextValue(key) — opaque scoped value (tenant id, region, etc.)
  • ctx.authStrengthAtLeast(level) — minimum auth strength gate
  • ctx.authenticatedVia(connector) — specific connector
  • ctx.subjectTypeIs("user" | "agent" | "service") — actor type

Bypass conditions

Sometimes service code needs to operate above the access model — a background job reconciling state, a webhook handler the user didn't initiate. Use a typed bypass:

access.model({
  // ...
  bypass: {
    fullBypass: access.where("subjectType", "==", "service")
                   .and(access.hasRole("system:reconciler")),
  },
});

Every bypass invocation is recorded on theAccessEnforcementDiagnostics.Bypasses audit trail with the entity, facet, actor, and reason — so you always know when the envelope was opened.

Native vs runtime enforcement

Vadyl's access engine consults the bound provider's SecurityCapabilities and decides per-facet:

  • Native — predicate is compiled into RLS / row-level filters in the database.
  • Runtime — predicate is enforced at the application layer via filter injection.
  • Hybrid — partial native, runtime supplementation for unsupported clauses.

Either way, the canonical decision is the same — your product intent doesn't care which mode the provider supports.

Explainability

Every access decision carries a stable reason code. Use the explainability plane to see exactly why a read or write was allowed or denied:

vadyl explain access --entity Order --filter "status=paid" --as user:abc