Summary#
Every anonymous caller is the same principal to a rule: user is null for all of them. So a rule that admits a guest's
row by Owner == null admits EVERY guest's row to EVERY guest — the date of birth, the company, the pickup address one
visitor typed at a checkout reads to the next. Visitor.Id tells guests apart: it is the browser the platform
proved for this request, and a rule compares a row to it exactly as Owner == user compares a row to the principal.
entity Basket {
[MaxLength(64)] string? VisitId;
[MaxLength(200)] string? DeliverTo;
security {
allow read, create, update, delete when IsAnonymous || IsAuthenticated where VisitId == Visitor.Id;
}
}Signature#
where … VisitId == Visitor.Id … // inside a security rule: the visitor this request's cookie provesA string?. null where there is no visitor (a workflow timer, a background job) — and a comparison with it then
matches nothing: an allow grants nothing and a deny applies.
Description#
Why a rule, and not only the function#
Write the functions so they never take "which visit" — they read Visitor.Id (Visitor). The rule says the
same thing again, and it is the half that cannot be walked around: a page's own query, a list, a function that forgot,
another package's function over your entity — each reads and writes through the rule. Declared once; after that the
rows simply are the visit's.
It is proved, not told#
The value a rule compares against is the id sealed in the visitor's HttpOnly cookie under the app's own key — never a value the request states. A caller who KNOWS another guest's visit id (it was on a screen, in a log, in a URL) still reads none of their rows and cannot create one under their visit: it is not the visitor its cookie proves, and a forged cookie is answered with a fresh visitor rather than the claimed one.
Owned rows and staff#
A guest row usually becomes an owned one at sign-in, and staff usually see all of them. Say both in the same rule — the
precedence is C#'s, so && binds before ||:
[Principal] entity Shopper {
[Unique, MaxLength(200)] string Email;
security { allow read when IsAuthenticated; }
}
entity Order {
[MaxLength(64)] string? VisitId;
Shopper? Owner;
[MaxLength(200)] string? Note;
security {
allow read, create, update when IsAnonymous || IsAuthenticated
where Owner == null && VisitId == Visitor.Id || Owner == user;
}
}A child row reaches its parent's visit the ordinary way — where Basket.Owner == null && Basket.VisitId == Visitor.Id.
How does a row get its visit, wherever it is made?#
Give the member the visitor as its default — = Visitor.Id — and every new stamps it, including a row made in
a helper or an overridden method the caller never sees:
entity Note {
[MaxLength(64)] string? VisitId = Visitor.Id;
[MaxLength(500)] string Text = "";
security {
allow read, create when IsAnonymous || IsAuthenticated where VisitId == Visitor.Id;
}
}
Note Jot(string text) { return new Note { Text = text }; }A default rather than an assignment after the fact, because a create rule is judged where the row is MADE — at the return of the function that made it. A helper that hands a row back for its caller to stamp hands back a row the rule has already refused.
Proving it in a test#
A test is one browser. runas (new Visitor { Id = "…" }) makes a block another browser — the principal stays as it
was — which is the whole of a "second guest cannot see the first guest's rows" test:
[Test]
void a_guest_sees_only_its_own_basket() {
runas (new Visitor { Id = "guest-a" }) { StartBasket("Home"); }
runas (new Visitor { Id = "guest-b" }) { Assert.Equal(0, Basket.Count()); }
runas (new Visitor { Id = "guest-a" }) { Assert.Equal(1, Basket.Count()); } // the control
}Examples#
The whole shape: a guest starts a basket, sees their own, and nobody else can read it or plant one under their visit.
[AllowAnonymous]
void StartBasket(string deliverTo) {
new Basket { VisitId = Visitor.Id, DeliverTo = deliverTo };
UnitOfWork.Commit();
}
[AllowAnonymous]
int MyBaskets() { return Basket.Count(); }See also#
- Visitor —
Visitor.Idin a page and a function: issued by the platform, the same value everywhere - security { } —
allow/deny,whenandwhere, and how rules combine - grants (a link that opens one row) — a signed link that opens ONE row for someone who is not signed in
- runas — being somebody else in a test