fix(erasure): an erased subject cannot write where SQL cannot see it - #86
Open
42-v wants to merge 2 commits into
Open
fix(erasure): an erased subject cannot write where SQL cannot see it#8642-v wants to merge 2 commits into
42-v wants to merge 2 commits into
Conversation
PUT /user/profile was closed by making auth.users refuse the write itself. The other subject-writing routes cannot be closed that way, and this is why: identity.profiles is keyed by an unlinkable pseudonym with no user_id and no foreign key, which is exactly what makes it pseudonymous and also what makes the database blind to the connection. Blobs are the same shape, and the MFA setup routes never load the user row at all. So PUT /user/identity recreated a name, a date of birth, a billing address and a VAT id on a subject the erasure had scrubbed, and nothing in the request path knew: the handler does not resolve the user, and Auth deliberately never reads the database. RequireLiveAccount is a lookup, applied to the five routes that persist subject-owned data and to nothing else. Read routes keep the stateless fast path that self-contained tokens exist to provide, and PUT /user/profile stays out of it because a second guard there would cost a round trip while hiding where the real invariant lives. It fails closed: a lookup error is a 401, because a database that cannot answer is not permission to write personal data back. Three existing gates had to learn the two new wrapper names, and each was worth the edit rather than a nuisance. The OWASP A01 gate keeps a guardComposes map precisely so a new guard's name cannot be trusted on its own; the NIST IA-11 gate checks the confirmation guard; the DPoP wiring gate checks both the route and the closure body. All three now cover the -Live pair, and the closure checks are what hold them to composing what they claim. Two register rationales cited server.go by line and this change moved both. They now point at the statements they always meant. The route gate is mutation-verified: swapping authedLive back to authed on one route fails it by name. It reads comment-free source, because the liveness meta-gate is right that a construct appearing only in a comment would otherwise satisfy the assertion.
42-v
force-pushed
the
fix/erasure-write-guard
branch
from
August 26, 2026 22:35
abdf105 to
9c206fc
Compare
42-v
enabled auto-merge (squash)
August 26, 2026 22:35
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #82, which closed
PUT /user/profileby makingauth.usersrefuse the write itself. The other subject-writing routes cannot be closed that way, and the reason is the point:identity.profilesis keyed by an unlinkable pseudonym — nouser_id, no foreign key. That is exactly what makes it pseudonymous, and also what makes the database blind to the connection. Blobs are the same shape, and the MFA setup routes never load the user row at all.So
PUT /user/identityrecreated a name, date of birth, billing address and VAT id on a subject the erasure had scrubbed, and nothing in the request path knew: the handler does not resolve the user, andAuthdeliberately never reads the database.What this does
RequireLiveAccountis a lookup, applied to the five routes that persist subject-owned data and to nothing else:PUT /user/identityauthedLivePOST /user/blobsauthedLivePUT /user/blobs/named/{name}authedLivePOST /auth/2fa/totp/setupconfirmedLivePOST /auth/2fa/backup-codesconfirmedLiveRead routes keep the stateless fast path that self-contained tokens exist to provide.
PUT /user/profilestays out of it deliberately — a second guard there would cost a round trip while hiding where the real invariant lives. It fails closed: a lookup error is a 401, because a database that cannot answer is not permission to write personal data back onto a possibly-erased subject.Three existing gates had to learn the new wrapper names
Each was worth the edit rather than a nuisance, and the repo had already anticipated it — the OWASP A01 gate keeps a
guardComposesmap precisely so a new guard's name cannot be trusted on its own:authGuards+guardComposes) — verifies the closure really composesauthMwdpopWrapperIdents+routeBuilderClosures) — verifies both route and closure bodyTwo register rationales cited
server.goby line and this change moved both; they now point at the statements they always meant (:546→:559,:847→:861).Mutation-verified: swapping
authedLiveback toauthedon one route fails the new gate by name. It reads comment-free source, because the liveness meta-gate is right that a construct appearing only in a comment would otherwise satisfy the assertion.Verified:
go build ./...,go vet ./...,go test -raceon middleware/server/handler,tests/specandtests/complianceboth green,golangci-lint run ./...0 issues on a cleaned cache, misspell clean.