Summary#
Security in Osy# rests on three ideas. Hold these and the rest is detail:
- Silence denies. An entity you say nothing about is readable by nobody. You never turn access off — you only ever grant it.
- A grant is part of the query. A row you may not see is not fetched and then hidden; it is never selected. So
Order.Count()honestly means "how many orders exist for me", and two users can correctly get different numbers from the same function. - A rule you have not tested is a rule you only believe you wrote. Security is the one area where compiling, passing tests and "working" tell you nothing about correctness. The only proof is to become the other user and be refused.
The rest of this page is the model those three ideas describe.
Description#
Which page answers which question?#
| Question | Answered by | Page |
|---|---|---|
| What may this entity's rows be used for, and by whom? | the security { } block | security { } |
| Is this row yours? | a where clause — a predicate over the row | security { } |
| Is it yours through something else? | a where clause that navigates relations, any depth | navigation in security predicates (any depth, either side) |
| Are you the kind of person who may do this at all? | a when clause — a predicate over the principal | principal predicates (IsAuthenticated / IsAnonymous) and open reads |
Who is user? | the [Principal] entity | below |
| May this person open this page? | [Authorize(policy)] on the component | page authorization (policies) |
| How does anyone log in, if nothing is readable yet? | app.AuthBootstrap | auth bootstrap (login, before anyone is signed in) |
| Does any of it actually work? | runas + Assert.Denied | runas |
1. Silence denies#
The posture is deny-all (secure by default (deny-all)). An entity with no security { } block is denied to every
user request — not "readable by signed-in users", not "readable by its owner". Denied.
To lock an entity completely, say nothing:
entity AuditRecord {
[MaxLength(200)] string Message;
// No security block. No user can read it, and no user can create one.
}There is therefore no default deny to write, and a security { } block is a list of grants. This is why the
failure mode of forgetting a rule is "nobody can do it" — reported within the minute — rather than "everybody
can", which nobody reports at all. A door that fails shut is a door you can trust.
2. Who user is#
One entity in your app is the principal — the thing a logged-in person is. Mark it [Principal], and user
inside a security rule means a row of it:
[Principal] entity User {
[Required] string Name;
}
entity Doc {
User Owner;
[MaxLength(200)] string Title;
security { allow read where Owner == user; } // `user` IS the acting principal's row
}The principal is yours — your entity, your fields, your extra relationships. The platform does not impose a user model on you; it only needs to know which of your entities is the one people log in as.
Roles are yours too, and they are just data. A role is granted by an ordinary entity: anything that references the
[Principal] and carries a member of your [Role] enum is a grant table, recognised by that shape. So "who may be
an admin" is not a platform setting — it is the question "who may create a row in that table", answered by a
security { } block like any other. One rule is worth carrying from the start: an app has exactly ONE [Role]
enum — the vocabulary the login ticket carries. Every other tier (org membership, project membership, a
team's Owner/Member) is ordinary data, and you query it. Both kinds work in a rule; they are simply answered
differently, and role grants (and the first admin) lays them out side by side — along with why a second [Role] enum is a
compile error rather than a convenience.
3. Two axes: the row, and the person#
This is the distinction to internalise, because almost every real rule is one or the other:
wherefilters by the row. The owner sees their own documents. It narrows which rows you get.whengates by the principal. Staff see every document. It decides whether you may at all.
Name the principal test with a policy so it is written once and reused everywhere:
[Role] enum AppRole { Staff, Admin } // the app's ONE role vocabulary
entity RoleGrant { // a [Principal] ref + a [Role] member ⇒ this table grants roles
User Grantee;
[Required] AppRole Level;
}
policy IsStaff => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Staff);
entity Report {
[MaxLength(200)] string Title;
security {
allow read when IsStaff; // by PERSON: staff, and nobody else
}
}
entity Memo {
User Owner;
[MaxLength(200)] string Note;
security {
allow read when IsStaff; // staff read every memo …
allow read where Owner == user; // … and everyone reads their own
allow update where Owner == user; // but only the author may change one
}
}Grants add up: a staff member who also owns a memo is covered by either rule. And note that read and write are separate grants — "the team can see it, only the author can change it" is the common case, not an exotic one.
4. The grant is inside the query#
A rule is compiled into the SQL alongside your own predicate. It is not a filter applied to rows you already fetched, and it is not a check you are expected to remember to call.
Three consequences worth stating plainly:
Count()is honest. Underallow read where Owner == user,Doc.Count()returns your documents' count. Two users get different numbers and both are right.- You never re-check after a query. There is no "and now verify they were allowed to see these". If a row came back, they were allowed.
- You cannot leak by forgetting. There is no code path — a function, an API call, an MCP tool, a UI query — that goes around it, because there is no "it" to go around. The rule is the query.
5. The bootstrap paradox#
If nothing is readable until you are authenticated, how does anyone log in — an act that must read the user row of someone who, by definition, is not yet authenticated?
app.AuthBootstrap resolves it: it names a role the engine mints a user-less ephemeral principal with, and the
functions (login, signup, password reset) it runs under that principal. Crucially, what that principal may touch is
your ordinary security { } grants — no special access is minted, so the bootstrap path cannot become a hole you
forgot about. See auth bootstrap (login, before anyone is signed in).
6. The UI has its own rail#
Data security and page security are different questions, and both are deny-first:
- A routed component requires authentication unless it says otherwise (page authorization (policies)).
[Authorize(policy)]requires a named policy — and the reference is compile-checked, so[Authorize(Typo)]is a build error rather than a silent hole.
But understand the layering: the UI rail decides who may open a page; the entity rules decide what data exists for
them. A page that forgets [Authorize] and a query that correctly filters by owner will still show nothing but the
user's own rows. Defence in depth is not a slogan here; the data path never trusts the UI path.
7. Prove it, or you have not done it#
Compiling proves nothing. Your tests passing proves nothing — they probably ran unrestricted. The only way to know that Bob cannot read Alice's document is to become Bob and be refused:
[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)]
void Bob_cannot_see_Alices_doc() {
var bob = User.Single(u => u.Name == "Bob");
runas(bob) {
Assert.Equal(0, Doc.Count()); // for Bob, Alice's row does not exist — it is not hidden, it is absent
}
}Note what is being asserted: not that an exception was thrown, but that the row is not there. That is the shape of a correct row-level rule.
Write one of these for every rule that matters. It is what stops a refactor a year from now from quietly opening a door that nobody notices is open — and "nobody notices" is the entire failure mode of security.
The mistakes people make#
| Mistake | What actually happens |
|---|---|
Writing default deny; | Nothing. It is already the default — and typing it teaches you that security exists where you typed it. |
A bare allow read; | A compile error on an app with a principal. Say who: when IsAuthenticated, or IsAuthenticated \|\| IsAnonymous if you truly mean the whole internet. |
Fetching rows and filtering them in a foreach | Slower, and it is not security — the rows already left the database. Put the predicate in the query. |
Testing with no runas | You tested what an unrestricted caller can do, which is everything. You have not tested security. |
| Assuming the UI protects the data | It does not, and it is not supposed to. The data path never trusts the UI path. |
See also#
- secure by default (deny-all) — the deny-all posture in full, and what it does not cover
- security { } — the
security { }block:allow,where,when,policy - principal predicates (IsAuthenticated / IsAnonymous) and open reads —
IsAuthenticated/IsAnonymous, and why an open read must say so - navigation in security predicates (any depth, either side) — following relations in a
where, at any depth and on either side of the comparison - public pages (what a signed-out visitor can see and do) — a public page and its public data are two grants; forget the second and the page renders empty
- capability rows that belong to a user — capability tables whose rows belong to one signed-in user, and the
[Principal]they need - rows that are part of another row — rows that are part of another row and take its rule; declare a shape's security once
- auth bootstrap (login, before anyone is signed in) — logging in before a principal exists
- [AuthMethod] — a function an unauthenticated visitor may call —
[AuthMethod]: the one door an unauthenticated visitor may walk through - role grants (and the first admin) — granting a role, the first admin, and never letting a lower tier grant a higher one
- app.Auth — how the platform authenticates a user of your app —
app.Auth: how the platform can authenticate a user of your app with no code from you - OAuth clients (app.OAuthClients) —
app.OAuthClients: signing users in with an external identity (Google/GitHub/…), and connecting to an external API on their behalf - Security.* — hashing, verifying, tickets, random ids —
HashPassword·VerifyPassword·IssueJwt·RandomId - page authorization (policies) —
[Authorize(policy)]on a page - runas — becoming a user, so a rule can be proved