Security
Default-deny, declared on the entity, enforced under every read your program can express. This is what that means in practice — including the two things it deliberately does not do.
01
Nothing is readable until you say so
An entity with no security block grants nothing to anyone — not read, not create, not to an admin, not to the person who wrote it. There is no "open by default and lock it down later", because later is where the leak is.
What you write instead is a vocabulary: the roles this app has, the table that grants them, and the named predicates the rest of the model reads.
1[Role] enum Role { /// The ephemeral principal `Login`/`Signup` run as. Never granted to a person. Authenticator, /// Submits expenses. The floor every signup lands on — no account is ever role-less (a user with no grant is /// denied by RLS everywhere, and cannot even read its own row). Employee, /// Approves their reports' first stage. Managerial approval is by the `Manager` LINK on User, not by this role — /// the role says "may approve at all", the link says "whose". Manager, /// Sees every report and every cost. The finance desk, and the audience for the cost-to-value screens. Finance, /// The third approval stage, reached only for large amounts. Cfo, } 2entity RoleGrant { [Required] User User; [Required] Role Role = Role.Employee; security { allow read when IsFinance; // finance sees who may do what allow read where User == user; // and you can see your own allow create, update, delete when IsFinance; allow create when IsAuthenticator; // signup seeds the Employee floor (leashed by Signup's body) } } policy IsAuthenticator => RoleGrant.Any(g => g.User == user && g.Role == Role.Authenticator); 3policy IsFinance => RoleGrant.Any(g => g.User == user && g.Role == Role.Finance); policy IsApprover => RoleGrant.Any(g => g.User == user && (g.Role == Role.Manager || g.Role == Role.Cfo)) || IsFinance;
Roles are an enum, so they are checked. A misspelled role is a compile error rather than a predicate that is quietly false forever.
The grant table is YOURS. The platform does not own a users-and-roles schema you have to bend to; a grant is a row in a table you declared, and it is governed by the same rules as every other row — look at its own security block.
A policy is a named predicate. Written once, read everywhere, and it is ordinary LINQ over your own model — so "a manager, or finance" is spelled the way you would say it.
02
Rows and fields, on the entity
Two kinds of rule, and both live on the declaration rather than at a call site. when is a condition on the CALLER. where is a filter on the ROWS — and it becomes part of the SQL, so an unauthorised read does not fail, it returns nothing.
1[Principal] entity User { [Required, MaxLength(255), Unique("An account with that email already exists.")] string Email; /// Salted hash — never the plaintext. Readable ONLY by the armed authenticator (the field mask below). [MaxLength(200)] string? PasswordHash; [MaxLength(200)] string? DisplayName; /// Who approves this person's expenses at the first stage. The workflow's `Manager` slot assignee. User Manager; /// Stamped on every successful authentication. Null = never signed in. DateTime? LastLoginAt; [ForeignKey(User)] RoleGrant[] RoleGrants; security { 2 allow read when IsFinance; // the finance desk sees the directory allow read when IsAuthenticator; // login/signup verify a credential 3 allow read where Id == user.Id; // you read your own profile 4 allow read where Manager == user; // a manager reads their own reports' profiles 5 deny read PasswordHash when !IsAuthenticator; // ONLY the auth flow ever reads the hash (field mask) allow create, update, delete when IsFinance; allow create when IsAuthenticator; // signup creates the credential allow update where Id == user.Id; // you edit your own profile 6 allow update LastLoginAt when IsAuthenticator; } } app.Auth = new PasswordAuth { LoginField = Email, PasswordField = PasswordHash };
This entity is who the caller IS. One per app, and it is your type — with your fields on it, including the Manager link the rules below read.
when — about the caller. True or false for the whole request.
where — about the row. Several allow read lines are alternatives: you can see your own row, OR your reports', OR everything if you are finance.
A filter that walks a link. This is why the grant table did not have to encode the org chart — the org chart is already a field.
⭐ A FIELD MASK. The row still comes back; the column does not. Only the armed authenticator ever reads the hash — and note the when is not optional, because an unconditional deny would drop the column from the login's own SELECT and refuse every correct password.
Masks and grants are per FIELD as well as per row, so "the login may stamp this one column" is a line rather than a service.
03
…and then you stop thinking about it
This is the part that is hard to believe from a page, so here is the precise claim: after the declaration, there is nothing about security for the rest of your program to get right.
A query is a query. A UI binding is a UI binding. An agent's read is a read. No Osy# code can bypass declared security — not a function you write, not a serializer, not a page — so there is no call site that could forget a check, and no code review that needs to look for one. The live var on a page narrows the moment you tighten a rule, and nothing on the page changes.
The one place it becomes visible again is when you want to READ what you declared, and that is a command rather than an audit:
$ osy explain > Posture: deny-all. An entity with no `security { }` block grants no access to anyone. ## `User` — default-deny - Read — The caller holds the Authenticator role; or any signed-in user — `PasswordHash` carries a rule of its own on top of this: the row still comes back, the column may not. - Delete — No one (denied by default). Fields - The PasswordHash field is withheld from a reader unless the caller holds the Authenticator role.
04
What happens when you change it
Security changes, and where they land
deny read PasswordHashWhere to go next
The same rules, seen from the page that reads them.
Every construct on this page, one page each.
The app on this page, one command away.