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.
They differ in what they ask, never in when they ask it. Both are decided against the facts as they stand at the moment of the read or the write — including a grant your own code committed a line earlier. See [[#same-freshness|the same freshness]].
A where on an update is asked about the row twice — as it is, and as it will be — and both must pass. That
is what makes a status gate a gate: see [[#update-both-images|before and after]].
A rule may also name a single field. Rules resolve in [[#layers|layers]], from general to specific — the entity's
rules, then a field's blanket, then a field's conditioned rules — and each layer overwrites only the callers and rows
it applies to. So a field allow only ever ADDS the column for the callers it names, and narrowing a field takes its
blanket deny. A field rule may take either a when (principal-only) or a [[#field-mask-row-scoped|where]]
(row-scoped, decided per row) — the same two shapes an entity-level rule takes.
Signature#
entity <E> {
// Everything is denied already. You do NOT write `default deny` — you only write what is ALLOWED.
security {
message "…"; // what a person reads when a WRITE here is refused (optional)
allow <verbs> when <policy>; // …to principals satisfying this policy
allow <verbs> where <row predicate>; // …only the rows matching this
// A rule may name ONE FIELD. Rules resolve general → specific — the entity's, a field's blanket, a field's
// conditioned rules — each overwriting only the callers and rows it applies to:
deny read <Field> when <policy>; // hides the column from these callers — the row still comes back
deny read <Field> where <row predicate>; // … decided per row, like an entity rule's `where`
deny <verb> <Field>; // the field's BLANKET: taken from everyone the entity granted it to …
allow <verb> <Field> when <policy>; // … and handed back to these callers. A field allow only ever ADDS.
}
}
// <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.
A where says which rows; it does not say who. A rule with a where and no when serves signed-in callers only —
a signed-out visitor gets none of its rows, through a page and a server function alike. To open rows to visitors,
name them: allow read when IsAnonymous || IsAuthenticated where Status == Published; ([[security-public-reads#grants]]).
this in a rule is the row itself — what to write when a policy takes the row as its argument:
allow read when IsAuthenticated where IsMemberOf(this); (The Organizations kit — companies, teams, and roles held within one). A policy whose parameter is a
base type (Organization) takes a row of a type derived from it (Company).
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 on read and on 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 stamped ownership idiom — a [CreatedBy] Guid? CreatedBy; field,
allow create when IsAuthenticated + allow read, update, delete where CreatedBy == user.Id — which is safe only
because a [CreatedBy] field is written by the platform and cannot be forged ([CreatedAt] [CreatedBy] [ModifiedAt] [ModifiedBy]). 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.
An update is checked against the row BEFORE and AFTER#
An update has two versions of the row, and the where is asked about both:
- the row as it is — may you touch this row at all?
- the row as it will be — may the row become this?
Both must pass. Read it as the two halves you already know: read/delete ask only the first, create only the
second (there is no "before"), and update — which has both — asks both.
This is the rule that surprises people, so here it is on the shape everybody writes. A timesheet is editable only while it is a draft:
enum SheetStatus { Draft, Submitted }
entity Timesheet {
[Required] User Owner;
[MaxLength(140)] string Note;
SheetStatus Status = SheetStatus.Draft;
security {
allow read, create where Owner == user;
allow update where Owner == user && Status == SheetStatus.Draft;
// Moving the sheet ON is its own grant, scoped to the one column it changes. Without this line the sheet
// could never leave Draft, because a row that ends up Submitted is not a row the rule above admits.
allow update Status where Owner == user;
}
}With that block:
| the write | why |
|---|---|
edit Note while Draft | ✅ both versions of the row are Draft |
edit Note once Submitted | ❌ the row as it is fails the gate |
set Status to Submitted | ✅ through the field-scoped grant, which governs Status alone |
set Status back to Draft and rewrite Note in one write | ❌ the row as it is is Submitted, so the entity-level gate refuses the Note — you cannot re-enter the gate to get past it |
⚠ That last row is the point of asking about both. If only the after-version were checked, a caller could edit any
row at all simply by setting Status = Draft in the same write and carrying every other column with it — and a gate
written that way would stop nobody. If only the before-version were checked, a caller inside the gate could write the
row into a state their own rule forbids.
⚠ And the third row is why a status gate needs a second rule. allow update where … Status == Draft deliberately
does not permit Draft → Submitted: leaving the gated state is a different decision from editing inside it, so it
gets its own grant. A [[#field-write|field-scoped rule]] is the usual way to say it: it ADDS that one column for the
rows it names, so leaving Draft becomes possible while everything else stays gated.
A rule that looks at its OWN table is never satisfied by the row you are writing#
The commonest authorization rule in the language looks at the very table it governs — "whoever already manages this thing may manage its roster":
enum SeatKind { Lead, Assistant }
entity Desk {
[MaxLength(60)] string Label;
security { allow read when IsAuthenticated; }
}
entity DeskSeat {
[Required] Desk Desk;
[Required] User Holder;
SeatKind Seat = SeatKind.Assistant;
security {
allow read when IsAuthenticated;
// The subquery ranges over DeskSeat — the entity being written. That is the shape this section is about.
allow create, update, delete where DeskSeat.Any(s => s.Desk == Desk && s.Holder == user && s.Seat == SeatKind.Lead);
}
}Read literally, that rule has a way of answering itself: if the subquery could see the row you are writing, then
writing {Desk: theirs, Holder: me, Seat: Lead} — or setting Seat = Lead on your own Assistant row — would be what
makes the rule say yes. It cannot. A write's rule is never decided on the pending state of the row it is deciding
about:
| the write | what the subquery sees of the row under judgment | outcome |
|---|---|---|
| an Assistant makes themselves a Lead | nothing — the row as it is committed, still Assistant | ❌ refused |
| a stranger writes themselves onto an empty roster as Lead | nothing — the row does not exist yet | ❌ refused |
| a Lead edits their own seat row | the row as it is committed, Lead | ✅ allowed |
| a Lead steps down to Assistant | the row as it is committed, Lead | ✅ allowed |
| a Lead promotes somebody else | the Lead's own row, which is not the row being judged | ✅ allowed |
So the rule means what it plainly says, and you do not have to write it defensively. Two consequences worth holding:
- A create's subquery cannot see the new row at all — it does not exist, so it is not evidence for its own
admission. That is what makes the "founding" idiom sound:
allow create where Owner == user && !Team.Any(t => t.Workspace == Workspace)grants the FIRST row in a workspace and never a second. - An update's subquery sees the row as it is COMMITTED — not as your write leaves it. That is the row-3 and row-4 cases above: a manager editing their own membership keeps the authority they already hold, including when the write is the one that gives it up.
⚠ This is separate from [[#update-both-images|before and after]], which is about the row's own COLUMNS. A
predicate that reads a column directly (allow update where Holder == user) is asked about both versions of the row,
so seizing the column in the same write is refused by the before-version. The rule here is about a subquery over
the same entity, which has no "before and after" of its own to pick between — it reads committed rows.
The row is fine and one FIELD is not — deny read <Field>#
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.
A mask can filter by ROW too — deny read <Field> where <row predicate>#
deny read <Field> when <policy> answers ONE question for the whole request: "is this caller the kind of person who
may see the column at all?" — a when has no row in scope, so it can never depend on WHICH row. Sometimes the real
rule needs the row too: "visible to the account's own person, or to Finance" is a per-row exception a when alone
cannot express.
A field mask takes a where for exactly this, evaluated per row the SAME way a row-level rule's where is:
entity BankAccount {
[Required] User Owner;
[MaxLength(34)] string? Iban;
security {
allow read when IsStaff;
deny read Iban when !IsAdmin where Owner != user; // an admin, or the account's own person — nobody else
}
}The two halves sit where they belong: when !IsAdmin is WHO the mask is for (everyone but an admin), where Owner != user is WHICH ROWS (somebody else's). Masked unless the caller is privileged, or it is their own row — and for an admin
the mask is never asked at all. Writing the role test into the where (where !IsAdmin && Owner != user) is a compile
error that prints this rewrite (when is who, where is which rows). The where may equally stand alone
(deny read Notes where Owner != user;) — a raw predicate, nothing to declare.
The mask is still exclusionary only, never a grant. It can only ever HIDE more of what the row-level rule already
allowed — it cannot expose a column on a row the row-level rule has already excluded. The row filter decides whether
there is a row to talk about at all; a field mask never runs ahead of it, and there is no caller for whom a mask
"opens" a row nobody's allow read granted them.
A masked field reads its default in your own queries too#
A masked field READS AS its type's default: null for a reference, a string or a T?, and for a non-nullable value
type the type's own default — 0, false, DateTime.MinValue, the enum's zero — so p.Salary + 1 is ordinary
arithmetic for every reader. Security.IsMasked(p.Salary) tells that stand-in from a real value
(Security.IsMasked — was this value withheld from me?).
A mask hides the column everywhere the caller's query could use it, not only in the rows that come back. In the
caller's OWN query a masked column reads as that same default — in a Where, a Count, an OrderBy, a GroupBy, an
aggregate, a navigation through it (p.Locker.Code), and a collection keyed by it — so nobody can learn a value they
may not see by filtering, counting or sorting on it:
entity Locker {
[MaxLength(40)] string Code;
[ForeignKey(Locker)] Parcel[] Parcels;
security { allow read when IsStaff; }
}
entity Parcel {
[MaxLength(40)] string Label;
Locker? Locker;
security {
allow read when IsStaff;
deny read Locker when !IsAdmin; // which locker a parcel sits in: admins only
}
}
// For a member of staff who is not an admin, `Locker` is null in these queries exactly as it is on the rows:
int ParcelsIn(Locker locker) { return locker.Parcels.Count(); } // 0 — the same as Parcels.ToList().Count
int ParcelsAt(string code) { return Parcel.Count(p => p.Locker.Code == code); } // 0
// …and for an admin, both answer the real count.A rule is the exception: a security {} condition that reads the column — allow read where Locker != null, or
another entity's rule that walks Parcel.Locker — decides on the real value, because a rule must judge the data as it
is. Only the caller's own query reads it masked.
When to split the field into its own entity instead. If the sensitive column wants a genuinely INDEPENDENT
security posture — its own auditing, its own write rule, a lifecycle of its own — give it its own entity with an
ordinary row rule (allow read where IsAdmin || Owner == user;) rather than masking it on the parent. Both are
correct; the split earns its keep when the field is not merely hidden but actually governed differently from the
rest of its row.
A condition that cannot be decided never widens access#
With nobody signed in, user is null — so Owner != user is neither true nor false, it is unknown, exactly as a
comparison with a null is in SQL. The same goes for two missing values: Team == user.Team for a caller with no team,
on a row with no team, is unknown — two blanks are not a match. An unknown condition always lands on the safe side:
| rule | condition unknown |
|---|---|
allow | not granted |
deny | denied — the rule applies |
So one rule covers everyone, signed in or not:
deny read Iban where Owner != user; // hidden from a stranger, from a visitor, and from a grant link's holder
allow update where Team == user.Team; // a caller with no team edits no row, the team-less ones includedThis holds for a row rule, a field rule and a write rule, and for a when guard, wherever the platform decides it —
in the database or at commit, for one row or a set-based Where(…).Update(…). It is the answer a rule gets when its
condition cannot be evaluated at all: a rule you wrote is never switched on, or off, by a value that is missing.
Comparing with null itself is not unknown — Owner == null is plainly true on an unowned row. So say it when "nobody
owns it" is meant to count:
allow read Contact where Owner == null || Owner == user; // unowned rows are public; owned ones are the owner's
deny read Iban where Owner != null && Owner != user; // an unowned row keeps its Iban readableThe same holds for a deny create on an optional column: deny create where Role == AppRole.Owner; refuses EVERY row
whose Role is not set, because "not set equals Owner" is unknown and an unknown deny applies. Write the case it is
about — deny create where Role != null && Role == AppRole.Owner;. A write refused this way says so: "this row's
condition cannot be decided … a value it compares is not set on this row", not merely that a rule matched.
A rule through a reference that is empty#
A condition may reach through a reference: Customer.Name == "Protected" reads the order's customer, then its name.
When the reference is empty — the order has no customer, or names one that is not there — there is no name to
compare, so the condition is unknown, and an unknown deny applies. So this rule protects more than the protected
customer's orders: it also refuses deleting every order with no customer.
entity Customer {
[Required, MaxLength(80)] string Name;
security { allow read, create, delete when IsAnonymous || IsAuthenticated; }
}
entity Order {
[Required, MaxLength(80)] string Title;
Customer? Customer;
security {
allow read, create, delete when IsAnonymous || IsAuthenticated;
deny delete where Customer.Name == "Protected"; // … and every order with no customer too
}
}
entity Invoice {
[Required, MaxLength(80)] string Title;
Customer? Customer;
security {
allow read, create, delete when IsAnonymous || IsAuthenticated;
deny delete where Customer != null && Customer.Name == "Protected"; // only the protected customer's
}
}
[AllowAnonymous] void AddOrder(string title) { new Order { Title = title }; UnitOfWork.Commit(); }
[AllowAnonymous] void AddInvoice(string title) { new Invoice { Title = title }; UnitOfWork.Commit(); }
[AllowAnonymous] void DeleteOrder(string title) { Order.Single(o => o.Title == title).Delete(); UnitOfWork.Commit(); }
[AllowAnonymous] int DeleteOrders(string title) { return Order.Where(o => o.Title == title).Delete(); }
[AllowAnonymous] int DeleteInvoices(string title) { return Invoice.Where(i => i.Title == title).Delete(); }The two ways of deleting answer the same, and each answers in its own way. Deleting one row and committing is
refused with an error. Deleting a set — Order.Where(…).Delete() — deletes the rows the rules allow and leaves
the refused ones standing, and the count it returns is the rows it deleted:
[Test]
void an_empty_reference_is_refused_unless_the_rule_exempts_it() {
AddOrder("loose");
AddInvoice("loose");
Assert.Equal(0, DeleteOrders("loose")); // in bulk: the order is left standing …
Assert.Equal(1, Order.Count(o => o.Title == "loose"));
Assert.Equal(1, DeleteInvoices("loose")); // … the invoice, exempted, is deleted
Assert.Equal(0, Invoice.Count(i => i.Title == "loose"));
Assert.Denied(() => DeleteOrder("loose")); // one at a time: refused
}To exempt rows with no customer, say so: Customer != null && Customer.Name == "Protected", as Invoice does. Read
it as "has a customer, and that customer is protected" — with no customer the first half is plainly false, so the
whole condition is false and the deny does not apply.
The same holds for every rule that reaches through a reference, however far: Order.Customer.Region.Name is unknown
when any step along it is empty. An allow through an empty reference grants nothing, so allow delete where Customer.Name != "Protected" lets nobody delete an order with no customer — write Customer == null || Customer.Name != "Protected" if those should be deletable.
allow read <Field> — lifting the mask again#
A field rule on read decides whether a column arrives in a row the entity already returns. deny read <Field>;
— the field's blanket — takes it from everyone; allow read <Field> when … hands it back to the callers it names (a
more specific rule overwrites a more general one: [[#layers]]). So the two spell the same idea from opposite ends, and
the pair is useful when the honest default is "nobody":
entity Applicant {
[Required, MaxLength(200)] string Name;
/// Interview notes — written about a person, read by the panel and nobody else.
[MaxLength(2000)] string Notes;
security {
allow read when IsAuthenticated; // the applicant row is ordinary, visible to the company …
deny read Notes; // … the notes are hidden, from everyone, with no exception …
allow read Notes when IsStaff; // … except the panel
}
}And that is a claim about BEHAVIOUR, so it is executed rather than asserted in prose:
principal Panelist => User.Single(u => u.Name == "Pat");
principal Colleague => User.Single(u => u.Name == "Sam");
[TestFixture]
void SeedApplicants() {
var pat = new User { Name = "Pat" };
var sam = new User { Name = "Sam" };
var panel = new RoleGrant { Grantee = pat, Level = AppRole.Staff };
var robin = new Applicant { Name = "Robin", Notes = "strong on systems" };
}
[Test(SeedApplicants)]
[runas(Colleague)]
void A_colleague_gets_the_row_but_not_the_notes() {
var a = Applicant.Single(x => x.Name == "Robin");
Assert.Equal("Robin", a.Name); // the ROW comes back …
Assert.Null(a.Notes); // … and the masked column does not
}
[Test(SeedApplicants)]
[runas(Panelist)]
void The_panel_gets_the_notes() {
var a = Applicant.Single(x => x.Name == "Robin");
Assert.Equal("strong on systems", a.Notes); // the lift, for the callers it names
}Written the other way round — deny read Notes when !IsStaff — that is one line instead of two and it means the same
thing here. Prefer whichever states the intent you would defend in review: an unconditional deny plus an explicit
lift says "nobody, and here is the exception", which is the safer sentence when the exception list is likely to
grow.
⚠ An allow read <Field> is NOT a grant, and the compiler will not let you write it as one. It cannot make a
column appear in a row the entity's read rules withhold — whether the ROW comes back is decided by those rules and
by them alone, and a field rule takes no part in it. And it can only hand back what the field's BLANKET took: a
conditioned deny read <Field> when … sits in the allow's own layer, where a deny wins. So an allow read <Field>
with no blanket deny read <Field>; beside it has nothing to lift, and is refused at compile time rather than accepted
as an inert line:
entity Vendor {
[MaxLength(200)] string Email;
security {
allow read when IsStaff;
allow read Email when IsAnonymous; // ✗ `allow read Email` does nothing on 'Vendor'.
} // There is no `deny read Email;` here, so this line has
} // nothing to lift and no caller's access changes.To open a column to a wider audience you widen the row rule (allow read when …) and mask what should stay
narrow. That is the only direction reading works in.
Rules resolve in layers — general to specific#
Every rule in a block is one of four kinds, and they are applied in this order, from the most general to the most specific:
| layer | the rule | example |
|---|---|---|
| 1 | an entity rule with no condition | deny delete; |
| 2 | an entity rule with a condition | allow read when IsAuthenticated; · allow update where Owner == user; |
| 3 | a field rule with no condition — the field's blanket | deny update LastLoginAt; |
| 4 | a field rule with a condition | allow update LastLoginAt when IsAuthenticator; |
Think of each column as carrying a permission bit per verb, off until a rule turns it on. Each layer touches only
the callers — and, for a where, the rows — it applies to, and overwrites what the layers before it said for them.
Inside one layer a deny wins over an allow. Whatever is left after layer 4 is the answer.
Two things follow, and they are the two things worth remembering:
- A field
allowonly ADDS. It hands the column to the callers it names and takes it from nobody. - To narrow a field, state its blanket.
deny update F;takes the column from everyone an entity rule granted it to, and a conditionedallow update F when …hands it back to the callers you mean.
⚠ It is not "OR every allow, then subtract every deny": that could never say "nobody but the authenticator". And a more general rule never outranks a more specific one — an entity grant cannot reach a column its blanket closed.
What an allow can lift, and what it cannot#
- A field deny can be lifted by its own allow.
deny read PasswordHash;thenallow read PasswordHash when IsAuthenticator;hands the column back to the auth flow — that is what layer 4 is for. - A kit's entity deny cannot be lifted. When a kit (a capability or a package) ships an entity with a
denyin itssecurity { }—deny delete;,deny update;— that deny is absolute for every app that uses the kit. It is decided AFTER all four layers of the app's own rules, so noallowthe app writes in apartial entity— entity or field, with a condition or without — reaches past it. The app may narrow further (add its own denies, scope rows); it can never grant back what the kit withheld. The one exception is a kit that says so itself:[Authority(Extensible)]on the entity opts it out ([Authority]). - A
deny … except via Fis a floor too, andFis the way through. Noallowopens a direct write past it, however it is conditioned —allow update where Owner == userbesidedeny update Left except via Spendstill refuses the owner's ownp.Left = …. Route the person's own write through the named function: from a page,action Use(Pass p) { Spend(p); }rather than assigning the field, with who may call it inSpend's[Authorize(…)]. The refusal says exactly this, naming the function. - A figure over rows a person may not read is computed by a function the entity names:
allow read via VoteCount;. That function's OWN reads see every row of the entity (no row rule, no field mask) and it answers a value the compiler checks holds no row; every other read — a helper it calls included — is judged as usual (allow read via).
// in the kit
entity Receipt {
security {
allow read, create when IsAuthenticated;
deny update; // a receipt, once written, is never edited
}
}
// in the app
partial entity Receipt {
security {
allow update when IsStaff; // refused: the kit withheld update, and the app cannot grant it back
deny read when !IsStaff; // fine: the app narrows what the kit allowed
}
}The credential: nobody but the auth flow#
The spelling every app writes is one conditioned field rule:
entity Account {
[Required] User Owner;
[MaxLength(200)] string PasswordHash;
security {
allow read when IsStaff; // layer 2: staff read the row …
deny read PasswordHash when !IsAuthenticator; // layer 4: … and the hash is withheld from anyone who is not the auth flow
}
}The same intent written as blanket plus exception reads "nobody, and here is who" — and under the layers it means exactly the same thing:
entity VaultLogin {
[Required] User Owner;
[MaxLength(200)] string PasswordHash;
security {
allow read when IsStaff; // layer 2: staff read the row
deny read PasswordHash; // layer 3: the hash is taken from everyone …
allow read PasswordHash when IsAuthenticator; // layer 4: … and handed back to the auth flow
}
}A field allow ADDS — so a narrowing needs its blanket#
A login stamp is the case that teaches it. Here a person edits their own profile, and the auth flow stamps the time they last signed in:
entity Profile {
[Required] User Person;
[MaxLength(40)] string Handle;
DateTime? LastLoginAt;
security {
allow read when IsAuthenticated;
allow update where Person == user; // layer 2: a person edits their own row — every column of it
allow update LastLoginAt when IsAuthenticator; // layer 4: ADDS the stamp for the auth flow
}
}That second rule gives the auth flow the column and takes it from nobody, so a signed-in person can still rewrite their own last-login time — and anything that reads the stamp to find stale accounts is reading what the account wrote about itself. To say "only the auth flow", close the column first:
entity SignInRecord {
[Required] User Person;
[MaxLength(40)] string Handle;
DateTime? LastLoginAt;
security {
allow read when IsAuthenticated;
allow update where Person == user; // layer 2: a person edits their own row …
deny update LastLoginAt; // layer 3: … the stamp is taken from everyone …
allow update LastLoginAt when IsAuthenticator; // layer 4: … and handed to the auth flow alone
}
}who writes LastLoginAt | layer 2 | layer 3 | layer 4 | on Profile | on SignInRecord |
|---|---|---|---|---|---|
| the person, on their own row | on | off (only SignInRecord) | — | ✅ | ❌ |
| the auth flow | — | off (only SignInRecord) | on | ✅ | ✅ |
| anyone else | — | off (only SignInRecord) | — | ❌ | ❌ |
principal Lee => User.Single(u => u.Name == "Lee");
principal AuthFlow => User.Single(u => u.Name == "Auth");
[TestFixture]
void SeedSignIns() {
var lee = new User { Name = "Lee" };
var auth = new User { Name = "Auth" };
var grant = new RoleGrant { Grantee = auth, Level = AppRole.Authenticator };
var profile = new Profile { Person = lee, Handle = "lee" };
var record = new SignInRecord { Person = lee, Handle = "lee" };
}
[Test(SeedSignIns)]
[runas(Lee)]
void Without_the_blanket_a_person_stamps_their_own() {
var p = Profile.Single(x => x.Handle == "lee");
p.LastLoginAt = DateTime.UtcNow; // allowed by layer 2
Assert.NotNull(p.LastLoginAt);
}
[Test(SeedSignIns)]
[runas(Lee)]
void With_the_blanket_a_person_does_not() {
var r = SignInRecord.Single(x => x.Handle == "lee");
Assert.Denied(() => r.LastLoginAt = DateTime.UtcNow); // layer 3 overwrote layer 2
}
[Test(SeedSignIns)]
[runas(AuthFlow)]
void With_the_blanket_the_auth_flow_still_does() {
var r = SignInRecord.Single(x => x.Handle == "lee");
r.LastLoginAt = DateTime.UtcNow; // layer 4 handed it back
Assert.NotNull(r.LastLoginAt);
}When a write is refused, the refusal names the rule that decided, the layer it sits in, and the grant it overwrote:
"Update of 'SignInRecord.LastLoginAt' denied — deny update LastLoginAt, the field's blanket rule, with no condition
(layer 3 of 4). It overwrites allow update where Person == user, an entity rule with a condition (layer 2 of 4)…"
One party's column, and everybody else keeps theirs#
Because a field allow only adds, granting one party a column never takes it from another. A zone's agent reports its zone's status; a platform admin manages every zone — including that column:
enum OutpostStatus { Up, Down }
entity Outpost {
User? Agent;
[MaxLength(40)] string Name;
OutpostStatus Status = OutpostStatus.Up;
[MaxLength(200)] string Notes = "";
security {
allow read, create, update, delete when IsAdmin; // layer 2: the admin manages every zone
allow read where Agent == user;
allow update Status where Agent == user; // layer 4: ADDS Status for the zone's own agent
}
}principal Ops => User.Single(u => u.Name == "Ops");
principal ZoneAgent => User.Single(u => u.Name == "Agent");
[TestFixture]
void SeedZones() {
var ops = new User { Name = "Ops" };
var agent = new User { Name = "Agent" };
var admin = new RoleGrant { Grantee = ops, Level = AppRole.Admin };
var north = new Outpost { Name = "north", Agent = agent };
}
[Test(SeedZones)]
[runas(ZoneAgent)]
void The_agent_reports_its_zone_status_and_nothing_else() {
var z = Outpost.Single(x => x.Name == "north");
z.Status = OutpostStatus.Down;
Assert.Equal(OutpostStatus.Down, z.Status);
Assert.Denied(() => z.Notes = "not the agent's column");
}
[Test(SeedZones)]
[runas(Ops)]
void The_admin_keeps_every_column_including_the_agents() {
var z = Outpost.Single(x => x.Name == "north");
z.Status = OutpostStatus.Down;
z.Notes = "maintenance";
Assert.Equal(OutpostStatus.Down, z.Status);
}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.
A rule whose when does not hold for this caller is never asked — its where, if it has one, is not evaluated
for a single row. So give each kind of caller its own rule, and say in its when who it is for:
allow read when IsStaff; beside allow read when IsClient where IsPartyTo(Id); costs the staff nothing for the
client rule. A role test written inside a where instead is a compile error that prints the rule split this way —
see when is who, where is which rows.
when and where have the same freshness#
The two ask different questions — when is WHO the rule is for, where is WHICH ROWS — and each has one place: a
part of a where that never reads the row is a compile error, because it belongs in a when
(when is who, where is which rows). What they do not differ in is when they ask. Whichever you write, the answer is
computed against the facts as they stand when the read or the write happens — so a grant your own code committed a
moment ago is already in force, and a grant it revoked is already gone. The same is true of a
[[#field-mask|field mask]], the third place a rule can live.
So allow read when IsStaff knows about a role your code granted a line earlier, exactly as allow read where Owner == user knows about a row your code just made the caller's:
// Onboarding, founding, an invite accepted: one request writes the grant and then does the work the grant
// authorizes. A `when` sees it, so does a `where`, and so does a field mask — nothing is snapshotted when the request began.
void JoinTheStaffAndGetToWork() {
var g = new RoleGrant { Grantee = Session.CurrentUser, Level = AppRole.Staff };
UnitOfWork.Commit(); // ← the grant is a fact now …
var mine = Report.Count(); // … and `allow read when IsStaff`, two blocks up, already knows it
}It runs the other way too, and that direction is the one worth stating: a role removed mid-request stops working for the rest of that request. An admin screen that revokes a membership and then re-renders beneath it is showing the access the person has now, not the access they had when the page began.
⚑ This covers ORDINARY ROWS, not just grants — which matters most for a gate that reaches through a reference.
allow update where Report.Employee.User == user && Report.Status == ReportStatus.Draft is decided against the
report as it is now, every time it is asked. So the flow an expense app is built out of behaves the way you would
read it: add a line while the report is a draft, submit the report, and the next edit of that line is refused — in
the same request that submitted it, with no re-login, no new page and no second context. Recall the report and the
same edit is allowed again. Nothing is snapshotted when the request begins, and nothing is remembered from the
first time the rule looked at the parent.
⚠ The bound is the READ, not the request. A change committed somewhere else — an admin in another session, a background job, your own API — is in force from the next read that asks. It is not pushed to anything already on screen: a page does not lose a button the instant a role is revoked; it loses it the next time it asks the server anything. So this is "authorization follows the facts", not "live revocation across a system".
by — a link instead of a person#
when asks who is asking and where which rows; by G admits whoever holds a valid link of the
grant G — to the ONE row the link was made for. It is how a signed-out reader manages their mail
from the link in it, or a customer pays an order from a mail, without an account:
grant Preferences for Subscriber { Expires = TimeSpan.FromDays(30); }
entity Subscriber {
[MaxLength(200)] string Email;
bool Weekly;
security {
allow read by Preferences; // the row the link was made for, and no other
allow update Weekly by Preferences; // writes are opt-in, per column
}
}A by rule may carry a where too, and must when the entity is not the grant's own:
allow read by Preferences where Subscriber == Preferences.Row;. The holder stays anonymous for everything else, and a
field mask still hides what it hides. See grants (a link that opens one row) for minting, expiry, revocation and pages.
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:
// A `principal` names a seeded row so a test can BE that person. It resolves unsecured, which is what makes
// it work: a `[Test]` body outside a `runas` is an anonymous caller, so looking a user up there reads nothing.
principal Bob => User.Single(u => u.Name == "Bob");
[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)]
[runas(Bob)]
void Bob_cannot_see_Alices_doc() {
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.
message — what a refused person is told#
A block may open with one line of your own copy, and it is the sentence anyone refused a create, update or
delete on this entity reads:
security {
message "Only an organisation admin can change an approval policy.";
allow create, update, delete when IsOrgAdmin;
}It covers the refusal however it was reached — the row matched none of the allow rules, or none of them is active
for this caller — which is the case no per-rule message can speak for, because the refusal was made by the ABSENCE
of a matching rule. Usually you do not write it per entity at all: app.DenialMessage = "…"; says it once for the
whole app and each entity overrides only if it needs different words. Reading is not covered and cannot be — a read
you may not do is filtered, not refused. The whole story is in what a refused user is told.
See also#
- when is who, where is which rows —
whenis who,whereis which rows, and why a rule for somebody else is never asked - what a refused user is told — the sentence a refused person reads, and how to write it yourself
- secure by default (deny-all) — what an entity permits before you write any block
- principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads — the
usera rule compares against - runas — acting as a principal, so a rule can be tested
- Assert —
Assert.Denied - Security.IsMasked — was this value withheld from me? — what a masked field reads as, and
Security.IsMaskedto tell it from a real value - grants (a link that opens one row) —
allow … by G: a signed link to one row, for someone who is not signed in - allow read via —
allow read via F: a function that computes a figure over every row