Osy#betaRun it on Osyrin: osyrin.com

Reference / Security

security { }

security { allow read, create, update, delete when <policy>; allow read where <row predicate>; deny read <Field> when <policy>; // a field mask, principal-only deny read <Field> where <row predicate>; // a field mask, row-scoped allow read <Field> when <policy>; // lifts that mask again }

The rules that decide who may read and write an entity's rows. A where clause filters by the row (the owner sees their own); a when clause gates by the principal (staff see everything). Rules are compiled into every query.

stable24 examples compiled by CIsecurityentity

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:

  • where filters by the row — the owner sees their own rows.
  • when gates 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 test

Description#

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 writewhy
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 writewhat the subquery sees of the row under judgmentoutcome
an Assistant makes themselves a Leadnothing — the row as it is committed, still Assistant❌ refused
a stranger writes themselves onto an empty roster as Leadnothing — the row does not exist yet❌ refused
a Lead edits their own seat rowthe row as it is committed, Lead✅ allowed
a Lead steps down to Assistantthe row as it is committed, Lead✅ allowed
a Lead promotes somebody elsethe 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:

rulecondition unknown
allownot granted
denydenied — 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 included

This 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 readable

The 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:

layerthe ruleexample
1an entity rule with no conditiondeny delete;
2an entity rule with a conditionallow read when IsAuthenticated; · allow update where Owner == user;
3a field rule with no condition — the field's blanketdeny update LastLoginAt;
4a field rule with a conditionallow 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 allow only 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 conditioned allow 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; then allow 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 deny in its security { } — 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 no allow the app writes in a partial 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 F is a floor too, and F is the way through. No allow opens a direct write past it, however it is conditioned — allow update where Owner == user beside deny update Left except via Spend still refuses the owner's own p.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 in Spend'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 LastLoginAtlayer 2layer 3layer 4on Profileon SignInRecord
the person, on their own rowonoff (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#

Related

secure by default (deny-all)

Deny-all is the posture, and it is the only one: an entity that declares no `security { }` block is denied to every…

principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads

Two built-in `when` predicates say who a request is: `IsAuthenticated` is a signed-in user, `IsAnonymous` is an…

when is who, where is which rows

A rule's `when` says WHO it is for and its `where` says WHICH ROWS. A rule whose `when` does not hold is never asked…

runas

Runs a block as a given principal, so security rules apply exactly as they would for that user. It is how you test that…

Assert

The assertions a test makes. Beyond the usual equality and null checks there are comparisons (Greater, Less, InRange)…

entity

Declares a persisted type — a table of rows the app stores, queries and secures. Every entity gets an Id from the…