Skip to content

fix(erasure): an erased subject cannot write where SQL cannot see it - #86

Open
42-v wants to merge 2 commits into
mainfrom
fix/erasure-write-guard
Open

fix(erasure): an erased subject cannot write where SQL cannot see it#86
42-v wants to merge 2 commits into
mainfrom
fix/erasure-write-guard

Conversation

@42-v

@42-v 42-v commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Follow-up to #82, which closed PUT /user/profile by making auth.users refuse the write itself. The other subject-writing routes cannot be closed that way, and the reason is the point:

identity.profiles is keyed by an unlinkable pseudonym — no user_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/identity recreated 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, and Auth deliberately never reads the database.

What this does

RequireLiveAccount is a lookup, applied to the five routes that persist subject-owned data and to nothing else:

Route Guard
PUT /user/identity authedLive
POST /user/blobs authedLive
PUT /user/blobs/named/{name} authedLive
POST /auth/2fa/totp/setup confirmedLive
POST /auth/2fa/backup-codes confirmedLive

Read routes keep the stateless fast path that self-contained tokens exist to provide. PUT /user/profile stays 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 guardComposes map precisely so a new guard's name cannot be trusted on its own:

  • OWASP A01 (authGuards + guardComposes) — verifies the closure really composes authMw
  • NIST IA-11 — verifies the confirmation guard
  • DPoP route wiring (dpopWrapperIdents + routeBuilderClosures) — verifies both route and closure body

Two register rationales cited server.go by line and this change moved both; they now point at the statements they always meant (:546:559, :847:861).

Mutation-verified: swapping authedLive back to authed on 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 -race on middleware/server/handler, tests/spec and tests/compliance both green, golangci-lint run ./... 0 issues on a cleaned cache, misspell clean.

fix(erasure): an erased subject cannot write where SQL cannot see it

42-v added 2 commits August 27, 2026 00:35
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
42-v force-pushed the fix/erasure-write-guard branch from abdf105 to 9c206fc Compare August 26, 2026 22:35
@42-v
42-v enabled auto-merge (squash) August 26, 2026 22:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant