BE-770: Use the Kratos user-ID lookup on the admin delete-user path - #9256
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## t/be-761-authenticate-cloudflare-access-jwts-in-the-graph-api #9256 +/- ##
=================================================================================================
+ Coverage 60.22% 60.27% +0.04%
=================================================================================================
Files 1430 1430
Lines 140559 140644 +85
Branches 6611 6613 +2
=================================================================================================
+ Hits 84657 84773 +116
+ Misses 54824 54792 -32
- Partials 1078 1079 +1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
PR SummaryMedium Risk Overview
The Kratos HTTP client now disables redirects. Unit tests cover the lookup; an integration test deletes by email while a scalar-email invitation exists. Reviewed by Cursor Bugbot for commit e9a8073. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Pull request overview
Moves admin email-based user deletion from Graph JSONB lookup to Kratos identity resolution.
Changes:
- Adds tested Kratos email lookup with ambiguity and provisioning checks.
- Threads the resolved identity ID through deletion for partial-deletion recovery.
- Removes obsolete Graph email lookup code.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
libs/@local/graph/type-fetcher/src/store.rs |
Removes email lookup passthrough. |
libs/@local/graph/store/src/user_deletion.rs |
Accepts a pre-resolved Kratos identity ID. |
libs/@local/graph/store/src/account/mod.rs |
Removes the obsolete store API. |
libs/@local/graph/postgres-store/src/store/postgres/mod.rs |
Removes the unsafe JSONB lookup. |
libs/@local/graph/authentication/src/kratos/mod.rs |
Exports shared Kratos metadata. |
libs/@local/graph/api/src/rest/admin.rs |
Resolves email through Kratos before deletion. |
libs/@local/graph/api/src/identity_provider.rs |
Implements and tests identity resolution. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
2524887 to
7ae0efe
Compare
7ae0efe to
49053e1
Compare
49053e1 to
14c5cf1
Compare
Deleting a user by email scanned `email` properties across all entity editions and extracted them as arrays. An entity carrying a scalar `email` — an org invitation, for example — could fail the whole query with "cannot extract elements from a scalar": predicate evaluation order is not guaranteed, so the extraction can run before the `user_actor` join filters the editions down to users. The lookup now asks Kratos. The identity is found by credentials identifier and the actor comes from the `graph_actor_id` it was provisioned with. Unlike authentication, an unverified address resolves — deletion must reach accounts that never completed verification — and an address held by several identities is rejected rather than guessed at. The resolved identity ID travels into the deletion instead of being read back from the graph, so a partially deleted user — entities purged, identity still alive because the Kratos step is non-fatal — stays deletable by email. The graph scan is removed.
The lookup and the deletion went through the router-wide client, which follows redirects, while the authentication resolver disables them for the same Kratos endpoint. The provider now builds its own client with redirects off, constructed once at router setup instead of per request. With redirects disabled a 3xx is no longer followed, and `error_for_status` only covers 4xx/5xx, so both paths reject a non-success status explicitly.
An org invitation's `email` property is a single string, and its mere existence broke every later delete-by-email through the graph scan. The test creates one through the real invite resolver and deletes a user by email against the admin server. The context helpers mirror the ones the email-handling PR adds, so the branches merge cleanly.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit e9a8073. Configure here.
| let emails = store | ||
| .get_user_emails(user_id) | ||
| let emails = identity_provider | ||
| .get_identity_emails(&kratos_identity_id) |
There was a problem hiding this comment.
Email fetch blocks entity purge
Medium Severity
get_identity_emails is fatal in step 1, but those addresses are only used later for non-fatal Mailchimp cleanup and the deletion report. Delete-by-ID used to read emails from the graph store, so a Kratos outage still allowed entity purge. A Kratos failure now aborts before any entities are removed, even when no subscription provider is configured.
Reviewed by Cursor Bugbot for commit e9a8073. Configure here.
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: 1002 | 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: 1527 | 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: 52 | 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: 108 | 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: 2 | 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: 26 | 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: 27 | 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?
Deleting a user by email resolved the address through a JSONB scan over all entity editions, extracting every
emailproperty as an array. One entity carrying a scalaremail— an org invitation, for example — could fail the whole query withcannot extract elements from a scalar, because Postgres does not guarantee predicate evaluation order even though the query joineduser_actor.Kratos already owns the email-to-identity mapping, and the Graph provisions each identity with its actor (
graph_actor_idinmetadata_public). The delete-user endpoint now resolves the email there, so the shape of storedemailproperties no longer matters for deletion.🔗 Related links
🚫 Blocked by
🔍 What does this change?
KratosIdentityProvidergainsfind_user_by_email: the identity is looked up by credentials identifier and the actor is read from itsmetadata_public.graph_actor_id. Unlike the authentication resolver, an unverified address resolves — deletion must reach accounts that never completed verification.delete_userinstead of being read back from the graph. Deletion order stays graph-then-Kratos, so the partial state a non-fatal Kratos failure leaves behind — entities purged, identity still alive — remains findable and deletable by email.get_user_id_by_emailand its JSONB scan are removed fromAccountStore, the Postgres store, and the type-fetcher passthrough.MetadataPublicis exported from the authentication crate, so the metadata contract keeps a single Rust definition.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:
🐾 Next steps
🛡 What tests cover this?
emailproperty is a single string — through the real invite resolver and deletes a user by email against the admin server; this failed withcannot extract elements from a scalarbefore.❓ How to test this?
The new integration test in
user.test.tsautomates the scenario: an org invitation exists, and deleting a user by email through the admin server succeeds.