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 "callers who hold 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."
- 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).
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 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.
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 or to diff between two revisions; 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 adviceThe data surface for one entity reads like this:
## `Order` — default-deny
- **Read** — Callers who hold the Staff role; or only their own rows (where Owner is the caller).
- **Create** — Callers who hold 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.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'.See also#
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) and open reads — the policies it translates into English.