Summary#
A security { } block on an entity decides who may read and write its rows. It is not a check you call — it is
compiled into every query and every write, so there is no code path that can go around it. A row you may not see
is not fetched and hidden; it is never in the result at all.
Two kinds of rule, and the distinction is the whole model:
wherefilters by the row — the owner sees their own rows.whengates by the principal — staff see everything.
Signature#
entity <E> {
// Everything is denied already. You do NOT write `default deny` — you only write what is ALLOWED.
security {
allow <verbs> when <policy>; // …to principals satisfying this policy
allow <verbs> where <row predicate>; // …only the rows matching this
deny read <Field> when <policy>; // a FIELD mask — the row comes back, the field does not
}
}
// <verbs> — one, or several, comma-separated:
// read · create · update · delete
policy <Name> => <predicate over `user`>; // a named, reusable principal testDescription#
You only ever write what is ALLOWED#
Everything is denied before you say a word, so a security { } block is a list of grants. There is nothing to
turn off first, and you never write default deny — that is the state you are already in.
The clearest demonstration is the locked entity: to make one that nobody may read or create, say nothing at all.
entity Secret {
[MaxLength(100)] string Code;
// No security block. Nobody reads it, nobody creates it. Locked, by saying nothing.
}
entity Article {
[MaxLength(200)] string Title;
// Public — and note you must say WHO: a bare `allow read;` with no `when`/`where` is a compile error.
security { allow read when IsAuthenticated || IsAnonymous; }
}That is the whole posture in one screen: silence denies, and only a grant opens. The failure mode of forgetting a rule is "nobody can do it" — reported within the minute — rather than "everybody can", which nobody reports until it is somebody else's headline.
where — the owner sees their own#
A where clause is a predicate over the row, and user is the acting principal. It becomes part of the query:
[Principal] entity User {
[Required] string Name;
}
entity Doc {
User Owner;
[MaxLength(200)] string Title;
security {
allow read where Owner == user; // I see mine; you see yours
}
}Under this rule Doc.Count() returns a different number for different users, and both are correct. That is the
point: the filter is part of the query, not a mask applied afterwards.
Read and write are separate#
They usually differ — a team can all read a memo, but only its author may change it:
entity Memo {
User Owner;
[MaxLength(200)] string Note;
security {
allow read when IsAuthenticated; // any signed-in colleague can see it
allow update where Owner == user; // only the author can change it
}
}Which verbs can I grant?#
A grant names what may be done: read · create · update · delete. Several may share one rule,
comma-separated — and they routinely differ, which is the point of naming them separately:
entity Organization {
[Required, MaxLength(200)] string Name;
[Required, MaxLength(80)] string Slug;
security {
allow read when IsStaff; // staff can see every org
allow create, update, delete when IsAdmin; // only an admin may change the SET of them
}
}A rule with no verb list is not a thing you can write: you always say what is being allowed. "Access" is not a permission — reading is, and deleting is, and they are not the same decision.
A where on create — checking the row you are writing#
A where filters existing rows for read/update/delete. On create there is no existing row — so the same
where is checked against the row you are writing: a create is refused unless the new row satisfies the predicate.
(This is a WITH-CHECK, the INSERT half of row-level security.) One predicate governs all four verbs — you may only
bring into existence a row you would be allowed to own:
entity Ledger {
User Owner;
[MaxLength(200)] string Note;
security {
// The SAME `where` governs create: on the commit path it validates the ROW being written.
allow read, create, update, delete where Owner == user;
}
}Here a caller may create a Ledger owned by themselves (the row satisfies Owner == user), but a create whose
Owner is someone else is denied at commit and rolled back — not saved-then-hidden.
This is the only way to scope a create by a column the app sets itself (an Owner ref, an Organization an
app supplies). It is different from the auto-stamped ownership idiom — allow create when IsAuthenticated +
allow read, update, delete where CreatedBy == user.Id — which is safe only because CreatedBy is stamped by the
platform and cannot be forged. When the scoping column is one the caller writes, a role-only when (which never sees
the row) would let them write it for any tenant; the create where is what refuses that.
deny read <Field> — the field mask#
Sometimes the ROW is fine and one FIELD is not. A password hash is the canonical case: the login flow must read it to verify a credential, and nobody else ever should — not an admin, not the user themselves, not a support tool, not an export.
deny read <Field> when <policy> masks a single field. The row still comes back; the field does not:
// `IsAuthenticator` is the ephemeral principal the login flow runs as (see [auth bootstrap (login, before anyone is signed in)](/reference/security/auth-bootstrap/)).
policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator);
entity Credential {
[Required] User Owner;
/// Salted hash of the password — never the plaintext.
[MaxLength(200)] string PasswordHash;
security {
allow read when IsStaff; // staff read the directory …
deny read PasswordHash when !IsAuthenticator; // … but the hash, only the auth flow, ever
allow update where Owner == user; // a user maintains their own credential
}
}This is worth reaching for more often than people do. A field mask is a narrow, auditable statement — this column, to these people, never — and it survives every future query, endpoint, export and tool automatically, because it is enforced where the data is read rather than wherever someone remembered to be careful.
when — gate by who is asking#
A where asks "is this row yours?". A when asks "are you the kind of person who may do this at all?". Name that
test with a policy and reuse it:
enum AppRole { Staff, Admin, Authenticator }
entity RoleGrant {
User Grantee;
[Required] AppRole Level;
}
policy IsStaff => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Staff);
policy IsAdmin => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Admin);
entity Report {
[MaxLength(200)] string Title;
security {
allow read when IsStaff; // staff read every report; nobody else reads any
}
}A policy is written once and referenced everywhere, so the definition of "staff" lives in one place. When it
changes, it changes everywhere — which is the only way it stays true.
Test it, or you have not written it#
A security rule you have not tested is a rule you believe you wrote. Prove it, with runas and
Assert.Denied:
[TestFixture]
void Seed() {
var alice = new User { Name = "Alice" };
var bob = new User { Name = "Bob" };
var aliceDoc = new Doc { Owner = alice, Title = "alice-doc" };
}
[Test(Seed)]
void Bob_cannot_see_Alices_doc() {
var bob = User.Single(u => u.Name == "Bob");
runas(bob) {
Assert.Equal(0, Doc.Count()); // not "hidden" — for Bob, the row does not exist
}
}Write one of these for every rule that matters. It is what stops a refactor six months from now from quietly opening a door nobody notices is open.
See also#
- secure by default (deny-all) — what an entity permits before you write any block
- principal predicates (IsAuthenticated / IsAnonymous) and open reads — the
usera rule compares against - runas — acting as a principal, so a rule can be tested
- Assert —
Assert.Denied