BE-460, BE-461: Add user deletion endpoint to admin API#8530
BE-460, BE-461: Add user deletion endpoint to admin API#8530TimDiekmann wants to merge 11 commits intomainfrom
Conversation
Add `POST /users/delete` endpoint that accepts either `{ userId }` or
`{ email }` to delete a user's data while preserving structural references
(principals, policies, web) needed by entity types from other webs.
Orchestrates deletion across multiple services:
1. Purge all entities from the user's personal web
2. Delete Kratos identity (removes PII)
3. Revoke Hydra login and consent sessions (best-effort)
4. Delete email subscriptions via Mailchimp (best-effort)
Introduces provider traits (`IdentityProvider`, `OAuthProvider`,
`EmailSubscriptionProvider`) in the store crate with concrete HTTP
implementations in the API crate, keeping external service dependencies
out of domain logic.
Also fixes `delete_principals` to clean up `entity_ids` tombstones
left by Purge-scoped deletions before removing webs.
…and comprehensive deletion tests - Make entity deletion temporal axes configurable via `DeleteEntitiesParams` so callers control which entities are found (live-only vs all temporal states) - Use `ReportSink` in user deletion orchestration to collect non-fatal errors from external services (Kratos, Hydra, Mailchimp) with full error-stack context - Return `UserDeletionOutcome` with both the deletion report and collected errors, differentiating the HTTP response message on partial failure - Fix Mailchimp clap `requires` to be bidirectional (api_key now requires server) - Add comprehensive deletion tests: link archival (basic, multiple, chain, self-loop, draft versions, idempotency), temporal axes validation, erase/purge interactions, cross-web batch handling, and user-deletion-then-reset scenario (ignored per BE-466) - Extend integration tests for deletion-by-email, org entity survival, and link archival provenance verification
|
The latest updates on your projects. Learn more about Vercel for GitHub.
2 Skipped Deployments
|
PR SummaryHigh Risk Overview Extends entity deletion to accept configurable Wires new external-service CLI/env config (Kratos/Hydra URLs + optional Mailchimp key/list) into the admin server, adds provider abstractions/implementations for Kratos/Hydra/Mailchimp (including Written by Cursor Bugbot for commit 97eac95. This will update automatically on new commits. Configure here. |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #8530 +/- ##
==========================================
- Coverage 62.65% 62.50% -0.15%
==========================================
Files 1312 1316 +4
Lines 133689 134002 +313
Branches 5510 5510
==========================================
- Hits 83757 83756 -1
- Misses 49018 49332 +314
Partials 914 914
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
🤖 Augment PR SummarySummary: Adds an admin API endpoint to delete a user and purge their personal web, including cleanup in external identity/OAuth/email systems. Changes:
Technical Notes: Incoming link handling now distinguishes purge-vs-erase semantics and uses locking to reduce race windows with concurrent edge creation. 🤖 Was this summary useful? React with 👍 or 👎 |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: User deletion temporal axes inconsistent with find-all pattern
- Changed QueryTemporalAxesUnresolved::TransactionTime to DecisionTime to match the established find_all_axes() pattern, ensuring archived entities are found during user deletion.
Or push these changes by commenting:
@cursor push e4cd897e6c
Preview (e4cd897e6c)
diff --git a/libs/@local/graph/store/src/user_deletion.rs b/libs/@local/graph/store/src/user_deletion.rs
--- a/libs/@local/graph/store/src/user_deletion.rs
+++ b/libs/@local/graph/store/src/user_deletion.rs
@@ -143,7 +143,7 @@
actor,
DeleteEntitiesParams {
filter: web_filter,
- temporal_axes: QueryTemporalAxesUnresolved::TransactionTime {
+ temporal_axes: QueryTemporalAxesUnresolved::DecisionTime {
pinned: PinnedTemporalAxisUnresolved::new(None),
variable: VariableTemporalAxisUnresolved::new(
Some(TemporalBound::Unbounded),This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
…n, optional email field - Use DecisionTime variant (not TransactionTime) for user deletion temporal axes so entities with closed decision_time ranges are found - Treat Kratos 404 as success for idempotency, consistent with Mailchimp - Make email_subscriptions_deleted Optional<bool> (None when no provider configured) to avoid misleading operators
The RevocationFailed variant is used for both login and consent session revocation, so the display should not specify "consent".
Mailchimp API keys have the format `<key>-<server>` (e.g. `abc123-us15`). Extract the server automatically instead of requiring a separate config parameter, reducing config from 3 to 2 fields and eliminating a source of misconfiguration.
…dd admin 404 fallback - Add `links_archived` field to `DeletionSummary` and `UserDeletionReport` - Treat Hydra 400 (no sessions) as success instead of error - Add fallback handler for unknown admin API routes
libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/delete.rs
Outdated
Show resolved
Hide resolved
Enables manual cleanup of external services when deletion partially fails, since the user's data is no longer in the Graph after entity deletion.
archive_incoming_link_edges counted all closed temporal rows via count(*), which double-counted link entities that have both published and draft versions. Changed to count(DISTINCT (web_id, entity_uuid)) to report the actual number of link entities archived.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/delete.rs
Show resolved
Hide resolved
get_user_emails now returns an error if the email property is not a JSON array or if an array entry is not a string, instead of silently producing an empty vector.
Benchmark results
|
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 2002 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 1001 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: high, policies: 3314 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: medium, policies: 1526 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 2078 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 1033 | Flame Graph |
policy_resolution_medium
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 102 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 51 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: high, policies: 269 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: medium, policies: 107 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 133 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 63 | Flame Graph |
policy_resolution_none
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 2 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 8 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 3 | Flame Graph |
policy_resolution_small
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 52 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 25 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: high, policies: 94 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: medium, policies: 26 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 66 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 29 | Flame Graph |
read_scaling_complete
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_id;one_depth | 1 entities | Flame Graph | |
| entity_by_id;one_depth | 10 entities | Flame Graph | |
| entity_by_id;one_depth | 25 entities | Flame Graph | |
| entity_by_id;one_depth | 5 entities | Flame Graph | |
| entity_by_id;one_depth | 50 entities | Flame Graph | |
| entity_by_id;two_depth | 1 entities | Flame Graph | |
| entity_by_id;two_depth | 10 entities | Flame Graph | |
| entity_by_id;two_depth | 25 entities | Flame Graph | |
| entity_by_id;two_depth | 5 entities | Flame Graph | |
| entity_by_id;two_depth | 50 entities | Flame Graph | |
| entity_by_id;zero_depth | 1 entities | Flame Graph | |
| entity_by_id;zero_depth | 10 entities | Flame Graph | |
| entity_by_id;zero_depth | 25 entities | Flame Graph | |
| entity_by_id;zero_depth | 5 entities | Flame Graph | |
| entity_by_id;zero_depth | 50 entities | Flame Graph |
read_scaling_linkless
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_id | 1 entities | Flame Graph | |
| entity_by_id | 10 entities | Flame Graph | |
| entity_by_id | 100 entities | Flame Graph | |
| entity_by_id | 1000 entities | Flame Graph | |
| entity_by_id | 10000 entities | Flame Graph |
representative_read_entity
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/block/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/book/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/building/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/organization/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/page/v/2
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/person/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/playlist/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/song/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/uk-address/v/1
|
Flame Graph |
representative_read_entity_type
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| get_entity_type_by_id | Account ID: bf5a9ef5-dc3b-43cf-a291-6210c0321eba
|
Flame Graph |
representative_read_multiple_entities
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_property | traversal_paths=0 | 0 | |
| entity_by_property | traversal_paths=255 | 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true | |
| link_by_source_by_property | traversal_paths=0 | 0 | |
| link_by_source_by_property | traversal_paths=255 | 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true |
scenarios
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| full_test | query-limited | Flame Graph | |
| full_test | query-unlimited | Flame Graph | |
| linked_queries | query-limited | Flame Graph | |
| linked_queries | query-unlimited | Flame Graph |

🌟 What is the purpose of this PR?
Adds a user deletion endpoint to the Graph admin API. When invoked, it purges all entities in the user's personal web, deletes their Kratos identity (PII removal), revokes Hydra OAuth sessions, and removes email subscriptions — while preserving principals and policies for structural integrity.
Also makes entity deletion temporal axes configurable, adds the
LinkDeletionBehavior::Archivevariant for archiving incoming links during purge, and significantly expands deletion test coverage.🔗 Related links
🔍 What does this change?
User deletion orchestration
POST /users/deleteadmin endpoint accepting{"userId": "..."}or{"email": "..."}hash-graph-store::user_deletion::delete_user()— generic over store + external service traitsErrreturn; non-fatal steps (Kratos, Hydra, Mailchimp) collected viaReportSinkintoUserDeletionOutcome::errorswith full error-stack contextExternal service integrations
IdentityProvidertrait +KratosIdentityProvider— deletes Kratos identity (PII removal)OAuthProvidertrait +HydraOAuthProvider— revokes login and consent sessionsEmailSubscriptionProvidertrait +MailchimpSubscriptionProvider— permanently deletes subscribers via Mailchimp Marketing APIEntity deletion enhancements
DeleteEntitiesParamsnow acceptsQueryTemporalAxesUnresolvedfor configurable entity findingLinkDeletionBehavior::Archive— temporally archives incoming link entities before purge instead of rejecting or ignoring them. Uses an atomic CTE that closesdecision_time, creates a historical row, and setsarchivedByIdprovenanceFOR UPDATElocking onentity_idsduring erase to close the TOCTOU gap with concurrent link creationAccountStoremethods:get_user_kratos_identity_id,get_user_emails,get_user_id_by_emailConfiguration
requiresconstraints are now bidirectional (api_key requires list_id)Pre-Merge Checklist 🚀
🚢 Has this modified a publishable library?
This PR:
📜 Does this require a change to the docs?
The changes in this PR:
🕸️ Does this require a change to the Turbo Graph?
The changes in this PR:
entity_idstombstones from prior Purge operations are invisible to subsequent Erase becauseSelectCompilerrequiresentity_temporal_metadatarows. A regression test exists but is#[ignore]d.🐾 Next steps
delete_user()orchestrator with mocked dependencies🛡 What tests cover this?
libs/@local/graph/postgres-store/tests/deletion/covering purge, erase, archive link behavior, draft handling, temporal axes validation, cross-web batches, and edge cases (self-loops, chains, idempotency)user.test.tscovering end-to-end user deletion (by ID and email), org entity survival, link archival provenance, and Kratos identity deletionadmin-server.tsfor thedeleteUserandresetGraphhelpersreset-database.httpupdated with temporal axes❓ How to test this?
cargo run --bin hash-graph --all-features -- admin-server --kratos-admin-url http://localhost:4434 --hydra-admin-url http://localhost:4445UserDeletionReportwith entity counts and service status booleanscargo nextest run --package hash-graph-postgres-store --test deletion