Skip to content

refactor(server): every route states the authority it demands, or why it demands none #7036

Description

@otavio

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 SSHIdentityAdd or SSHIdentityManage 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. As an auditor, I want DeleteSSHIdentity to report the two permissions it accepts, so that the
    route table stops disagreeing with the code.
  6. As an auditor, I want every NoPermission reason to be a sentence someone wrote, so that a route
    cannot become permissionless by copy-paste.
  7. 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.
  8. As a client signing in against POST /api/auth/user, I want the same.
  9. 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.
  10. 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.
  11. 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  • Collapsing the anonymous allowlist into the route table. Deferred by Tracking: converting the route table to pure handlers #7032; the allowlist must
    survive for routes that never reach the gateway.
  • 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.
  • Ordering. This rung depends only on refactor(server): make route registration and declaration the same act #7030's option shape. It does not touch cloud/ and so
    does not wait on shellhub-io/cloud#2534.
  • 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions