# Checking access

> Runs your app and checks that it does what its model says about access: who can read, change and delete each row, what a signed-out visitor can reach, which fields a deny hides, which writes a deny refuses, the security headers, and how it answers requests shaped to attack it: extra members, self-granted roles, ended sessions, foreign origins, open redirects, malformed bodies and another tenant's ids, and whether the routes the page itself runs on (contexts, the function bridge, the live socket, files, the log stream) answer only their owner. It is opt-in, and each run is recorded with the project, so `osy compile` and `osy deploy` say when it is due. Each check reports conforms, not applicable, or MISMATCH, and every mismatch carries the request that showed it.

<!-- id: local-checking-access · area: local · stability: preview · html: https://osysharp.com/reference/local/checking-access/ -->

## Summary        {#summary}

`osy check --access` holds your running app to its own model. The model is the specification: the `security { }`
blocks, the `deny read` and `deny update` fields, the `[AllowAnonymous]` functions and pages, and the REST APIs your
source declares. The check compiles the app into an app of its own on your local platform, gives it accounts and
data, and then uses the same HTTP routes a browser uses to ask whether it agrees. It reports every place it does not.

## Signature      {#signature}

```console
osy check --access [path] [--json] [--seed <seed>]
```

## Description    {#description}

`osy check` validates, lints and tests your source. `osy check --access` asks a different question: whether the app,
**running**, enforces what the source says about access. It is opt-in, because it starts a platform and compiles the
app twice; plain `osy check` does not run it (`--no-access`, which said so, is still accepted). `--json` and `--seed`
belong to it.

**The run is recorded.** Each finished run writes `accessCheck` into the project's `osyrin.json`: the check's version,
a hash of the source it ran on, when, and its verdict. Commit it with the source. `osyrin.json` is not part of the
source, so recording a run does not change the hash it records. `osy compile` and `osy deploy` read it and print one
line, and never refuse, when:

- the check has never been run on this project;
- it last ran as an older version of the check, which checks less than this one;
- the source has changed since it ran;
- it found mismatches.

When the last run is on this source, with this version of the check, and conformed, they say nothing. A run that could
not finish, because the app did not compile or the platform stopped answering, leaves the last record as it was.

**Where it runs.** On this project's own local platform — the one `osy compile`, `osy test` and `osy launch` use — in
an app of its own named after yours with `-access` added. Your app and its data are not touched, and your platform is
not stopped. Each run compiles the current source into that app, so every run starts from an empty app. It then
builds a small world:

1. It loads one of your `[DevSeed]` fixtures. By default this is the last one your tests declare; `--seed` picks
   another.
2. It makes **two accounts for every member of your `[Role]` enum** and signs each one in.
3. The first account of each role creates a row of every entity your app declares, filling only the required
   members. Rows of a kit's own tables come from the seed and from the kit's own flows.

An app that signs people in through its own `[AuthMethod]`s, with no `app.Auth = new PasswordAuth { … }`, gives the
platform nowhere to put a password. For such an app the check uses the accounts the seed made instead, or two made by
the app's own `Signup`. The report says which accounts it used.

**The model checks.**

| Check | What must hold | How the check proves it | ASVS 4.0.3 |
|---|---|---|---|
| Row visibility | A principal can read, change or delete by id exactly the rows in its own list read of that entity. | For each entity and each account, the rows other accounts can list and this one cannot are read by id, then changed and deleted. Each must be refused, and the row's owner must still see it unchanged. A row in an account's own list must also be readable by id. | V4.2.1, V4.1.3 |
| Signed-out access | A signed-out caller reaches exactly the functions, pages and REST routes the model marks anonymous. | Every function the instance serves, every routed page, and every published REST route is called with no account. A function is anonymous when it is `[AllowAnonymous]`, or when it is an `[AuthMethod]` that `app.AuthBootstrap` puts in a slot. | V4.1.1, V4.1.5, V13.1.4 |
| Masked fields | A field under `deny read` never appears in any response to a principal the deny covers. This includes credentials. | Every response to a covered account is searched for the field: list reads, reads by id, and pages opened with their data. Each is also searched for the field's **values** as an uncovered account reads them, so the field cannot leak under another name. No account's password may appear in any response. | V4.1.3, V8.3.1 |
| Denied writes | A write to a field under `deny update` is refused, and the row does not change. | Each covered account writes the field on a row it can see. The write must be refused, and an account that may read the field must see the old value. A write the instance accepts and then drops is a MISMATCH, because the client was told something saved when it did not. | V4.1.2, V5.1.2 |
| Security headers | Every page carries the platform's declared policy. | The root and every static route are requested signed out. CSP must be present, with no `'unsafe-inline'` or `'unsafe-eval'` in `script-src`, `object-src 'none'`, and a bound `base-uri`. The page must also carry `frame-ancestors 'self'`, `X-Content-Type-Options: nosniff` and `Referrer-Policy: strict-origin-when-cross-origin`. | V14.4.3, V14.4.7, V14.4.4, V14.4.6 |

**The request-shape checks.** These send requests shaped the way an attacker shapes them. None carries a hostile
string; each is a request a browser could send, and an oracle that says what the answer must be.

| Check | What must hold | How the check proves it | ASVS 4.0.3 |
|---|---|---|---|
| Mass assignment | A write sets only members the entity declares, never one the server owns, and points a reference only at a row the writer can read. | On a row an account may change, it writes a member no entity declares, the row's `Id` and type, and its stamps; it creates a row with an undeclared member; and it points a reference at a row the account cannot read. Each must be refused with a 4xx, and nothing may change. | V5.1.2, V4.2.1 |
| Role escalation | Only the accounts your grant table's rules name may hand out a role, and an `[AuthMethod]` runs only as the sign-in flow. | Each account grants itself and another account every role it lacks, then raises its own grant. A grant that lands where the declaration allows no create is a mismatch. Every `[AuthMethod]` is then called directly by a signed-in account and must be refused. | V4.1.3, V4.2.1 |
| Sessions | A ticket stops working when its session ends, works only on the app that issued it, and only as signed. Cookies are HttpOnly and SameSite. | A fresh sign-in signs out, and its ticket must then be refused (401). The ticket is sent to Admin's host, with its signature altered, and with its signature removed (`alg: none`). The cookies the app sets are read for their flags. | V3.3.1, V3.5.3, V3.4.2, V3.4.3 |
| Cross-origin | A foreign site cannot ride a cookie, or get a CORS yes, for a state change. | A preflight from `https://evil.example` must get no `Access-Control-Allow-Origin`; an allowed origin must get no `Allow-Credentials`. A `text/plain` POST carrying only the cookie, with no ticket, must be refused. | V4.2.2 |
| Redirects | A destination named in a request is followed only on the app's own site. | The OAuth authorize and connect routes are given a foreign `redirect_uri`, the site is asked for `//evil.example/`, and the root and each routed page are given return parameters pointing at `https://evil.example/`. A `Location` off the app's site is a mismatch. | V5.1.5 |
| Error bodies | A request the client gets wrong is refused with a 4xx, and no body carries a stack trace, SQL, or a file path. | An entity, page, function, context or address that does not exist; a body that is not JSON; a row id that is not an id; a filter naming an unknown member, or one that does not parse; an unknown sort member; text written to a number. A 5xx, or any of those markers in a body, is a mismatch. | V7.4.1 |
| Plan limits | The meters that bound expensive work refuse past their limit. | Reported as not applicable on a local platform, which grants no plan; each meter names the tests that guard it. | V11.1.4 |
| Tenant isolation | Nothing one tenant hands out opens anything in another. | The same source is compiled into a second app, `<slug>-access-tenant-b`. Tickets, row ids, the visitor cookie and file addresses from the first are each tried on the second, and must open nothing. The report says which organization the second app is in. | V4.1.1, V4.2.1 |

**The client-runtime checks.** The page a browser runs talks to the platform over routes your source never declares:
the context it reads and writes through, the bridge a function crosses on, the live socket, file addresses, the log
stream. Each hands out something that names its owner (a context id, a flow id, a file id, a ticket). The method is
the same for all of them: the first account of a role (A) makes something, and then another account of the same role
(B), a signed-out visitor, A after signing out, and a request with no ticket each use A's ids and tickets.

| Check | What must hold | How the check proves it | ASVS 4.0.3 |
|---|---|---|---|
| Context ownership | A context answers only the account, or the signed-out visit, that opened it. Another caller's discard leaves it alive, and another caller's commit does not commit what A staged. | A opens a context and a child context, and stages a create it does not commit. Every route under `/api/context/{id}/` is then called on both by each of the others: reads, page trees, queries, calls, streams, writes, commit, the function routes, discard. Each must be refused: 401 for a ticket whose session is over or none, a 4xx otherwise. Afterwards A's context must still answer A, and the staged row must not exist. | V4.2.1, V3.5.2 |
| Function hand-over | A function that runs on the server never ships its body to the browser, and a flow's continuation is its owner's. | Every function of your own is asked for its tree. One that runs on the server, or one that reaches outside the platform (its durability is External: an HTTP call, a mail, a model), must be refused. Each of the others, on its own context, resumes, completes and polls a flow it does not own, and hands off a continuation that claims to be the server's. Each must be refused. `complete` answers alike either way, because it drops only the caller's own flows. | V4.2.1, V4.1.1 |
| Live channels | The live socket opens only for a live ticket, and a change frame names a row without carrying it. A log line a page writes is filed under its writer. The anonymous telemetry ingest is bounded. | The socket is opened with an ended ticket and with none, and must be refused (401). A subscribes to an entity; another account creates or changes a row of it, and the frame A hears must carry the type, id and kind and nothing else. The others write log lines to A's context id, naming A's user id and another app's in the payload. Read back as the operator, each line must be in this app's stream and not filed under A. A telemetry body over 2 MB is sent with its length and then chunked, and both must be refused (413). | V8.3.1, V4.1.1, V7.3.3, V11.1.4 |
| File ownership | A fresh upload is its uploader's until it is shared. Only a caller who can read it gets a signed address. | A uploads a file. Its address must serve nobody without a signature. Each of the others must be refused a signature, and the file itself. A's own signed address must serve it. An app that does not use `Osysharp.Storage` stores no files, so this is not applicable there. | V4.2.1, V12.5.1 |
| Log access | An app's log stream is read by its team and by an operator, never by its own users. | `/api/logs` is opened with A's ticket and with each of the others', and must be refused. The local platform's own admin ticket must open it. | V7.3.3, V4.1.3 |

Sessions also checks **resume**: the ticket a resume answers is the same sign-in it was asked with, the same session
and sign-in time. Signing out of it must end the first ticket too (401).

Two things in this area are not applicable on any run, because the check never creates them: another account's
parked flow, and another account's stream run. The report names the guard tests that cover each one. The same goes
for presence.

**Whom a deny covers** is worked out from your declaration, never asked of the instance. That way the code that
enforces a rule is not also the code that grades it. The check follows each `when` guard through your policies down
to role questions: a grant row of your `[RoleGrant]` entity that matches `user` and a member of your `[Role]` enum.
It also understands `IsAuthenticated`, `!`, `&&` and `||`. It then evaluates the guard for each account by the role
that account holds.

A guard that depends on anything else is not guessed. Examples are a hop through an ordinary entity, a parameterised
policy, or a condition on the row (`where`). For those, the rule is reported as **not applicable**, with the reason.

**Verdicts.**

- **conforms**: the instance did what the model says.
- **not applicable**: there was nothing to judge, and the reason is given. Examples: no row of an entity for any
  account to try, a deny that covers none of the accounts, a guard that is not a role question, or HSTS on a
  plain-http loopback instance.
- **MISMATCH**: the instance did something the model does not say. The finding carries the request (method, path,
  account, status) and what the response showed. A mismatch the app's declaration does not explain is a platform
  defect.

The check exits `1` when it finds any mismatch.

**What it does not probe.** Five concerns are covered by the platform's own guard tests for every app, so the check
sends no hostile payloads and runs no request loops for them. The report lists each one with the tests that cover it:

- markup: text an app renders back cannot become markup or script (ASVS V5.3.3)
- filter handling: a client's filter and sort order cannot become SQL (V5.3.4)
- the sign-in rate limit (V2.2.1)
- sign-in enumeration: an unknown account and a wrong password answer alike, in the same time (V2.2.1, V3.2.3)
- uploads: what a visitor uploads is stored inert and served only as a download (V12.2.1, V12.5.2)

**HSTS.** The check's instance serves plain http on loopback, and a browser ignores `Strict-Transport-Security`
there (RFC 6797 §7.2). So HSTS is always reported as not applicable. The platform itself sends
`Strict-Transport-Security: max-age=31536000; includeSubDomains` on every https response from `osyrin.app` and its
subdomains (Admin and every app), and `max-age=31536000` alone on a customer's custom domain: `includeSubDomains` would
bind the customer's other subdomains, which the platform does not serve. It sends nothing over plain http.

**Admin.** Admin's source compiles only into a platform's own Admin, so for Admin the check starts a platform of its
own (`<instance>-access`), from empty, and discards it when it ends. Tenant isolation is not applicable there: a
platform holds one Admin.

**How long it takes.** Measured on 2026-10-19 on a 16 GB laptop, with the client-runtime checks included:
`demo/auth-demo` 19 s and `kits/work-queues` 25–51 s, each including its platform's start. Admin, which always starts
its own platform, took 174–177 s.

**A kit.** Run in a package directory, the check uses the package's own tests app, as `osy test` does.

`--json` writes the report to standard output as one JSON document: the verdict, the counts, the accounts, every
finding with its request, and the concerns covered elsewhere. Progress goes to standard error.

## Examples       {#examples}

```console
osy check --access                    # the access check, recorded in osyrin.json
osy check --access --json > access.json
osy check --access --seed AnEditorAndAMember
osy check --access kits/my-kit        # a kit, through its tests app
```

What `osy compile` says after the source changed under a passing run:

```text
✓ Osyrin compiled and applied to Todo (2 file(s)).
  ⓘ Todo is at http://todo.localhost:8136/
  ⓘ The source has changed since the access check last ran (2026-10-21 04:17 UTC) — run `osy check --access` on this source.
```

A mismatch reads like this:

```text
✗ Denied writes — a write to a field under `deny update` is refused and changes nothing
  MISMATCH Osysharp.Identity.Principal.Source · Member-b (V4.1.2, V5.1.2)
    `deny update Source` covers Member-b; the row did not change, but the write was ACCEPTED (200) and dropped in
    silence instead of refused — a client is told it saved something it did not.
    POST /api/context/…/commit as Member-b → 200 — {"success":true,"committedEntityTypes":[]}
```

## See also       {#see-also}

[Checking your app](https://osysharp.com/reference/local/checking-your-app/): `osy lint`, the rules a production app should meet, read from source.

[Explaining your app's security](https://osysharp.com/reference/local/explaining-your-app/): who can do what, in plain English, from the same declaration.

[security { }](https://osysharp.com/reference/security/entity-security/): how to declare an entity's access rules, which this check holds the instance to.

[auth bootstrap (login, before anyone is signed in)](https://osysharp.com/reference/security/auth-bootstrap/): which `[AuthMethod]`s a signed-out visitor may call.
