Osy#betaRun it on Osyrin: osyrin.com

Reference / Security

public pages (what a signed-out visitor can see and do)

security { allow read when IsAnonymous || IsAuthenticated; } // on the ENTITY the public page reads

A public page, its public data, its public actions and its live updates are four separate declarations. `[AllowAnonymous]` on a component says a signed-out visitor may see the PAGE; whether they get its ROWS is decided by the entity's own `security { }` block; whether they may RUN an action is a grant on the function it calls; and whether a `topic` reaches them at all is `[AllowAnonymous]` on the topic. Grant only the page and it renders perfectly with nothing in it, its buttons do nothing and it never updates — the quietest failures in a public app.

preview5 examples compiled by CIsecurityauthorizationanonymousui

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. And if the page shows LIVE data — a topic — there is a fourth, on the topic itself. See Live updates are a FOURTH grant.

[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;
  }
}

[Route("/")]
[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 (or the public page that renders the component), the entity — the app's own or a used package's — and the line to add. You do not have to go looking for it: osy validate prints it in full, with its fix, even when the source is otherwise valid (as ANON_PAGE_READ_REFUSED). It stays a warning, not an error — a page that is public on purpose over data that is not is a choice you are allowed to make.

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:

GrantAnonymous 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
allow read where Status != ProductStatus.Draft;refused — a where says which rows, not who; it admits no signed-out caller
allow read when IsAnonymous \|\| IsAuthenticated where Status != ProductStatus.Draft;reads the rows the filter passes
no security { } block at allrefused under deny-all

⚠ A row filter is not an audience. allow read where Status != ProductStatus.Draft; reads like "anyone may see what is not a draft", and it means "any SIGNED-IN caller may see what is not a draft". A signed-out visitor gets none of it, on every path alike: the page's query is refused, an [AllowAnonymous] server function reading Product.Where(…) gets no rows, and so does a [Test] body with no runas — one rule, one answer, whoever asks and from wherever. Say both halves in ONE rule — who (when) and which rows (where):

[Principal] entity User { [MaxLength(200)] string Email; }

enum ProductStatus { Draft, Listed }

entity Product {
  [MaxLength(80)] string Name;
  ProductStatus Status;
  security {
    allow read when IsAnonymous || IsAuthenticated where Status != ProductStatus.Draft;   // who, then which rows
    allow create, update, delete when IsAuthenticated;
  }
}

[Route("/")]
[AllowAnonymous]
component Catalog() {
  live var products = Product.Where(p => p.Status == ProductStatus.Listed).ToList();
  render { foreach (var p in products) { Text(p.Name); } }
}

Do not add a separate allow read when IsAnonymous; beside the filtered rule instead: grants add up, so that is a second, unfiltered grant, and a visitor would see every draft. security-anon-page-reads-ungranted-entity prints the one-rule form for you, with your own filter in it — including when the entity is a package's, where the block lives in the package (or in a partial entity of yours that restates it).

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, a server function, the boot bundle, the server-side render — follows it.

Two kinds of caller are not visitors, and a where alone still serves them: the holder of a grant link (allow read by Preferences; — the link is the audience, see grants (a link that opens one row)) and your own [AuthMethod] while it signs someone in (it runs as the app's sign-in role, see auth bootstrap (login, before anyone is signed in)).

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 / IsTheApp) 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:

saysif missing
[AllowAnonymous] on the componenta stranger may SEE the pagethey are redirected to login
an anonymous read/write grant on the entitya stranger's rows may be read/writtenthe read is empty; the write is refused at commit
[AllowAnonymous] on the functiona stranger may CALL itthe click posts and the server refuses it — a dead button
[AllowAnonymous] on the topica stranger may SUBSCRIBE and PUBLISHthe page renders once and never updates
[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();
}

[Route("/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.

Live updates are a FOURTH grant#

A public page that shows something changing — a queue display, a status board, a live scoreboard — reads its updates from a topic, and a topic refuses a caller with no principal unless it declares otherwise.

[Principal] entity User {
  [MaxLength(200)] string Email;
  bool IsStaff;
  security { allow read when IsAuthenticated; }
}

class Status { public string State; }

[AllowAnonymous]                 // the TOPIC — a stranger may subscribe to it
topic ServiceStatus() {
  Candidates = _ => true;
  Publishers = u => u.IsStaff;   // …and only staff may say what the state is
  Carries    = Status;
}

[Route("/status")]
[Render(CSR)]
[AllowAnonymous]                 // the PAGE
component StatusBoard() {
  live var updates = ServiceStatus.Listen();
  render { Text(updates.Count() + " updates"); }
}

This is the quietest of the four. The page loads, the layout is right, nothing is refused on screen — and the number never moves, for the one reader who is not signed in. There is no error to see, because a subscription that was refused is a room that is simply silent.

⚠ Candidates = _ => true is not the declaration. It means every signed-in user, and reading "public" out of a rule that happens not to mention its principal is exactly what the platform refuses to do: a rule like _ => Room.Any(r => r == room) restricts on the address and nothing about who, and it must not become world-readable because of what it does not say. The attribute is the statement; the rule is still the gate.

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;
  }
}

[Route("/")]
[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#

Related

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…

security { }

The rules that decide who may read and write an entity's rows. A where clause filters by the row (the owner sees their…

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…

topic

A topic is a named, addressable realtime destination: a declared place to put a message so that every open page…

routes and pages

How a component becomes a page: it declares a route with `[Route("/catalog/{slug}")]`, and navigating to a matching…

Visitor

`Visitor.Id` names the browser someone is using. The platform issues it on their first visit, proves it on every…