You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The A′ rung of #7032, and the last of the three claims a route makes.
Problem Statement
CONTEXT.md already states the rule:
Breadth, anonymity and authority are never left implicit: a route states each, or states why it
does not need to.
Two thirds of that is true. gateway.Unbounded(reason) and gateway.Anonymous(reason) each take a
required argument, so breadth and anonymity cannot arrive by omission, and route_table_test.go
refuses a reason left blank. Authority has no such option and no such check: 46 of the 94 route
registrations carry no gateway.Requires, and nothing in the route table asks why. The glossary is
ahead of the code.
Most of the 46 are fine — a login cannot demand a credential, leaving a namespace needs no authority
over anyone else, a device token carries no role at all. But a reader cannot tell those from the
ones where the check merely moved somewhere the route table cannot see:
DELETE /api/ssh-identities/:id registers bare while its two siblings require SSHIdentityAdd.
It is guarded — the handler checks for SSHIdentityAddorSSHIdentityManage and 403s, then
passes manage down so the service can decide the ownership half. The behaviour is correct and the
route table under-reports it, because gateway.Requires expresses one permission and this route's
rule is two.
POST /api/devices/pairing/:code/accept, POST /api/ssh-approvals/:code/confirm and POST /api/ssh-approvals/:code/reject decide in the service, because the namespace they act on
comes from the code in the path and not from the caller's session.
GET /api/ssh-identities silently narrows ?all=true for a caller without SSHIdentityManage.
Each of those is a deliberate decision with nowhere to be written down. An auditor reading Declarations() sees an unguarded DELETE.
Separately, the audit that produced this issue found one route pair that is simply broken. POST /api/auth/device and POST /api/auth/user — the v2 spellings of device authentication and user
login — declare no anonymity and are absent from the allowlist. The authenticator fails closed, so
they answer 401 to the very clients they exist for. anonymityMismatches does not catch it: it
fires on a claim without an allowlist entry, or an entry without a claim, but a route carrying neither reads as consistent.
Solution
A route states the authority it demands, or states why it demands none, and a test refuses the
omission — the same shape Unbounded and Anonymous already have.
Three new ways to say it, because the 46 do not share one reason:
gateway.RequiresAny(permissions...) — the route admits a caller holding any one of several
permissions. Like Requires, it declares and enforces in one act, so the claim is evidence
rather than description. This is what lets DeleteSSHIdentity's guard move out of the handler
body and back onto the line that mounts it.
gateway.PermissionInHandler(reason) — the admission decision needs request data the
middleware cannot see: a namespace named by a code in the path, a query parameter that widens the
answer. The check stays where it is; the route stops pretending it has none.
gateway.NoPermission(reason) — the route genuinely demands no authority. Self-service acts
on the caller's own record, acts prior to holding any role, callers whose credential is a device
token that carries no role at all.
Anonymous discharges the authority claim on its own: a route with no actor has no role, so there
is nothing for a permission to be checked against. The eleven anonymous routes need no second
sentence.
Then the invariant, as a predicate over Declarations() with a known-bad companion, exactly like the
seven already there: every route names a permission, names several, says the handler decides, or
says it needs none.
User Stories
As a reviewer of a security-sensitive PR, I want to read the route table and see what every route
demands of its caller, so that "this DELETE has no permission" is a fact rather than a question I
have to answer by opening the handler.
As an engineer adding a route, I want the build to fail until I have stated its authority, so that
forgetting is not one of the outcomes.
As an engineer whose route's rule is two permissions, I want to say so on the mounting line, so
that I am not forced to choose between an inexpressible claim and no claim.
As an engineer whose route decides admission from the request body, I want to record that fact,
so that the next reader knows the omission was reasoned.
As an auditor, I want DeleteSSHIdentity to report the two permissions it accepts, so that the
route table stops disagreeing with the code.
As an auditor, I want every NoPermission reason to be a sentence someone wrote, so that a route
cannot become permissionless by copy-paste.
As a device enrolling against a current server, I want POST /api/auth/device to answer, so that
the v2 endpoint is not a 401.
As a client signing in against POST /api/auth/user, I want the same.
As a maintainer, I want the anonymity check to fail a route that claims nothing and is allowlisted
nowhere, so that the class of bug in stories 7 and 8 cannot recur silently.
As a reviewer, I want the new predicate to have a companion test fed a known-bad table, so that a
green run means the predicate looked rather than that it found nothing.
As a maintainer of cloud/, I want the vocabulary to exist before the /admin/api surface is
reviewed, so that A′-cloud is a decision about policy and not about mechanism.
Implementation Decisions
The three options
All three follow the established RouteOption shape: record on the Declaration, return the guard
that enforces the claim, or nil when nothing is enforced.
RequiresAny(permissions ...authorizer.Permission) records the set and returns a guard that admits
a role holding any of them, 403 otherwise — the same status and the same bodiless response as RequiresPermission. Passing a single permission is legal but pointless; passing none is a
programming error the route-table test should catch rather than a silent admit-all.
PermissionInHandler(reason string) and NoPermission(reason string) record a flag and a reason
and install nothing. Reason is a required argument, per the Unbounded/Anonymous precedent.
The Declaration gains the fields to carry them. Permission keeps its existing RequiresPermission discriminator, because the zero permission value is a real one; the new fields
need the same treatment for the same reason.
Whether these are four separate booleans or one enumerated Authority field with a reason is an
implementation choice — the constraint is that the predicate can tell "claimed nothing" from every
kind of "claimed deliberately", and that a reason cannot be blank.
Classification of the 46
Every one of the 46 is assigned below. The reasons are drafted, not final: reviewing them is this
issue's work, and any one of them may be wrong.
RequiresAny — 1 route. Enforcement moves from the handler body to the mounting line; the 403 is
unchanged.
Route
Claim
DELETE /api/ssh-identities/:id
RequiresAny(SSHIdentityAdd, SSHIdentityManage)
The handler keeps deriving manage for the service, which decides the ownership half — that is a
data decision, not an admission one, and it stays.
PermissionInHandler — 7 routes.
Route
Proposed reason
GET /api/ssh-identities
the listing narrows to the caller's own identities without SSHIdentityManage, so the permission widens the answer rather than admitting the call
POST /api/devices/pairing/:code/accept
the namespace comes from the pairing code, not the session, so the permission is checked against a namespace the middleware cannot know
POST /api/ssh-approvals/:code/confirm
as above: the approval names the namespace
POST /api/ssh-approvals/:code/reject
as above
GET /api/namespaces/:tenant
the handler admits a member of the named namespace, which is a membership question and not a role one
GET /api/auth/token/:tenant
the service issues a token only for a namespace the caller is already a member of
GET /api/auth/user (the token-minting GET, distinct from the POST login on the same path) takes
the same reason as the last row.
NoPermission — 25 routes, in four groups.
Self-service — the caller is the target:PATCH /api/users, PATCH /api/users/:id/data, PATCH /api/users/:id/password, PATCH /api/namespaces/:tenant/invitations/accept, DELETE /api/namespaces/:tenant/members (leaving), POST /api/namespaces (creating one is prior to
holding a role in one), POST /api/web-terminal/reauth, GET /api/users/invitations.
The credential carries no role:POST /api/devices/auth/code, GET /api/devices/auth/status (both
read DeviceUID() and 401 for themselves), POST /api/auth/ssh (the SSH gateway checking an offered
key), GET /api/devices/login-code/:code, GET /api/ssh-approvals/:code.
Reads the namespace scope already bounds:GET /api/devices, GET /api/devices/:uid, GET /api/devices/resolve, GET /api/sessions, GET /api/sessions/:uid, GET /api/stats, GET /api/tags, GET /api/namespaces/:tenant/tags, GET /api/sshkeys/public-keys, GET /api/namespaces/api-key, GET /api/namespaces/:tenant/members.
Answers about the caller across namespaces:GET /api/namespaces (already Unbounded).
Anonymous, already claimed — 11 routes, needing nothing further: the healthcheck, POST /api/devices/auth, POST /api/devices/enroll/callback/:token, POST /api/login, POST /api/register, GET /api/invitations/resolve, POST /api/devices/pairing, GET /api/devices/pairing/:code/status, GET /api/info, GET /api/install, POST /api/setup.
Anonymous, missing — 2 routes.POST /api/auth/device and POST /api/auth/user take the same
reasons as their v1 spellings and are added to the allowlist. This is the bug fix.
The anonymity check gains a direction
anonymityMismatches compares a claim against the allowlist in both directions but treats "neither"
as agreement. It should refuse a route that claims no anonymity, is in no allowlist, and whose
authority claim says it expects no credential — which is what would have caught the v2 pair. The
exact predicate is an implementation question; the requirement is that stories 7 and 8 cannot recur.
What does not change
No route gains or loses a permission a caller can observe. RequiresAny on the SSH identity delete
answers 403 to exactly the callers the handler answers 403 to today. Every other change is a
declaration, a reason, or an allowlist entry.
Testing Decisions
Four seams, all of them already load-bearing in this package. The invariant is the primary one.
The invariant — a free function over []gateway.Declaration returning a slice of complaints,
asserted empty in TestRouteTableHoldsItsClaims, with a companion fed a hand-written known-bad
table proving it bites. This is the unstatedClaims pattern verbatim, including the
complaint-as-a-sentence style ("DELETE /api/x demands no permission and states no reason"). It
must reject both a route that claimed nothing and a claim whose reason is blank or whitespace.
Verify it fires against the real route table once, by deleting a claim, before trusting a green
run.
The mechanism — RequiresAny, once, through a mounted route in the gateway's own tests,
mirroring TestRequiresDeclaresThePermitItEnforces: a role holding the first permission passes, a
role holding the second passes, a role holding neither gets 403, and the declaration reports the
set it enforces. Declaring and enforcing must be provably the same act, or the option is worth
less than the comment it replaces.
The wiring, for the one route whose enforcement moved — DELETE /api/ssh-identities/:id
through HTTP, with a role holding neither permission, asserting 403. There is no handler test for
this guard today; the rule is covered only at the service layer. Moving an unguarded-by-test check
is how a regression gets in.
The reachability fix — the v2 auth routes reachable without a credential, which TestAnonymousRouteReachableWithoutCredential already has the shape for, plus the tightened anonymityMismatches and its companion.
Go, mockery where a service is involved, no build tags. Run in-container: a PR based on a feature
branch runs zero checks in this repository, because the workflows are gated on master.
Out of Scope
A′-cloud — the 32 /admin/api routes, none of which declares a permission and all of which
are guarded only by X-Admin. Tracked in shellhub-io/team#238. This issue builds the vocabulary
that one will use.
Requiring DeviceDetails / SessionDetails on the reads that have a matching permission.
Tempting, and behaviour-neutral for every human role — but RoleService holds zero permissions, so
it would start 403ing service accounts on routes they can call today. That is a policy tightening
and wants its own review. Recorded in Further Notes.
The legacy Authorize guard. Six routes carry Guard(routesmiddleware.Authorize), which reads
like authorization at the call site and only asserts that a request carrying an ID also carries a
tenant. Deciding its fate is a behaviour question, deferred by Tracking: converting the route table to pure handlers #7032.
The req.Manage pattern and the silent req.All downgrade — authorization decided from
request data inside services. PermissionInHandler records that it happens; changing it is
deferred by Tracking: converting the route table to pure handlers #7032.
GET /api/auth/token/:tenant writing the user's preferred namespace. A GET with a side effect,
noticed while auditing. Real, unrelated, not this issue.
Further Notes
CreateUserToken was investigated as a possible privilege escalation and is not one. Its UserID binds from X-ID, which the authenticator clears and rewrites on every request, and the
service refuses a caller who is not a member of the named namespace. It mints a token for the
caller, scoped to a namespace they already belong to. Its doc comment claims it is "reachable only
internally", which nothing enforces now that authentication moved in-process — worth a follow-up,
but not a boundary crossing.
CONTEXT.md needs no new vocabulary — it already asserts this rule. It may need the three new
option names under Route declaration.
The Unbounded/Anonymous commit bodies are the design rationale worth reading first: a required
argument makes a reason impossible to omit, and only an inventory read back by a test makes an
empty one impossible to merge.
The
A′rung of #7032, and the last of the three claims a route makes.Problem Statement
CONTEXT.mdalready states the rule:Two thirds of that is true.
gateway.Unbounded(reason)andgateway.Anonymous(reason)each take arequired argument, so breadth and anonymity cannot arrive by omission, and
route_table_test.gorefuses a reason left blank. Authority has no such option and no such check: 46 of the 94 route
registrations carry no
gateway.Requires, and nothing in the route table asks why. The glossary isahead of the code.
Most of the 46 are fine — a login cannot demand a credential, leaving a namespace needs no authority
over anyone else, a device token carries no role at all. But a reader cannot tell those from the
ones where the check merely moved somewhere the route table cannot see:
DELETE /api/ssh-identities/:idregisters bare while its two siblings requireSSHIdentityAdd.It is guarded — the handler checks for
SSHIdentityAddorSSHIdentityManageand 403s, thenpasses
managedown so the service can decide the ownership half. The behaviour is correct and theroute table under-reports it, because
gateway.Requiresexpresses one permission and this route'srule is two.
POST /api/devices/pairing/:code/accept,POST /api/ssh-approvals/:code/confirmandPOST /api/ssh-approvals/:code/rejectdecide in the service, because the namespace they act oncomes from the code in the path and not from the caller's session.
GET /api/ssh-identitiessilently narrows?all=truefor a caller withoutSSHIdentityManage.Each of those is a deliberate decision with nowhere to be written down. An auditor reading
Declarations()sees an unguarded DELETE.Separately, the audit that produced this issue found one route pair that is simply broken.
POST /api/auth/deviceandPOST /api/auth/user— the v2 spellings of device authentication and userlogin — declare no anonymity and are absent from the allowlist. The authenticator fails closed, so
they answer 401 to the very clients they exist for.
anonymityMismatchesdoes not catch it: itfires on a claim without an allowlist entry, or an entry without a claim, but a route carrying
neither reads as consistent.
Solution
A route states the authority it demands, or states why it demands none, and a test refuses the
omission — the same shape
UnboundedandAnonymousalready have.Three new ways to say it, because the 46 do not share one reason:
gateway.RequiresAny(permissions...)— the route admits a caller holding any one of severalpermissions. Like
Requires, it declares and enforces in one act, so the claim is evidencerather than description. This is what lets
DeleteSSHIdentity's guard move out of the handlerbody and back onto the line that mounts it.
gateway.PermissionInHandler(reason)— the admission decision needs request data themiddleware cannot see: a namespace named by a code in the path, a query parameter that widens the
answer. The check stays where it is; the route stops pretending it has none.
gateway.NoPermission(reason)— the route genuinely demands no authority. Self-service actson the caller's own record, acts prior to holding any role, callers whose credential is a device
token that carries no role at all.
Anonymousdischarges the authority claim on its own: a route with no actor has no role, so thereis nothing for a permission to be checked against. The eleven anonymous routes need no second
sentence.
Then the invariant, as a predicate over
Declarations()with a known-bad companion, exactly like theseven already there: every route names a permission, names several, says the handler decides, or
says it needs none.
User Stories
demands of its caller, so that "this DELETE has no permission" is a fact rather than a question I
have to answer by opening the handler.
forgetting is not one of the outcomes.
that I am not forced to choose between an inexpressible claim and no claim.
so that the next reader knows the omission was reasoned.
DeleteSSHIdentityto report the two permissions it accepts, so that theroute table stops disagreeing with the code.
NoPermissionreason to be a sentence someone wrote, so that a routecannot become permissionless by copy-paste.
POST /api/auth/deviceto answer, so thatthe v2 endpoint is not a 401.
POST /api/auth/user, I want the same.nowhere, so that the class of bug in stories 7 and 8 cannot recur silently.
green run means the predicate looked rather than that it found nothing.
cloud/, I want the vocabulary to exist before the/admin/apisurface isreviewed, so that
A′-cloudis a decision about policy and not about mechanism.Implementation Decisions
The three options
All three follow the established
RouteOptionshape: record on theDeclaration, return the guardthat enforces the claim, or
nilwhen nothing is enforced.RequiresAny(permissions ...authorizer.Permission)records the set and returns a guard that admitsa role holding any of them, 403 otherwise — the same status and the same bodiless response as
RequiresPermission. Passing a single permission is legal but pointless; passing none is aprogramming error the route-table test should catch rather than a silent admit-all.
PermissionInHandler(reason string)andNoPermission(reason string)record a flag and a reasonand install nothing. Reason is a required argument, per the
Unbounded/Anonymousprecedent.The
Declarationgains the fields to carry them.Permissionkeeps its existingRequiresPermissiondiscriminator, because the zero permission value is a real one; the new fieldsneed the same treatment for the same reason.
Whether these are four separate booleans or one enumerated
Authorityfield with a reason is animplementation choice — the constraint is that the predicate can tell "claimed nothing" from every
kind of "claimed deliberately", and that a reason cannot be blank.
Classification of the 46
Every one of the 46 is assigned below. The reasons are drafted, not final: reviewing them is this
issue's work, and any one of them may be wrong.
RequiresAny— 1 route. Enforcement moves from the handler body to the mounting line; the 403 isunchanged.
DELETE /api/ssh-identities/:idRequiresAny(SSHIdentityAdd, SSHIdentityManage)The handler keeps deriving
managefor the service, which decides the ownership half — that is adata decision, not an admission one, and it stays.
PermissionInHandler— 7 routes.GET /api/ssh-identitiesSSHIdentityManage, so the permission widens the answer rather than admitting the callPOST /api/devices/pairing/:code/acceptPOST /api/ssh-approvals/:code/confirmPOST /api/ssh-approvals/:code/rejectGET /api/namespaces/:tenantGET /api/auth/token/:tenantGET /api/auth/user(the token-minting GET, distinct from thePOSTlogin on the same path) takesthe same reason as the last row.
NoPermission— 25 routes, in four groups.Self-service — the caller is the target:
PATCH /api/users,PATCH /api/users/:id/data,PATCH /api/users/:id/password,PATCH /api/namespaces/:tenant/invitations/accept,DELETE /api/namespaces/:tenant/members(leaving),POST /api/namespaces(creating one is prior toholding a role in one),
POST /api/web-terminal/reauth,GET /api/users/invitations.The credential carries no role:
POST /api/devices/auth/code,GET /api/devices/auth/status(bothread
DeviceUID()and 401 for themselves),POST /api/auth/ssh(the SSH gateway checking an offeredkey),
GET /api/devices/login-code/:code,GET /api/ssh-approvals/:code.Reads the namespace scope already bounds:
GET /api/devices,GET /api/devices/:uid,GET /api/devices/resolve,GET /api/sessions,GET /api/sessions/:uid,GET /api/stats,GET /api/tags,GET /api/namespaces/:tenant/tags,GET /api/sshkeys/public-keys,GET /api/namespaces/api-key,GET /api/namespaces/:tenant/members.Answers about the caller across namespaces:
GET /api/namespaces(alreadyUnbounded).Anonymous, already claimed — 11 routes, needing nothing further: the healthcheck,POST /api/devices/auth,POST /api/devices/enroll/callback/:token,POST /api/login,POST /api/register,GET /api/invitations/resolve,POST /api/devices/pairing,GET /api/devices/pairing/:code/status,GET /api/info,GET /api/install,POST /api/setup.Anonymous, missing — 2 routes.POST /api/auth/deviceandPOST /api/auth/usertake the samereasons as their v1 spellings and are added to the allowlist. This is the bug fix.
The anonymity check gains a direction
anonymityMismatchescompares a claim against the allowlist in both directions but treats "neither"as agreement. It should refuse a route that claims no anonymity, is in no allowlist, and whose
authority claim says it expects no credential — which is what would have caught the v2 pair. The
exact predicate is an implementation question; the requirement is that stories 7 and 8 cannot recur.
What does not change
No route gains or loses a permission a caller can observe.
RequiresAnyon the SSH identity deleteanswers 403 to exactly the callers the handler answers 403 to today. Every other change is a
declaration, a reason, or an allowlist entry.
Testing Decisions
Four seams, all of them already load-bearing in this package. The invariant is the primary one.
[]gateway.Declarationreturning a slice of complaints,asserted empty in
TestRouteTableHoldsItsClaims, with a companion fed a hand-written known-badtable proving it bites. This is the
unstatedClaimspattern verbatim, including thecomplaint-as-a-sentence style ("
DELETE /api/xdemands no permission and states no reason"). Itmust reject both a route that claimed nothing and a claim whose reason is blank or whitespace.
Verify it fires against the real route table once, by deleting a claim, before trusting a green
run.
RequiresAny, once, through a mounted route in the gateway's own tests,mirroring
TestRequiresDeclaresThePermitItEnforces: a role holding the first permission passes, arole holding the second passes, a role holding neither gets 403, and the declaration reports the
set it enforces. Declaring and enforcing must be provably the same act, or the option is worth
less than the comment it replaces.
DELETE /api/ssh-identities/:idthrough HTTP, with a role holding neither permission, asserting 403. There is no handler test for
this guard today; the rule is covered only at the service layer. Moving an unguarded-by-test check
is how a regression gets in.
TestAnonymousRouteReachableWithoutCredentialalready has the shape for, plus the tightenedanonymityMismatchesand its companion.Go, mockery where a service is involved, no build tags. Run in-container: a PR based on a feature
branch runs zero checks in this repository, because the workflows are gated on
master.Out of Scope
A′-cloud— the 32/admin/apiroutes, none of which declares a permission and all of whichare guarded only by
X-Admin. Tracked in shellhub-io/team#238. This issue builds the vocabularythat one will use.
DeviceDetails/SessionDetailson the reads that have a matching permission.Tempting, and behaviour-neutral for every human role — but
RoleServiceholds zero permissions, soit would start 403ing service accounts on routes they can call today. That is a policy tightening
and wants its own review. Recorded in Further Notes.
Authorizeguard. Six routes carryGuard(routesmiddleware.Authorize), which readslike authorization at the call site and only asserts that a request carrying an ID also carries a
tenant. Deciding its fate is a behaviour question, deferred by Tracking: converting the route table to pure handlers #7032.
req.Managepattern and the silentreq.Alldowngrade — authorization decided fromrequest data inside services.
PermissionInHandlerrecords that it happens; changing it isdeferred by Tracking: converting the route table to pure handlers #7032.
survive for routes that never reach the gateway.
GET /api/auth/token/:tenantwriting the user's preferred namespace. A GET with a side effect,noticed while auditing. Real, unrelated, not this issue.
Further Notes
CreateUserTokenwas investigated as a possible privilege escalation and is not one. ItsUserIDbinds fromX-ID, which the authenticator clears and rewrites on every request, and theservice refuses a caller who is not a member of the named namespace. It mints a token for the
caller, scoped to a namespace they already belong to. Its doc comment claims it is "reachable only
internally", which nothing enforces now that authentication moved in-process — worth a follow-up,
but not a boundary crossing.
cloud/and sodoes not wait on shellhub-io/cloud#2534.
CONTEXT.mdneeds no new vocabulary — it already asserts this rule. It may need the three newoption names under Route declaration.
Unbounded/Anonymouscommit bodies are the design rationale worth reading first: a requiredargument makes a reason impossible to omit, and only an inventory read back by a test makes an
empty one impossible to merge.