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#
osy check --access [path] [--json] [--seed <seed>]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:
- It loads one of your
[DevSeed]fixtures. By default this is the last one your tests declare;--seedpicks another. - It makes two accounts for every member of your
[Role]enum and signs each one in. - 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#
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 appWhat osy compile says after the source changed under a passing run:
✓ 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:
✗ 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#
Checking your app: osy lint, the rules a production app should meet, read from source.
Explaining your app's security: who can do what, in plain English, from the same declaration.
security { }: how to declare an entity's access rules, which this check holds the instance to.
auth bootstrap (login, before anyone is signed in): which [AuthMethod]s a signed-out visitor may call.