Summary#
A shop's catalogue, a published article, a price list: some data is meant for people who have not signed in. Making that work takes two declarations, on two different things, because the page and its data are gated separately:
[AllowAnonymous]on the component — a signed-out visitor may load the PAGE. Without it, routing sends them to your login route and they never see it.- an anonymous read grant on the ENTITY — a signed-out visitor may read its ROWS. Without it, the page renders and every query on it comes back refused.
If the page also lets a visitor do something, there is a third: [AllowAnonymous] on the function the action
calls. See Writing is a THIRD grant, below.
[Principal] entity User { [MaxLength(200)] string Email; } // who "signed in" means, for the grants below
entity Product {
[MaxLength(80)] string Name;
decimal Price;
security {
allow read when IsAnonymous || IsAuthenticated; // ← the DATA half: anyone may read the catalogue
allow create, update, delete when IsAuthenticated;
}
}
[Page("/")]
[AllowAnonymous] // ← the PAGE half: anyone may load this route
component Catalog() {
live var products = Product.ToList();
render { foreach (var p in products) { Text(p.Name); } }
}The two do not inherit from each other, in either direction. That is deliberate — a public page composed of private data is a real design, and so is a private page over public data — but it means granting one and forgetting the other is a thing you can do, and the failure is silent.
Signature#
security {
allow read when IsAnonymous; // signed-out visitors only
allow read when IsAnonymous || IsAuthenticated; // everyone, said out loud
}Description#
The half that is easy to forget#
Under deny-all, an entity you have not granted is denied to everyone — including the
anonymous visitor. So a page marked [AllowAnonymous] over an ungranted entity renders: the chrome, the headings,
the empty list, the 0 items count. No error, no redirect, no console message. It simply shows nothing, and it looks
exactly like a catalogue with no products in it.
This is worth stating plainly because it survives the checks you would expect to catch it. It compiles. Its tests pass — a test runs under a principal you choose, and the ordinary anonymous read those tests exercise is honoured correctly. The page renders in every screenshot taken while signed in. Only opening the app signed out shows the gap.
The compiler does look for it. An [AllowAnonymous] page reading an entity with no anonymous grant raises the
security-anon-page-reads-ungranted-entity maturity finding, which names the page, the entity, and the line to add.
What "reaches an anonymous caller" means#
An entity is anonymously readable when its security { } block grants a read to a caller who is not signed in:
| Grant | Anonymous visitor |
|---|---|
allow read when IsAnonymous; | reads it |
allow read when IsAuthenticated \|\| IsAnonymous; | reads it |
allow read when IsAuthenticated; | refused — an anonymous caller is not authenticated |
allow read where Owner == user; | refused — there is no user to match |
no security { } block at all | refused under deny-all |
There is no separate switch and no attribute to add: the grant IS the declaration. Say it once on the entity, and every surface — the page's query, the boot bundle, the server-side render — follows it.
Only the rows you granted#
Granting an anonymous read opens exactly what the grant says and nothing beside it. The row filter still applies the
predicate per row, field-level deny read masks still apply, and an entity you did not grant stays invisible — not
merely unreadable, but absent from the model an anonymous session is served at all. So a public catalogue does not
drag the customer table into public view because they happen to be related.
Prefer the narrowest grant that is true. IsAnonymous alone is right when a page is public and the signed-in view is
a different one; the || form is right when the answer really is "everyone" — see
principal predicates (IsAuthenticated / IsAnonymous) and open reads.
Writing is a THIRD grant#
Reading needs two declarations. A visitor who does something — fills a basket, casts a vote, submits a form — needs three, because an action reaches a server function and who may call a function is its own grant:
| says | if missing | |
|---|---|---|
[AllowAnonymous] on the component | a stranger may SEE the page | they are redirected to login |
| an anonymous read/write grant on the entity | a stranger's rows may be read/written | the read is empty; the write is refused at commit |
[AllowAnonymous] on the function | a stranger may CALL it | the click posts and the server refuses it — a dead button |
[Principal] entity User { [MaxLength(200)] string Email; }
entity Vote {
[MaxLength(80)] string Choice;
security { allow read, create, update when IsAnonymous || IsAuthenticated; } // the ROWS
}
[AllowAnonymous] // the FUNCTION — a stranger may run it
void CastVote() {
var v = new Vote { Choice = "yes" };
UnitOfWork.Commit();
}
[Page("/vote")]
[AllowAnonymous] // the PAGE
component VotePage() {
action Cast() { CastVote(); }
render { Button("Vote", onPress: Cast); }
}The third one is the quietest of the three: the page renders, the button is there, it looks enabled, and pressing it
produces nothing at all — the refusal never reaches the screen. The compiler flags it as
security-anon-page-calls-gated-function, naming the page, the action and the function.
An [AuthMethod] function needs no such mark: it is an anonymous entry point by construction. And an action that
signs someone in and then calls a gated function is fine — by that line the caller is no longer a stranger.
Look at it signed out#
The habit worth forming: open the app in a private window, on the real page, and check the data is there. A public app's most common defect is not a refusal — a refusal is loud. It is a page that renders beautifully and is empty, and the only reader who ever sees it is the one who is not signed in.
Examples#
A storefront: a public catalogue, a basket keyed to the browser, and a customer record that stays private.
[Principal] entity Customer {
[MaxLength(200)] string Email;
security {
allow read where Id == user.Id; // you read your own profile, and nobody reads anyone else's
allow update where Id == user.Id;
}
}
entity Product {
[MaxLength(80)] string Name;
decimal Price;
security {
allow read when IsAnonymous || IsAuthenticated; // a catalogue is public — that is what a shop is
allow create, update, delete when IsAuthenticated;
}
}
[Page("/")]
[AllowAnonymous]
component Catalog() {
live var products = Product.ToList();
render {
Stack(gap: 2) {
Text(products.Count() + " items");
foreach (var p in products) { Text(p.Name + " " + p.Price.ToString("C")); }
}
}
}Signed out, this page shows the products. Customer is granted to nobody anonymous, so it is not readable and not
even described to an anonymous session — the public page cannot leak it by accident.
See also#
- principal predicates (IsAuthenticated / IsAnonymous) and open reads —
IsAnonymous/IsAuthenticated, and why an open read must say so - security { } — the
security { }block the grant lives in - secure by default (deny-all) — the deny-all posture that makes an unmentioned grant a denial
- routes and pages —
[AllowAnonymous], and protected-by-default routing - Visitor —
Visitor.Id, for work a person begins before they are anybody