Repository navigation
api: answer 409 for org deletes with users and duplicate emails, and name the actor on admin operations - #1384
Merged
Merged
Conversation
|
Thanks for opening this pull request! A SPACE maintainer will take a look. CONTRIBUTING.md explains how we review outside contributions. |
nikhilbirmiwal-pplx
approved these changes
Nov 3, 2026
ac-pplx
force-pushed
the
ac/admin-api-refusals
branch
from
November 3, 2026 22:21
7f1c8b1 to
6e4a4ab
Compare
… use
DELETE /orgs/{org_id} surfaced every foreign-key refusal as a 500. Name
the blocking rows instead: org_has_users, org_has_sandboxes,
org_has_archived_sandboxes, org_has_templates, org_has_node_groups, or
org_manages_cluster when the org became the managing org mid-request.
Creating or updating a user with another user's email now answers 409
email_in_use, matched on users_email_key rather than any 23505.
The admin operations list and get now return actor_email, the current email of actor_user_id's user, joined in the same statement, or null when there is no actor or the user is gone. The console no longer needs a user read per row.
…ete refusals A deleted template's row keeps its org_id until tombstone retention removes it, a retention period after the delete request and only once no node caches it. The template routes no longer show it, and deleting it again answers 404, yet the org delete answered org_has_templates, asking for a delete the operator cannot make. When every template the org owns is deleted, the refusal is now org_has_retained_deleted_templates, with retained_until when the end is knowable and "retry later" when a node still caches one. An org with no sandboxes left can still be held by a snapshot storage location its deleted snapshots left behind, encrypted under its master key. The snapshot storage sweeper skips encrypted locations, so neither deleting sandboxes nor waiting clears it. The master-key constraint now answers org_has_orphaned_snapshot_storage once the org has no sandbox rows, and org_has_sandboxes while it has any. The node-group refusal now names both ways out: release the group with :assign-org, or delete it once its nodes are relabeled or retired. The OpenAPI text says the delete detaches child organizations, and the classification test covers all five constraints that restrict a master key's delete.
…s audit events The docs said actor_email is the field admin audit events carry. An audit event's actor_email is a never-null copy taken when the request ran; an operation's is read with it, so it follows a change of email, and it is null once the user is deleted, because the delete also clears actor_user_id. The OpenAPI text and the record's doc now say so. That same clearing meant the test's deleted-actor case could not fail apart from its no-actor case, so they are one case, which also asserts the cleared actor_user_id. Two comments on the admin reads now describe both extra columns they select, not one payload-derived expression.
ac-pplx
force-pushed
the
ac/admin-api-refusals
branch
from
November 3, 2026 22:21
6e4a4ab to
0405325
Compare
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.
Summary
Three admin API fixes on
/admin/api/v1, extending the identity error'sreasonfield that #1303 introduces.Org delete refusals.
DELETE /orgs/{org_id}used to answer 500 forevery foreign-key refusal. It now answers 409 with a
reasonnaming whatstill references the org, and a message saying what to do:
org_has_users: delete or move the users first.org_has_sandboxes: deleted sandboxes kept for retention included.org_has_archived_sandboxes.org_has_templates: at least one template is not deleted.org_has_retained_deleted_templates: every template is deleted, buttombstone retention still keeps the rows, which the template routes no
longer show. The body's
retained_untilsays about when the last onegoes. It is omitted, and the message says to retry later, while a node
still caches one, because no fixed time bounds that.
org_has_orphaned_snapshot_storage: the org has no sandboxes left, butits master key still encrypts snapshot storage locations that its deleted
snapshots left behind. The snapshot storage sweeper skips encrypted
locations, so neither deleting sandboxes nor waiting clears them. While
the org has sandbox rows, the same constraint answers
org_has_sandboxes.org_has_node_groups: release each group with:assign-organd a nullorg_id, or delete it once its nodes are relabeled or retired.org_manages_cluster: the org became the managing org during the request.Every key that can refuse the delete was checked against the migrated
schema. That includes the five encrypted-row constraints that restrict the
delete of the org master key, which the org's delete cascades to. API keys,
credentials, operations and the other
ON DELETE CASCADErows are deletedwith the org and never block it. Child organizations are detached: their
parent_idbecomes null. That behavior predates this PR, and the OpenAPItext now says it.
Email in use.
POST /orgs/{org_id}/usersandPATCH /users/{user_id}used to answer 500 for a unique violation on
users_email_key. They nowanswer 409
{"error": "email already in use", "reason": "email_in_use"}.Other unique violations still answer 500.
Actor email on admin operations.
GET /operationsandGET /operations/{operation_id}return a nullableactor_emailon eachoperation. It is the email
actor_user_id's user has when the operation isread, so it follows a change of email. It is null when the operation has no
actor, which includes once the user is deleted, because the delete also
clears
actor_user_id. It is not theactor_emailof an admin audit event,which is a never-null copy taken when the request ran. It is read with a
primary-key
LEFT JOIN usersin the store's existing admin statements, sono read runs per row, and the console can drop its per-row
GET /users/{id}lookups.The refusal is refined only after a delete has failed:
org_template_rows, mirrors the tombstonereaper's conditions to tell live templates from retained deleted ones.
org_has_sandbox_rows, is oneEXISTSonidx_sandboxes_org_id.The OpenAPI documents the new 409s and
actor_email. The body-shape commentin
routes/admin/mod.rsnow says every identity 409 carries areason, andnames both
retained_untilcases.Validation
cargo nextest run -p pplx-space-api --all-features: 2195 passed and 1skipped (an existing
#[ignore]benchmark), with Postgres and Redis fromthe nextest setup scripts and no dependency skips.
New Postgres-backed tests:
delete_org_answers_conflict_naming_what_still_references_itinsertseach blocker alone. A soft-deleted template row checks
retained_untilagainst its delete request plus the retention period. A node inventory
row for it checks that
retained_untilis omitted. An orphaned encryptedsnapshot storage location with no sandboxes checks its reason, and
adding a sandbox refines it to
org_has_sandboxes. The final step checkssuccess with a cascading API key and a detached child org.
org_delete_blockers_classify_the_database_constraintcovers all fivemaster-key constraints and the managing-org constraint, classified from
real database errors.
an_email_in_use_answers_conflict_on_create_and_update.admin_operations_name_the_actor_by_emailcovers a renamed actor, and adeleted actor whose operation reads as one with no actor, on both the
list and the get.
Without the fixes, these tests fail:
and update halves of the email test each get a 500.
as the plain template and sandbox cases, the org-delete test gets
org_has_templatesand the classification test getsSandboxes.nullemail.cargo clippy --all-targets --all-features -- -D warningspasses forpplx-space-api,pplx-space-sandbox-storeandpplx-space-cluster-controller-server, andcargo fmt --all -- --checkpasses.
cargo archagainstmain's baseline and architecture reports the samefindings at this head as at the previous head of this branch.
Kind (Kubernetes substrate), run at the previous head and not rerun for the
refusal refinements:
/admin/api/v1with a synthetic org and users, a duplicate emailanswered 409
email_in_useon create and on update.org_has_users, and with themgone, 204.
actor_emailon list and get.
The other blockers can't be created through the API, so the Postgres-backed
tests cover them.
Severity and risk
Severity: low to moderate. Operators saw 500s for ordinary refusals. They
could not tell what blocked an org delete, and for deleted templates and
orphaned snapshot storage, nothing they could do would clear it.
Risk: low. The changes are additive. Statuses move from 500 to 409 only for
the named constraints.
retained_untilandactor_emailare new responsefields. The refinement reads run only after a refused delete. The response
structs are serialize-only, and nothing in the workspace decodes these
responses with
deny_unknown_fields. Roll back by reverting.Stack
Stacked on #1303 (
ac/admin-api-console-gaps), which introduces the identityreasonfield and the body-shape comment this extends. Merge after it.Not covered
the database checks. The next attempt names the next.
org_has_usersmessage says to delete or move them, as asked.
org_has_orphaned_snapshot_storageis reachable only through the schema.No endpoint purges them yet: the sweeper's encrypted-row skip is the
documented follow-up in
snapshot_storage_sweeper/mod.rs.retention. It clears when that node's inventory no longer lists the
template.
AI assistance: Claude Code drafted the code, tests and this description.