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.
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.
TestAnOperationThatDeclaresAScopeRunsForWhoeverWasGrantedIt
(internal/server/scope_test.go:65) — the path that was unreachable before now
runs.TestACallerCannotGrantItselfAScope (scope_test.go:153) and
TestProvingTheServerSecretIsNotTheSameAsHoldingAScope
(scope_test.go:240) — operator proof and scope are different things.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.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.Client.Present (sapedb.go:136), pinned by
TestTheSurfaceIsExactlyTheseThirtyThreeNames (sapedb_test.go:44) —
Present is the thirty-third name.exp changes the signed message.scopes field on anything a caller can
read back. A scope may contain : and may not contain ,.