Summary#
An entity with no security { } block is denied to every user request — access is granted only where you
write it. This is the secure-by-default posture: you cannot forget to lock down a table, because an unmentioned
table is already locked. It is not a setting you turn on and there is no way to turn it off, so there is nothing to
write and nothing to remember: every app is deny-all, every entity starts closed, and the only question left is
which access you grant back.
// Deny-all is the default — no security block → denied to every user request.
entity Ledger { string Entry; }
// Grant access back explicitly — anyone may read the catalog.
entity Product {
string Name;
security { allow read when IsAuthenticated || IsAnonymous; }
}Signature#
entity Ledger { string Entry; } // no block → denied to every user request
security { allow read when IsAuthenticated; } // a block → a LIST OF GRANTS, and the only way inThere is nothing app-level to write. The posture is a property of the language, not a configuration value, so it
cannot be relaxed for an app, a file or an entity — the only lever is which grants you write in a security { }
block. The one thing that genuinely has to run before anyone is signed in — login, signup, a password reset — is
declared as an auth-bootstrap and runs under an identity you defined, on a leash of the
grants you gave that role.
Description#
The posture changes only what a user request may touch. It does not change how the platform runs itself:
- User requests (a logged-in or anonymous caller reading/writing app data) are subject to the posture. An entity
with no
security { }block is denied; an entity with a block is governed by exactly what that block grants. - System, designer, and bootstrap work (schema evolution, seeding, the metadata designer) is never affected — it does not run through a user's security context, so the posture never denies the platform's own operations.
- Platform (
osy.*) entities are exempt — their access is gated by other layers, so the posture never denies a built-in read.
Grant access back with ordinary security { } rules — allow read where Owner == user;, a role check,
or one of the built-in principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads (allow read when IsAuthenticated, when IsAnonymous, or
when IsAuthenticated || IsAnonymous for a genuinely public read).
Every
allowmust say WHO — a rule with nowhenand nowhereis a compile error. Deny-all decides what happens when you say nothing; this decides what happens when you open a block and then forget to qualify a line inside it. An unqualifiedallowgrants that verb to every caller, anonymous included, and the runtime consults nothing — so it is not a weaker rule, it is the absence of one wearing the syntax of one, and it is worse than no block at all because deny-all would have caught the omission. It applies to all four verbs and to every app, whether or not it declares a[Principal].The remedy is never to remove the grant — it is to name who holds it:
you mean you write the owner of the row allow update where Owner == user;anyone signed in allow read when IsAuthenticated;signed-out visitors (signup, a public form) allow create when IsAnonymous;genuinely everyone allow read when IsAuthenticated \|\| IsAnonymous;That last row is supported and is the right answer for a store front — "everyone" is a legitimate decision, and spelling it out is the whole point. What is refused is leaving it unsaid.
when trueis a compile error for the same reason: it says yes without saying who, so a reader cannot tell a public surface from an unfinished one. The diagnostic names the form that does mean everyone.An entity-level
denymust say WHEN as well. "Denies more, never less" is the reasoning of an allow-by-default system; under deny-all there is nothing broader to narrow. A baredeny update;on an entity is dead text either way: the verb was already denied, and when a siblingallownames the same verb that grant is more specific — rules resolve from general to specific ([[security-entity-security#layers]]) — so it overwrites the deny for every caller it names. A line that reads as a control and decides nothing is worse than deleting it, which is the same argument that refusesdefault deny;.A FIELD-scoped
denyneeds no condition — except on a CREDENTIAL.deny read PasswordHash when !IsAuthenticator;subtracts one column from whatever the entity grants, because a field's blanket is more specific than every entity rule and only a conditioned rule for that same column hands it back ([[security-entity-security#layers]]) — it is the only way to say "nobody, ever", and nowhenstates it as cleanly.The platform's own CORE types cannot declare security, and saying so is a compile error. A
security { }block on an entity in core baseline source is refused, naming the entity and the form that works:partial entity Osysharp.<Name> { security { … } }. It is refused rather than honoured because a core type shipping its own default rule would hand every app that imports it access the app never declared — a security boundary moved by a line nobody in that app wrote. And it is refused rather than ignored because ignoring it is worse than either: the block read as though it granted something while the entity stayed denied by default, and nothing said so. (A capability or a package may still declare its ownsecurity { }— that one is lowered in the consuming app's compile, whereuserhas a[Principal]to bind to.)A
partial entityis NOT exempt. A capability's grant for the table (allow … delete where UserId == user) is a conditioned rule, more specific than a baredeny delete;, so it overwrites the deny and the partial's line would take nothing away. To narrow a grant declared elsewhere, deny it in the grant's own layer, where a deny wins:deny delete when IsAuthenticated || IsAnonymous;.There is no way out, and that is the point. Deny-all holds for every app, always — so "we will turn security on before we ship" is not a state this platform can be in, and an app that appears to work is an app whose grants you actually wrote. The only sanctioned path past a locked table is the one that must exist: login and signup have to reach a user row before a principal exists, so you declare them in
app.AuthBootstrapand the engine runs them as an ephemeral principal bearing a role you chose — which may touch exactly what yoursecurity { }blocks grant that role, and nothing else. See auth bootstrap (login, before anyone is signed in), and page authorization (policies) for page-level authorization, the request-time complement to entity-level deny-all.
Examples#
A public catalog an anonymous visitor may browse, alongside a private table only an owner sees — under the posture,
the untouched Ledger is denied to all:
[Principal] entity User { string Name; }
entity Product { // anonymous visitors may browse the catalog
string Name;
security { allow read when IsAnonymous || IsAuthenticated; }
}
entity Order { // a buyer sees only their own orders
User Buyer;
string Item;
security { allow read where Buyer == user; }
}
entity Ledger { string Entry; } // no security block → denied to everyone (the default)See also#
- security { } — the
security { }block that grants access back - rows that are part of another row — the exception: a row that is PART OF another takes that row's rule, with no block of its own
- principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads —
IsAuthenticated/IsAnonymous, and why everyallowmust say who - page authorization (policies) — page-level authorization, the request-time complement to deny-all