zenstack-access-control — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited zenstack-access-control (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
ZenStack enforces access policies declared in ZModel at the ORM layer. Policies live next to your models (see zenstack-schema-modeling); enforcement is opt-in via a runtime plugin. For client/query basics — and for data validation — see zenstack-querying.
After editing policies, run zen generate to regenerate the client (it also validates the schema, policy expressions included). If you only want to validate without regenerating, run zen check.
Policies do nothing until you (1) declare the plugin in the schema and (2) install it on the client.
Schema:
plugin policy {
provider = '@zenstackhq/plugin-policy'
}Install the package: npm install @zenstackhq/plugin-policy.
Client:
import { ZenStackClient } from '@zenstackhq/orm';
import { PolicyPlugin } from '@zenstackhq/plugin-policy';
const db = new ZenStackClient(schema, { dialect }); // raw, unprotected
export const authDb = db.$use(new PolicyPlugin()); // access control enforcedZenStackClient instances are immutable — $use() and $setAuth() return new (cheap, shallow- clone) instances and never mutate the original. Query with db to bypass policies (e.g. trusted server code) and with the policy-enforced client for user-facing access.
auth() and $setAuth()Bind the authenticated user per request; auth() in policies resolves to it.
const userDb = authDb.$setAuth(user); // user-bound client
await userDb.post.findMany(); // policies evaluated against `user`$setAuth() → anonymous mode → auth() is null. $setAuth(undefined) forces anonymous.client.$auth.Define what `auth()` returns, by priority: a model/type annotated @@auth, otherwise a model literally named User, otherwise a compile error. Using a type decouples the auth shape from your tables:
type Auth {
id Int
role String
@@auth
}@@allow / @@deny@@allow(operation, condition)
@@deny(operation, condition)Operations (comma-separate to combine): create, read, update, delete, post-update, and all (= create+read+update+delete, not post-update).
Evaluation (secure by default): any matching @@deny → denied; else any matching @@allow → allowed; else denied. read/update/delete silently filter out rows that fail, rather than erroring; create is checked before insert.
model User {
id Int @id @default(autoincrement())
email String @unique
posts Post[]
@@allow('create,read', true) // anyone can sign up; profiles public
@@allow('all', auth().id == id) // the user has full access to themselves
}
model Post {
id Int @id @default(autoincrement())
title String
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int
@@deny('all', auth() == null) // no anonymous access
@@allow('read', published) // published posts readable by anyone
@@allow('all', auth().id == authorId) // author has full access
}== != > >= < <=, && || !, and field references.auth().city == profile.address.city.relation?[cond] — some matchrelation![cond] — all matchrelation^[cond] — none match @@deny('delete', posts?[published == true]) // can't delete a user with published poststhis to escape the collection scope: comments?[author.verified && this.published].relations don't exist yet at creation time.
duplication: @@allow('read', check(user)) / @@allow('update', check(user, 'update')).
contains, startsWith, endsWith (each takes anoptional case-insensitive flag), has, hasSome, hasEvery, isEmpty, currentModel(), currentOperation().
Single-@ @allow / @deny on a field, operations read and update only.
model User {
id Int @id
email String @allow('update', auth() == this) // only the owner can change it
name String @deny('read', auth() == null) // hidden from anonymous readers
}null, still usable in SQLcomputations) — you can't distinguish a real null from a denied one.
ORMError, reason REJECTED_BY_POLICY).post-update expresses conditions that must hold after an update; field references see the new values, and before() reads the old values. (This replaces V2's future().)
model Post {
published Boolean @default(false)
authorId Int
@@deny('post-update', published == true && auth().id != authorId) // only author can publish
@@deny('post-update', before().authorId != authorId) // authorId is immutable
}If a model has no post-update rule, an update only needs to pass update. If it has any, at least one @@allow('post-update', ...) must pass.
Per mutation, policies are checked before the database is touched (after any input validation — see zenstack-querying). Policy-related ORMError.reason values: REJECTED_BY_POLICY (policy) and NOT_FOUND (missing or filtered out by a read policy). REJECTED_BY_POLICY further carries rejectedByPolicyReason: NO_ACCESS, CANNOT_READ_BACK (mutation allowed but result not readable back), or OTHER.
$queryRaw/$executeRaw) and query-builder access bypass policies. Allow raw SQL whilethe policy plugin is installed only via new PolicyPlugin({ dangerouslyAllowRawSql: true }) (v3.5.0+).
Full ZenStack documentation for this topic is bundled under references/:
@@allow/@@deny, auth(), expressions$setAuth runtimepost-update rules and before()~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.