← 1.0.0 scope SAPE-21

Scopes travel the wire as a signed grant

Status
Done
Version
1.0.0
Component
server, signing, clients
Commits
40793e4
Opened
ISS-11, ISS-12

Description

An operation has been able to declare scopes for as long as there have been operations, and store.allowed has checked them fail-closed the whole time — but nothing on the wire could ever present one. internal/server built its store.Caller with no Scopes at all. So an operation that declared a scope was an operation nobody could call: a field that refused everything and permitted nothing, which is not a permission system, it is a way of disabling an operation by mentioning a word.

What a caller sends now is not a list of scopes. It is a list and a signature over it, made with a key derived from the server's own secret under a label of its own, signing.GrantLabel = "sapedb/scopes:v1" (internal/signing/signing.go:228), so a grant and a connection string can never be presented as each other. The signed message is account_id ":" dbname ":" scope[,scope...], sorted and de-duplicated. Consequently a caller cannot mint one, cannot edit the list under a real signature, and cannot present one minted for another account or another database on the same server.

On the wire it is an optional grant object on the invoke payload: {"scopes": [...], "sig": "..."} (internal/server/server.go:667). Sending none is sending no scopes, so every existing client keeps working unchanged with exactly the permissions it had. Verification is Server.granted (server.go:833); minting is Server.Grant (server.go:1057), which sits next to Sign and is not reachable from the wire.

Why it was in 1.0.0

The signed message is a public surface. Once a tag exists, changing what bytes go into that HMAC is a compatibility break for every signer in every language. And a scope check that has never run end to end is a security mechanism nobody can say works: the check was fail-closed and therefore untested, which is the quietest kind of untested there is.

How it was verified

  1. TestAnOperationThatDeclaresAScopeRunsForWhoeverWasGrantedIt (internal/server/scope_test.go:65) — the path that was unreachable before now runs.
  2. TestACallerCannotGrantItselfAScope (scope_test.go:153) and TestProvingTheServerSecretIsNotTheSameAsHoldingAScope (scope_test.go:240) — operator proof and scope are different things.
  3. TestComposingIsNotAWayRoundAScopeOverTheWire (scope_test.go:294). The scope-union rule SAPE-20 landed with was half-measured: in-process it was already true that a parent must ask for every scope its callees ask for, but the other half — "and then a caller that does not hold it is refused at call time" — had never been observed, because nothing could hold one. Both halves now run on one connection.
  4. A grant that does not verify refuses the call with code grant, not not_allowed (server.go:1106): a bad credential is a different thing from a missing permission, and a caller whose grant was issued for the wrong database must not be told "you need articles:read" about a grant that says articles:read.
  5. Go client: Client.Present (sapedb.go:136), pinned by TestTheSurfaceIsExactlyTheseThirtyThreeNames (sapedb_test.go:44) — Present is the thirty-third name.

What it did not do