Osy#betaRun it on Osyrin: osyrin.com

Reference / Local

Explaining your app's security

osy explain [path] [--json] [--with-findings]

Explains your app's declared authorization in plain English — who can read, create, update and delete each entity, which fields are masked, each page's auth posture, which realtime topics admit a signed-out visitor, where a page's access does not line up with the data it shows, and — for every declared secret — every place it leaves the server and when it was last set.

stable1 example compiled by CIlocalcliauthoringsecurity

Summary#

Turns your app's declared authorization into a plain-English report — who can do what, per entity and per page — so you can reason about access across the whole app without reading a single security predicate. It needs no platform and no database: it parses and resolves, nothing else.

Signature#

osy explain [path] [--json] [--with-findings]

Description#

Your app states access as declarations — a security { } block on an entity, a policy, an [Authorize] on a page. Each is a precise rule, but reading them one file at a time never adds up to the question you actually have: across this whole app, who can read the customer's card number? who can create an order? which page is public? osy explain answers that. It walks every declaration and writes it out in sentences.

Because access here is declared, not coded, the translation is exact and repeatable — the same rules always produce the same words. It is not a summary that guesses; it is a rendering. When a rule uses a shape the report cannot state plainly, it says so — it prints the shape faithfully and marks it (needs review) rather than paraphrasing a meaning it cannot stand behind. A sentence with no such mark is one you can rely on.

The report has three parts.

The data surface — per entity. For every table-backed entity, who may read, create, update and delete it, in one line each:

  • A role check reads as "the caller holds the Staff role." A grant to signed-in users reads as "any signed-in user." A grant that also admits anonymous visitors reads as "anyone, including anonymous visitors (public)."
  • A row filter reads as "only their own rows (where Owner is the caller)." Membership through a related table reads as "only rows they belong to (via Membership)." A membership that also demands a role on that record names it: "only rows of an Organization where the caller's Membership has Role = Admin" — and the same rule written through a parameterised policy, where IsAdmin(Organization), reads as that same sentence.
  • A condition on the row's own fields is said as written: "only rows where Status is Status.Open", "rows where Archived is false", "rows where Zone is set".
  • Any other related record is spelled out piece by piece: "only rows where there is a Placement whose ComputeZone is this row and for which the caller is the User of an ApplicationMember whose Application is that Placement's Application." A policy called inside it, such as IsAdmin(r.Organization), is read as its own body with the argument put in.
  • An entity that grants nothing reads as "no one (denied by default)" — the honest reading of a security { } block with no matching rule, or of an entity with no block at all under secure by default (deny-all).

Grouping is never left to guess. A && B reads "A, and of those only B", never as two separate grants. Alternatives inside a condition are bracketed, with each option a whole sentence: "(either its Owner is the caller; or its Helper is the caller)". An operation with more than one grant lists them one per line, and field rules that say the same thing are one line naming every column they cover.

Every condition a rule carries reaches the sentence. When a rule holds a condition the report cannot phrase (arithmetic such as m.Score * 2 > 10, say), the whole rule is printed as written, with the brackets the source used, and marked (needs review). It is never narrowed to a weaker sentence with that condition silently gone. A sentence about less restriction than the rule declares is the one thing this report must not produce, because it is read in place of a review.

It also lists field masking: a field a rule withholds ("the CardNumber field is never returned to any reader"), and a [Classification] mask ("Ssn is classified Secret — masked from readers below Secret"), noting where a field is also redacted from the audit trail.

The UI surface — per page. Access is enforced at the data, but each routed page also declares its own posture, and the two are independent — so the report shows both. Per page: its route; whether it is public ([AllowAnonymous]), gated by a policy ([Authorize(P)], stated as the same English the data surface uses), open to any signed-in user (the secure-by-default rule), or a composable fragment that inherits its host's gate; the entities it reads and writes; and any policy-aware controls it carries (a button or field that reflects a policy). A composable that touches no data is left out — the report is about pages that matter to access, not every presentational primitive.

The realtime surface — per topic. A topic's Candidates decides who receives a room's traffic, so it is an authorization rule like any other and the report states it. Per topic: its address (RoomFeed(Room room) — two addresses are two topics), whether it admits a caller with no principal ([AllowAnonymous] on the topic, which is the only thing that does — the join gate refuses a signed-out caller on every topic that does not declare it), whether publishing follows its own Publishers rule or falls through to Candidates, what it carries, and whether membership is presence-derived.

It also flags a join rule that never reads its principal. That is not a verdict — Candidates = _ => true is a perfectly good "every signed-in user" — but a rule that restricts on the topic's address reads as a per-room restriction and is not one, so an auditor is shown which rules discriminate between callers and which do not. The same question osy lint asks, answered the same way, so the two cannot disagree about one app.

The coverage cross-check. The reason to put the two surfaces side by side is to catch where they disagree. A page's gate opens the screen, but the row read is gated separately at the entity, and the two do not inherit — so a page that lets in a caller the data will deny renders fine and shows an empty list, with no error to point at. The report flags every such gap: a public page over data that is not anonymously readable, a signed-in page over data only a role may read, or a page that reads an entity nothing grants a read to. It only flags what it can prove — a page that plainly admits more than the data serves. It does not guess a direction between two different policies.

Findings you meant. Some of these flags are deliberate — a public status topic, a welcome page that shows members more than visitors. [Intended("<finding>", Justification = "…")] on the page or topic says so ([Intended] — acknowledging an osy explain finding): the finding is still listed, as ✓ intended with your reason, and the count the report opens with — > **Findings:** 2 open · 1 acknowledged as intended — stops counting it. A mark that no longer matches a finding is itself counted open.

A package's entity your app adds rules to. A partial entity X { security { … } } over a package's entity does not replace the package's rules: the report lists the package's rules first, then this app's partial entity rules, and says how the two combine — under [Authority(Narrowable)] (the default) the package's rules still decide and yours can only narrow them; under [Authority(Extensible)] your grants add to theirs. Only where the package declares no rules of its own are yours the whole answer.

The secrets surface — per secret. For every secret the app declares, every path by which it leaves the server: the sink it ends at (a request header and its name, a declared query or body slot, the key of a signing call, the mTLS client certificate), the host it goes to — from the URL when the code writes one, otherwise the hosts the app granted the package that sends it — the method, the function and file:line, and the chain of helpers it travelled through. A secret that reaches no sink is listed as unused. Every path here was checked by the same compiler pass that refuses every other path ([[function-secret-read#confinement]]), so the list cannot drift from what the compiler enforces. Each secret also shows when its value was last set, read from the local platform running this project when there is one; offline it says it does not know rather than guessing, and a deployed app's times are on the console's Secrets tab. The secretIn lines in force are listed last, with whether the app granted each one (where a secret may be sent — secretIn).

Output. Markdown by default — a document you can read, review, or commit. --json writes the whole report as JSON, the form to hand to a coding agent, to diff between two revisions, or to export into a compliance tool as audit evidence (the secrets map is its secrets member); the English sentences ride inside the JSON, so the machine-readable surface is also the readable one. --with-findings additionally folds in the maturity linter's security and UI advice inline (see Checking your app) — off by default, because the report's job is to state what your rules are, and the findings are what a reviewer would then suggest. Exit is non-zero when the source did not fully resolve, so the report is partial and says so.

For the machine model of the whole app — types, relations, function effects — see Understanding your app. For where the app falls short of a production bar, see Checking your app.

Examples#

osy explain                    # the plain-English security report, human-readable
osy explain --json             # the same report as JSON (the English rides in it too)
osy explain --with-findings    # also fold in the linter's security / UI advice

The data surface for one entity reads like this:

## `Order` — default-deny

- **Read** — any of these 2:
  - The caller holds the Staff role.
  - Only their own rows (where Owner is the caller).
- **Create** — The caller holds the Staff role.
- **Update** — No one (denied by default).
- **Delete** — No one (denied by default).

**Fields**

- The CardNumber field is never returned to any reader.

An organisation-scoped grant table — writable only by an admin of the row's organisation, whether the rule is written inline or through a policy:

[Role] enum OrgRole { Admin, Member }

[Principal] entity User {
  [Required, MaxLength(200), Unique] string Email;
  security { allow read when IsAuthenticated; }
}
entity Organization { [Required, MaxLength(80)] string Name;
  security { allow read when IsAuthenticated; } }
[RoleGrant] entity Membership { [Required] User User; [Required] Organization Organization; [Required] OrgRole Role;
  security { allow read when IsAuthenticated; } }

policy IsAdmin(Organization o) => Membership.Any(m => m.User == user && m.Organization == o && m.Role == OrgRole.Admin);

entity RoleGrant {
  [Required] Organization Organization;
  [Required, MaxLength(40)] string Label;
  security {
    allow read when IsAuthenticated;
    allow create, update, delete
      where Membership.Any(m => m.User == user && m.Organization == Organization && m.Role == OrgRole.Admin);
  }
}
entity Invitation {
  [Required] Organization Organization;
  [Required, MaxLength(200)] string Email;
  security {
    allow read when IsAuthenticated;
    allow create, update, delete where IsAdmin(Organization);   // the same rule, named
  }
}

reads, for both entities:

- **Read** — Any signed-in user.
- **Create** — Only rows of an Organization where the caller's Membership has Role = Admin.
- **Update** — Only rows of an Organization where the caller's Membership has Role = Admin.
- **Delete** — Only rows of an Organization where the caller's Membership has Role = Admin.

A page whose access does not line up with the data it shows is flagged in place:

## `OrdersPage` @ `/orders`

- **Access** — Any signed-in user (secure-by-default; not [AllowAnonymous]).
- **Reads** — Order
  - ⚠ 'OrdersPage' admits any signed-in user, but 'Order's read is gated by a policy/role — a signed-in user outside
    that grant sees an empty 'Order'.

Where each secret goes reads like this:

## Secrets — where each one leaves the server

### `StripeApiKey` — last set 2026-09-28 14:02 UTC
- **header** `Authorization` → granted: api.stripe.com · POST · `StripeClient.Post` (StripeClient.osy:342) [Osysharp.Payments.Stripe] · via `StripeClient.Headers`

### `PostNordApiKey` — last set 2026-09-21 09:40 UTC
- **query** `apikey` → api2.postnord.com · GET · `PostNordClient.Track` (PostNordClient.osy:145) [Osysharp.Carriers.PostNord]

### `LegacyToken` — last set: unknown offline — no local platform is running for this project
- **unused** — no code path sends it anywhere.

See also#

reading a secret's value (Secret.Name) — what the compiler lets a secret do, and the one place it may end.

where a secret may be sent — secretIn — egress "host" { secretIn … }, the only way a secret may travel in a URL or a body.

Understanding your app — the machine model of the whole app (types, relations, effects).

Checking your app — where the app falls short of a production bar; --with-findings folds it in.

secure by default (deny-all) — why an entity with no security block grants nothing.

security { } — the security { } block this report reads.

principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads — the policies it translates into English.

Related

[Intended] — acknowledging an osy explain finding

`osy explain` flags a few postures with ⚠ — a public page whose data the public cannot read, a topic open to signed-out…

Checking your app

Checks your app against production best-practice rules and reports where it falls short — the maturity signal, beside…

Understanding your app

Prints your app's RESOLVED model — field types bound to real types, relations wired to the entity they target, each…

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…

role grants (and the first admin)

A role is granted by an ordinary entity you mark `[RoleGrant]` — one that holds a reference to your `[Principal]` and a…

principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads

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

public pages (what a signed-out visitor can see and do)

A public page, its public data, its public actions and its live updates are four separate declarations…

topic

A topic is a named, addressable realtime destination: a declared place to put a message so that every open page…

reading a secret's value (Secret.Name)

`Secret.Name` in a function body evaluates to the declared secret's value — the key itself, as a string, read at the…

where a secret may be sent — secretIn

A secret may end only at a sink: a request header, the key of a signing call, the mTLS certificate — or a URL query…