Why Osy# · Chapter 05 · Security
Say who may read it, once. Every query obeys.
Who may see a record has to hold for every read of it, whichever page, function or agent makes the read. In Osy# the rule is declared once, on the entity, and the compiler puts it inside every query and every write the program can express. An entity with no rule grants nothing to anyone, not to an admin, not to the person who wrote it. The rule can be as simple as ownership of a row, or composed from the application's own data.
the reports example app · model/reports.osy · compiles as printed[Principal] entity User { [Required, MaxLength(100)] string Name; security { allow read when IsAuthenticated; } } entity ExpenseReport { [Required, MaxLength(140)] string Title; [Required] User Employee; security { allow read where Employee == user; } }
the same file · an ordinary query over itList<ExpenseReport> MyReports() { return ExpenseReport.ToList(); } // not one word about who is asking
01
Same query. Different principal. Different rows.
One rule on one entity, and an ordinary query over it. Anna and Otto each have expense reports; they run the same function.
Anna
MyReports()
ExpenseReport.ToList(), with the rule inside it
Lisbon, March
Berlin, April
Otto
MyReports()
ExpenseReport.ToList(), with the rule inside it
Oslo, May
Same query. Different principal. Different rows.
The rule belongs to ExpenseReport, not to this query. Security is part of what the entity means, so a query over the entity is already a query over the rows the current principal may read, and the person writing the query does not need to know how the rule works. The test beside these files runs that function as Anna and as Otto and asserts two rows and one; it passes.
02
Nothing is readable until you say so
Take the rule away and the query still compiles. It answers nobody anything: an entity with no security block grants nothing, not read, not create, not to an admin, not to the person who wrote it.
With the rule
allow read where Employee == user;
Anna: her two reports
Otto: his one
With no read grant
security { }
Anna: no rows
Otto: no rows
The empty state is the closed one, and every grant is something you wrote down. That is the base of the model rather than a feature of it: the question a security block answers is which access to grant back.
03
when is about the principal. where is about the row.
Two kinds of rule, and every security declaration you will read is made of them.
when · the principal
allow read when IsFinance;
true or false for the whole request
true: every row · false: no rows
where · the row
allow read where Employee == user;
evaluated per row, inside the query
the rows for which it holds
Several allow read lines are alternatives: a person sees a row if any of them admits it. A where becomes part of the SQL, so a read the caller may not make does not fail; it returns nothing. And a when may name a policy, a predicate over the model written once and used by name, which is what IsFinance is.
04
A real rule is composed from named policies
The simple model scales. A caseworker may read a case if their unit handles it, they have not recused themselves, and, for a restricted case, they are senior enough. Each condition is a policy that takes the row it is about; the rule composes them; and the composed predicate is still inside every query.
the rule · Docs/reference/security/naming-a-policy.md · compiled by CI// each rule named once; one ABOUT a row takes it policy HandledByUnit(Unit u) => RoleGrant.Any(g => g.Holder == user && g.Unit == u); policy HasRecused(Guid caseId) => Recusal.Any(r => r.Person == user && r.Case.Id == caseId && r.Lifted == false); policy MaySeeRestricted => RoleGrant.Any(g => g.Holder == user && g.Level == Level.Senior); entity Case { [Required, MaxLength(40)] string Reference; [Required] Scheme Scheme; bool Restricted; security { allow read where HandledByUnit(Scheme.OwningUnit) && !HasRecused(Id) && (!Restricted || MaySeeRestricted); } } // inlined into the SQL of every query on Case, // and on everything that hangs off one
A policy is ordinary LINQ over the application's own model, and a where may call one with the row it is about. The compiler inlines the composed predicate into the persisted rule, byte-for-byte the welded one, so the database does the same work it would for a rule written out long-hand, and the recusal rule is written in one place. Naming a policy walks the whole example.
05
Roles and grants are application data
The roles an application has, the table that grants them, and the policies the rest of the model reads are declared in the application, so a rule can name a role the way it names any other fact about a row.
1[Role] enum AppRole { Authenticator, Member, Cfo } [Principal] entity User { [Required("Tell us what to call you."), MaxLength(100)] string Name; [Required("We need an address to sign you in at."), MaxLength(200), Unique] string Email; [MaxLength(200)] string? PasswordHash; Dept Department = Dept.Legal; User Manager; security { allow read when IsAuthenticated; allow read, create when IsAuthenticator; allow create when IsCfo; allow update when IsCfo; deny read PasswordHash when !IsAuthenticator; // nobody but the auth flow ever sees the hash } } 2[RoleGrant] entity RoleGrant { [Required("A grant has to name who it is for.")] User Grantee; AppRole Level = AppRole.Member; security { allow read when IsAuthenticated; allow create when IsAuthenticator; // …the signup, for the first grant 3 allow create, update, delete when IsCfo; // all three verbs, or the weakest is the way in } } policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator); 4policy IsCfo => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Cfo);
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. A grant is a row in a table you declared, with the shape your application needs, and it is governed by the same rules as every other row: look at its own security block.
All three verbs, or the weakest is the way in. Who may grant a role is itself a rule on a row — here the CFO's, and the first grant is the signup's.
A policy is a named predicate. Written once, read everywhere, and it is ordinary LINQ over your own model. It answers a question about the principal, so on its own it admits every holder of the role to every row; a rule about WHOSE row pairs it with a where.
06
Rows are not the smallest boundary
A row can be readable while one of its fields is withheld, and a field can be writable by fewer people than the row is. Here is the principal entity from SpendFlow, abridged to its field rules.
apps/spendflow/model/identity.osy · abridged[Principal] entity User { [Required(…), Unique(…), MaxLength(255)] string Email; [MaxLength(200)] string? PasswordHash; [MaxLength(120)] string? DisplayName; DateTime? LastLoginAt; … security { allow read when IsAuthenticator; allow read where Id == user.Id; … deny read PasswordHash when !IsAuthenticator; allow create when IsAuthenticator; allow update where Id == user.Id; … deny update LastLoginAt; allow update LastLoginAt when IsAuthenticator; } }
A field mask. deny read PasswordHash when !IsAuthenticator leaves the row readable and drops that column from the statement for everyone except the authenticator, the principal the login runs as. The when is what lets the login read it: an unconditional deny would drop the column from the login's own read and refuse every correct password.
A field grant. A column rule adds to or takes from the entity's grant for that column. allow update where Id == user.Id lets a person change their own row; deny update LastLoginAt takes that one column back from everyone the update grant admits, and the conditioned allow hands it to the authenticator alone — so only the login stamps it.
07
The rule follows every read
Once security is part of the entity, every way of reading the entity carries the same rule. A page, a function, an agent and an export read ExpenseReport; each read is resolved with the entity's rule inside it.
A page
ExpenseReport
the entity's rule
one resolved query
A function
ExpenseReport
the entity's rule
one resolved query
An agent
ExpenseReport
the entity's rule
one resolved query
An export
ExpenseReport
the entity's rule
one resolved query
The entity means the same thing wherever it is read.
So a function says what it is looking for, and the entity says which of those rows this principal may see; the two predicates become one resolved query. Here is a read over the Case entity from section 04. It filters for restricted cases under a scheme, and not one word about who is asking:
Docs/reference/security/naming-a-policy.md · compiled by CIList<Case> RestrictedUnder(string scheme) { return Case.Where(c => c.Restricted && c.Scheme.Name == scheme) .OrderBy(c => c.Reference) .ToList(); } // outside the unit: an empty list, not an error. // recused from this case: it is not in the list. // not senior: no restricted case at all. // one function, for all of them.
Tighten the rule on the entity and every one of those reads narrows at once; the live var on a page narrows the moment the rule changes, and nothing on the page changes.
08
Ask what you declared
Because the rules are part of the application model, the toolchain can read them back. osy explain derives a report from the same declarations the runtime enforces, so it cannot say something the runtime would not do.
$ 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.
What happens when you change it
Security changes, and where they land
Each of these is a thing you would do on a Tuesday. The verdict is the compiler's, not a convention's.
| You do this | Verdict | Because |
|---|---|---|
| Tighten a rule | compiles | Every read in the program narrows at once: pages, functions, agents, exports. The rule is inside each of their statements, so nothing else changes. |
| Add an entity and forget its security block | compiles | It compiles, and nobody can read it. Forgetting produces an entity nobody can reach, not exposed data, which is why the default is deny. |
| Query around a rule in your own code | refused | There is nothing to write. The rule is applied beneath every read the language can express. |
Write an unconditional deny read PasswordHash | check fails | It compiles, and your own login would refuse every correct password, because the masked column is dropped from its read too. A MUST-tier lint finding names it, so osy check fails. |
| Give an agent a role | compiles | It reads what that role reads. An agent is a principal, so its access is the same declared rules, evaluated for it. |