Skip to content

fix(admin): revoke reaches an admin who created another admin - #89

Open
42-v wants to merge 2 commits into
mainfrom
fix/admin-revoke-creator
Open

fix(admin): revoke reaches an admin who created another admin#89
42-v wants to merge 2 commits into
mainfrom
fix/admin-revoke-creator

Conversation

@42-v

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

Copy link
Copy Markdown
Owner

POST /admin/admins/{id}/revoke could not revoke any admin who had opened another account. The account survived, and so did its live sessions.

auth.admin_users.created_by referenced its own table with no ON DELETE clause, so NO ACTION applied and deleting a row another row still named raised 23503. AdminUserRepo.Revoke is a bare DELETE, so the whole statement failed. admin_sessions cascades — but only when the parent delete succeeds, and it did not, so the sessions the revoke exists to kill stayed open.

created_by is set on every create and migration 016 raises when it is null once any admin exists, so the admin graph is a tree. The bootstrap super_admin became unrevokable the moment it opened one other account — which is the first thing an operator does.

Why this outranks an ordinary 500: revoke is the only containment lever the admin plane has. router.go:125-128 is the entire surface — list, create, revoke. No admin update, no admin lock, no per-admin-session revoke. An admin whose credentials were known to be compromised could not be stopped at all.

The fix

Migration 042 makes the constraint ON DELETE SET NULL. Deliberately not CASCADE — that would delete the revoked admin's entire created subtree, so revoking one compromised account would silently remove every account it had ever opened. Worse than the bug.

The handler now:

  • reads the row before deleting, and writes the old created_by into the audit metadata, so "who authorized this account" survives the SET NULL
  • audits the failure path. Returning 500 before the audit call left no trace of the one event an operator most needs to see: a containment attempt that did not work.

Coverage

The existing repo test passes on the unfixed code because it only ever revokes a leaf (target.CreatedBy = admin.ID, then revokes the target). The new test revokes upward and asserts all three properties:

Property Why it matters
the creator is gone the revoke actually happened
its sessions cascaded to zero the cascade only runs if the parent delete succeeds
the child survives with created_by cleared SET NULL, not CASCADE

Removing the migration fails it with the defect named. docs/admin-gateway.md asserted the opposite of the tree's behaviour and is corrected; UPGRADING.md carries the migration count and what the upgrade does.

Verified: go build ./..., go vet, go test -race ./internal/adminapi/, tests/spec + tests/compliance green, golangci-lint 0 issues on a cleaned cache.

fix(admin): revoke reaches an admin who created another admin

42-v added 2 commits August 26, 2026 21:46
POST /admin/admins/{id}/revoke could not revoke any admin who had opened another
account. The account survived, and so did its live sessions.

auth.admin_users.created_by referenced its own table with no ON DELETE clause,
so NO ACTION applied and deleting a row another row still named raised 23503.
AdminUserRepo.Revoke is a bare DELETE, so the whole statement failed.
admin_sessions cascades, but only when the parent delete succeeds, and it did
not -- so the sessions the revoke exists to kill stayed open.

created_by is set on every create and 016 raises when it is null once any admin
exists, so the admin graph is a tree. The bootstrap super_admin became
unrevokable the moment it opened one other account, which is the first thing an
operator does.

That is worse than an ordinary 500 because revoke is the only containment lever
the admin plane has: router.go:125-128 is list, create, revoke, with no admin
update, no admin lock and no per-admin-session revoke. An admin whose
credentials were known to be compromised could not be stopped at all.

042 makes the constraint ON DELETE SET NULL. Deliberately not CASCADE: that
would delete the revoked admin's entire created subtree, so revoking one
compromised account would silently remove every account it had ever opened.

The handler now reads the row before deleting it and writes the old created_by
into the audit metadata, so "who authorized this account" survives the SET NULL,
and it audits the failure path as well. Returning 500 before the audit call left
no trace of the one event an operator most needs to see: the containment attempt
that did not work.

The existing repo test passes on the unfixed code because it only ever revokes a
leaf. The new one revokes upward and asserts all three properties: the creator
is gone, its sessions cascaded to zero, and the child survives with created_by
cleared. Removing the migration fails it.

docs/admin-gateway.md asserted the opposite of the behaviour in the tree.
The coverage gate counted two statements neither covered nor excluded: the
provenance read that keeps created_by before the SET NULL erases it, and the
audit row written when the revoke itself fails.

Both are now driven. The first asserts that a successful revoke carries
revoked_admin_created_by, which is where the answer to "who authorized this
account" survives once 042 nulls the column on every account the revoked admin
opened. The second asserts a failed revoke writes a row at all, with an outcome
and a reason -- it used to return 500 before reaching the audit call, so the
operator was told the request failed and the trail said the attempt never
happened.

Wired through captureAudit and a directly constructed Handler, which is the
idiom audit_actor_test.go already uses for this question. newTestHandler takes
an audit repository but builds its logger from testAuditLog(), so rows written
through the handler never reach a repository passed in -- worth knowing before
writing the next audit assertion against it.
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