Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / Security

The security model

How authorization works in Osy#, end to end. Everything is denied until you grant it; a grant is compiled into every query rather than checked afterwards; and the only way to know a rule works is to become the user and try.

stable4 examples compiled by CIsecurityguide

Summary#

Security in Osy# rests on three ideas. Hold these and the rest is detail:

  1. Silence denies. An entity you say nothing about is readable by nobody. You never turn access off — you only ever grant it.
  2. 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.
  3. 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?#

QuestionAnswered byPage
What may this entity's rows be used for, and by whom?the security { } blocksecurity { }
Is this row yours?a where clause — a predicate over the rowsecurity { }
Is it yours through something else?a where clause that navigates relations, any depthnavigation 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 principalprincipal predicates (IsAuthenticated / IsAnonymous) and open reads
Who is user?the [Principal] entitybelow
May this person open this page?[Authorize(policy)] on the componentpage authorization (policies)
How does anyone log in, if nothing is readable yet?app.AuthBootstrapauth bootstrap (login, before anyone is signed in)
Does any of it actually work?runas + Assert.Deniedrunas

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:

  • where filters by the row. The owner sees their own documents. It narrows which rows you get.
  • when gates 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. Under allow 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#

MistakeWhat 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 foreachSlower, and it is not security — the rows already left the database. Put the predicate in the query.
Testing with no runasYou tested what an unrestricted caller can do, which is everything. You have not tested security.
Assuming the UI protects the dataIt does not, and it is not supposed to. The data path never trusts the UI path.

See also#

Related

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…

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…

navigation in security predicates (any depth, either side)

A `where` row filter may follow relations as far as the model goes — `Folder.Workspace.Region.Head == user` is a…

capability rows that belong to a user

Some capability tables hold rows that belong to one signed-in user — a chat conversation is yours, not the app's. Those…

principal predicates (IsAuthenticated / IsAnonymous) and open reads

Two built-in `when` predicates say who a request is: `IsAuthenticated` is a signed-in user, `IsAnonymous` is an…

auth bootstrap (login, before anyone is signed in)

Under deny-all, login faces a paradox: it must read a user row *before* anyone is authenticated. `app.AuthBootstrap`…

[AuthMethod] — a function an unauthenticated visitor may call

`[AuthMethod]` marks a sign-in function — login, signup, password-reset — as reachable by a visitor who is not signed…

role grants (and the first admin)

A role is granted by an ordinary entity — any entity that has both a reference to your `[Principal]` and a property…

app.Auth — how the platform authenticates a user of your app

`app.Auth` binds two properties of your `[Principal]` — which one is the login, which one holds the password hash — and…

OAuth clients (app.OAuthClients)

`app.OAuthClients` declares the third-party OAuth providers your app uses — for signing users in (Login) and for…

Security.* — hashing, verifying, tickets, random ids

The calls an authentication flow needs: `HashPassword` (salted, one-way), `VerifyPassword` (constant-work comparison…

runas

Runs a block as a given principal, so security rules apply exactly as they would for that user. It is how you test that…

page authorization (policies)

A `policy` names a reusable authorization predicate over the current user — e.g. `policy Admins => UserRole.Any(r =>…