Skip to content

feat(identity-resolution): remaining domains — roles, person-roles, visibility, subchart, internal lookup (#1755) - #1862

Merged
mozhaev-dev merged 22 commits into
mainfrom
feat/identity-resolution-domains-cutover
Jul 24, 2026
Merged

feat(identity-resolution): remaining domains — roles, person-roles, visibility, subchart, internal lookup (#1755)#1862
mozhaev-dev merged 22 commits into
mainfrom
feat/identity-resolution-domains-cutover

Conversation

@mozhaev-dev

@mozhaev-dev mozhaev-dev commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

What

Ports the remaining .NET identity endpoints to the Rust identity-resolution
service, completing code-level parity with the .NET service. Stacked on the
read (#1745) and write-seed (#1825) work, both already in main.

Endpoints added in this PR:

  • Roles cataloguePOST/GET/DELETE /v1/roles (admin-gated; hard-delete with
    an atomic in-use guard).
  • Person-rolesPOST/GET/DELETE /v1/person-roles (grant/list/revoke;
    last-admin lockout guard enforced in a RepeatableRead txn with FOR UPDATE).
  • VisibilityPOST/GET/DELETE /v1/visibility (create/list/revoke grants).
  • Org subchartGET /v1/subchart (forest) and GET /v1/subchart/{person_id}
    (single), recursive-CTE traversals with the first visibility gating in the port.
  • Internal lookupGET /internal/persons/by-email/{email}, service-only
    (sub_type=service), for the authenticator's login bootstrap.
  • Shared admin gate (require_admin / require_caller), and a bind_named
    helper that expands the .NET named @params to SeaORM positional ? so the
    recursive CTEs stay character-identical to the .NET source.

Verification

  • 53 unit tests pass; clippy -D warnings clean.
  • Full-branch adversarial review (multiple independent passes) + fixes:
    last-admin lockout race (txn + FOR UPDATE), reason length validation,
    driver-agnostic visibility probe, depth/field/limit hardening, bounded subchart
    depth + dropped-row telemetry.
  • Endpoint-parity + consumer audit vs .NET. Analytics (POST /v1/profiles,
    forwards the user JWT) is compatible and now correctly visibility-gated. The
    authenticator's internal lookup is contract-matched but is currently blocked
    at the OIDC layer
    by an empty-tenant service token — see Known limitations.
  • POST /v1/profiles visibility gate restored (was missing on the read path):
    require_caller + is_target_in_visible_set, deny→404, parity with the .NET
    VisibilityService.CanSeeAsync.
  • Two rounds of adversarial review + CodeRabbit + two external reviews addressed
    (last-admin lock, reason validation, RFC-3339 valid_from, concurrent-dup 409,
    bounded subchart depth, per-field validation, service-gate test, …).

Intentional divergences from .NET (unchanged from #1745/#1825)

  • Error-type namespace gts.cf.insight.identity_resolution.* (RFC-9457) instead
    of urn:insight:error:*.
  • No HTTP 422 in the gears error model → ambiguous-profile → 409 aborted;
    role-in-use / last-admin → 400 failed_precondition.
  • next_cursor dropped from list responses (was always null).
  • Internal by-email route is a raw axum route, out of OpenAPI (mirrors the .NET
    .ExcludeFromDescription()).
  • Tenant/caller come from the gateway JWT; X-Insight-Tenant-Id /
    X-Insight-Person-Id headers are no longer read.
  • Self-managed SeaORM pool + raw SQL (not the toolkit db capability) — see
    toolkit-db: no raw-SQL path (DbConn/DbTx) — window functions, recursive CTEs & atomic conditional DML can't be expressed, forcing a bespoke SeaORM pool gears-rust#4239.
  • depth on subchart is clamped to the server's max_depth (and defaults to it
    when omitted) — a deliberate bound the .NET endpoint lacked, so a caller cannot
    force an unbounded org-tree traversal.
  • The deprecated GET /v1/persons/{email} is intentionally not carried: no
    consumer calls it, and the redesign removes it.

Known limitations — NOT yet swap-ready (do not switch traffic on merge)

This PR is code-only; the following block a production cutover and are
tracked for the next PR (#1753):

  • Authenticator login blocked: it mints the service JWT with an empty
    tenant_id; the gears OIDC plugin parses tenant_id as a UUID and 401s before
    the handler, so GET /internal/persons/by-email/{email} is unreachable. Needs
    a tenantless service-token path (or a sentinel tenant) — a cross-service change.
  • Migrations + bootstrap admin: the service only connects to MariaDB; it does
    not run DbUp or seed the first admin role. Migrations (incl. 013) still live
    in the .NET project. Clean-env bring-up + first-admin bootstrap are unresolved.
  • Deployment: no Dockerfile/Helm/compose; the service binds 8083 while
    consumers expect identity:8082; env prefix changed (IDENTITY__
    APP__gears__identity-resolution__config__); OIDC full-auth config/CA not
    mounted.
  • CI + contract: identity-resolution is not registered in CI
    (scripts/ci/components.py); the OpenAPI drift gate still targets .NET; there
    is no HTTP+MariaDB integration / parity suite. OpenAPI path/query params for the
    new routes are not yet declared (pairs with wiring the Rust drift gate).

Known parity quirks (faithful to .NET, documented)

  • GET /v1/subchart/{person_id} synthesizes a null-valued root for an unknown id
    instead of 404 — the anchor is literal in Sql.Subchart.cs; unchanged here.
  • GET /v1/persons/{email} (deprecated) intentionally dropped — no consumer.

Deferred polish

Helper de-duplication, a domain-owned subchart DTO, redacted Debug on the
ClickHouse password.

Part of #1755. Merging this does not enable the cutover — it lands the code;
the swap happens in #1753 after the blockers above are closed.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added admin APIs for managing roles, person-role assignments, and visibility grants.
    • Added authenticated subchart endpoints for visible forests and depth-bounded subtrees (with valid_at filtering).
    • Added an internal service-only endpoint to resolve a person by email.
  • Bug Fixes
    • Profile resolution and subchart results now consistently respect visibility permissions.
    • Improved limit handling and strengthened protection against revoking the last active administrator.
  • Documentation
    • Updated identity-resolution documentation for the expanded, authenticated API surface.
  • Tests
    • Added coverage for validation and internal response formatting.

mozhaev-dev and others added 16 commits July 22, 2026 10:52
First domain of the remaining .NET port: admin-gated CRUD over the global
`roles` table, 1:1 with the .NET RolesEndpoints (ADR-0013).

- GET /v1/roles — list all roles (ListResponse shape, next_cursor null).
- POST /v1/roles — create; validates name (non-empty, <=64); pre-checks the
  unique name for a friendly 409 (already_exists) instead of an opaque 500;
  201 + Location /v1/roles/{id}.
- DELETE /v1/roles/{id} — hard delete with an atomic in-use guard
  (TryDeleteRoleIfUnused); 204 on success, 404 if missing, and a precondition
  error when the role still has active person_roles.

Infra: roles_repo gains Role + get_by_name/get_by_id/list_all/insert_role/
try_delete_if_unused/count_active_assignments_any_tenant (SQL verbatim from
Sql.Roles.cs). Extract the shared admin gate (require_admin/resolve_caller) out
of seed.rs into api/gate.rs so roles/person-roles/visibility reuse it; add
AccessError (gate 403) + RoleError.

Divergence (documented): the .NET "role in use" 422 has no gears canonical
equivalent, so it is surfaced as failed_precondition (400) — same spirit as the
gts:// vs urn: error-type divergence.

Tests: role-name validation + resolve_caller (moved with the gate). clippy
clean. Live business-logic e2e is deferred to the gateway-JWT harness (#1753):
post-#1777 the host enforces gateway-JWT auth, so endpoints now require a signed
JWT — verified here that routes register and the authn gate returns canonical 401.

Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
Second domain: admin-gated grant / list / revoke over the `person_roles`
junction, 1:1 with the .NET PersonRolesEndpoints (ADR-0014).

- POST /v1/person-roles — grant; validates person_id/role_id present;
  valid_from defaults to now; 201 + Location + assignment (read back).
- GET /v1/person-roles — list with ?person= / ?role= / ?active= (default all) /
  ?limit= (default 50, cap 500), newest first.
- DELETE /v1/person-roles/{id} — revoke (soft-delete via valid_to); optional
  {reason} body; refuses to remove the tenant's LAST active admin (lockout
  guard) via the atomic TrySoftDeletePersonRoleProtectingLastAdmin UPDATE.

Infra: new person_roles_repo (PersonRole + get_by_id/list/insert/
try_soft_delete_protecting_last_admin) — SQL verbatim from Sql.Roles.cs, incl.
the last-admin subquery + positional bind order. New PersonRoleError.

Divergence (documented): the .NET last-admin 422 has no gears equivalent →
failed_precondition (400), consistent with the roles domain.

Tests: id-presence validation unit test (the DB-level guard/filters are covered
by the deferred #1753 integration tests). clippy clean, 36 tests.

Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
Third domain: admin-gated create / list / revoke over the `visibility` table,
1:1 with the .NET VisibilityEndpoints (ADR-0012).

- POST /v1/visibility — grant; validates viewer_person_id present + reason
  <=500; viewed_person_id optional (null = whole-tree visibility); valid_from
  defaults to now; 201 + Location + grant (read back).
- GET /v1/visibility — list with ?viewer= / ?viewed= / ?active= (default all) /
  ?limit= (default 50, cap 500), newest first.
- DELETE /v1/visibility/{id} — soft-delete (set valid_to); optional {reason}
  body; 404 only if the grant never existed, else 204 (idempotent re-revoke).

Infra: new visibility_repo (Visibility + get_by_id/list/insert/soft_delete),
SQL verbatim from Sql.Visibility.cs; new VisibilityError. No lockout guard here
(unlike person-roles) — revoke is a plain soft-delete.

Tests: reason-length validation. clippy clean, 37 tests.
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
…-resolution-domains-cutover

# Conflicts:
#	src/backend/services/identity-resolution/src/api/seed.rs
…sibility lists (#1755)

Consistency with the persons-seed list (@cyberantonz review): leaner {items}
shape, no always-null cursor field.

Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
… blockers (#1755)

The role in-use (correlated NOT EXISTS) and last-admin (UPDATE...JOIN count)
guards have no toolkit-db builder form — annotate them alongside the central
infra::db rationale. See constructorfabric/gears-rust#4239.

Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
…#1755)

Port GET /internal/persons/by-email/{email} from the .NET PersonsEndpoints —
the login-bootstrap resolver the authenticator calls before a tenant or caller
identity exists. It deliberately bypasses the tenant + visibility gates the
public /v1/profiles enforces, but stays fail-closed: a valid gateway JWT is
required (host authn) and a non-service principal (subject_type != "service",
the gears mapping of the .NET sub_type claim) gets 403.

- persons_repo: re-add resolve_person_id_by_email_any_tenant (tenant-agnostic
  ROW_NUMBER() latest-observation pick; raw SQL per gears-rust#4239).
- handlers: internal_person_by_email + InternalPersonResponse wire struct.
- mod: register as a RAW axum route so it stays out of the generated OpenAPI,
  matching the .NET .ExcludeFromDescription(); auth + SecurityContext still come
  from the host pipeline like every other route.
- test: lock the { value_type, value, insight_source_type, insight_source_id }
  wire shape the authenticator depends on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
Port GET /v1/subchart/{person_id} and GET /v1/subchart from the .NET
SubchartEndpoints / SubchartService / Sql.Subchart.cs (#348 / #344), plus the
visibility predicate from Sql.Visibility.cs::IsTargetInVisibleSet — the first
visibility gating in the Rust port (the read-side subordinates[] descent is
unguarded).

- infra/db/sql_named: bind_named helper — rewrites the .NET named @params
  (which repeat on every recursion level) to positional `?` in occurrence
  order, so the recursive-CTE SQL stays character-identical to the .NET source
  while running on SeaORM. Unit-tested (repeats, terminators, bare @, unbound).
- infra/db/subchart_repo: is_target_in_visible_set (CanSee), get_subchart_flat,
  get_forest_flat — raw WITH RECURSIVE + ROW_NUMBER() on the self-managed pool
  (per gears-rust#4239); inline SQL comments stripped, rationale in Rust docs.
- domain/subchart: SubchartNode/SubchartResponse/SubchartForestResponse DTOs +
  assemble_forest (flat→tree, O(N), ported from BuildTree). Unit-tested.
- api/subchart: get_forest + get_subchart handlers; require_caller gate (401);
  depth (>=0) + valid_at (RFC-3339/naive/date-only → UTC, no-future) validation
  matching the .NET binder. 404-on-deny hides existence. Unit-tested.
- api/gate: extract require_caller (baseline identified-caller gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
Findings from a full-branch review (3 independent passes) verified against the
.NET source:

- person-roles last-admin lockout: the single guarded UPDATE's correlated COUNT
  is a snapshot read on MariaDB, so two concurrent revokes of different admins
  in a 2-admin tenant could both pass and leave zero admins. Restore the .NET
  behaviour: a RepeatableRead transaction that takes row-level write locks on
  every active admin in the tenant (SELECT ... FOR UPDATE) before the UPDATE.
- reason length (<=500) was unvalidated on person-role create and on both revoke
  paths (person-roles + visibility); .NET rejects with 400 invalid_reason.
  Validate consistently.
- subchart CanSee decoded SELECT EXISTS(...) as i64, the typed-EXISTS-scalar
  pattern roles_repo deliberately avoids; rewrite as a SELECT 1 ... LIMIT 1
  presence probe so truthiness maps cleanly through SeaORM.
- depth overflow saturated to i32::MAX (200) instead of 400; reject as
  invalid_depth to match the .NET int? binder.
- person-role id validation reported the wrong field; emit per-field
  invalid_person_id / invalid_role_id.
- negative ?limit= failed query deserialization (400); type it i64 and clamp to
  [1,500] (negative -> 1), matching the .NET int? clamp.

Each fix carries a unit test. 50 tests pass; clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
…rows (#1755)

Address the availability findings from a second review pass:

- subchart depth is now clamped to the server's config.max_depth (and defaults
  to it when omitted) via effective_depth(), instead of passing an unbounded
  None to the UNION ALL subtree CTEs. Closes two levers: a caller omitting
  ?depth= to pull a whole large-tenant tree in one request, and cyclic org_chart
  data recursing until cte_max_recursion_depth (now a bounded partial tree, not a
  500). The visibility gate's CTE is left as-is: it is UNION/distinct, so it
  self-terminates on cycles.
- assemble_forest now warn-logs rows unreachable from any root (orphaned or
  cyclic) that get dropped from the response, so corrupted org data is visible in
  telemetry instead of a silently-shrunk tree.
- drop the now-stale #![allow(dead_code)] on subchart_repo (all three query fns
  are called from the handlers).

Tests: depth-cap, orphan-drop, and 2-cycle-terminates. 53 pass; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
@mozhaev-dev
mozhaev-dev requested a review from a team as a code owner July 23, 2026 03:28
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c9add4f8-0f75-4fd6-9c73-7cbe36789812

📥 Commits

Reviewing files that changed from the base of the PR and between a3a82dc and d6c53d8.

📒 Files selected for processing (3)
  • src/backend/services/identity-resolution/src/api/datetime.rs
  • src/backend/services/identity-resolution/src/api/handlers.rs
  • src/backend/services/identity-resolution/src/api/subchart.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/backend/services/identity-resolution/src/api/datetime.rs
  • src/backend/services/identity-resolution/src/api/handlers.rs

📝 Walkthrough

Walkthrough

The PR expands identity-resolution with shared authorization, internal email lookup, role and visibility management APIs, recursive subchart queries, hierarchical response assembly, canonical errors, repository support, and route registration.

Changes

Identity-resolution API expansion

Layer / File(s) Summary
Authorization, lookup, and route wiring
src/backend/services/identity-resolution/src/api/{error,gate,handlers,mod,seed,datetime}.rs, src/backend/services/identity-resolution/src/infra/db/persons_repo.rs, src/backend/services/identity/src/Insight.Identity.Infrastructure/Migrations/013_persons_email_any_tenant_idx.sql, src/backend/services/identity-resolution/README.md, src/backend/services/identity-resolution/src/main.rs
Adds shared caller/admin/service authorization, canonical errors, service-only email lookup, visibility checks, seed-handler reuse, flexible datetime handling, authentication wiring, and routes for the new operations.
Roles and person-role lifecycle
src/backend/services/identity-resolution/src/api/{roles,person_roles}.rs, src/backend/services/identity-resolution/src/infra/db/{roles_repo,person_roles_repo}.rs
Adds admin-gated role CRUD and person-role grant/list/revoke operations with validation, tenant scoping, soft deletion, and last-admin protection.
Visibility grant lifecycle
src/backend/services/identity-resolution/src/api/visibility.rs, src/backend/services/identity-resolution/src/infra/db/visibility_repo.rs
Adds visibility grant creation, filtering, listing, and soft revocation with bounded parameters and canonical responses.
Subchart retrieval and assembly
src/backend/services/identity-resolution/src/api/subchart.rs, src/backend/services/identity-resolution/src/domain/{mod,subchart}.rs, src/backend/services/identity-resolution/src/infra/db/{mod,sql_named,subchart_repo}.rs
Adds recursive visibility-aware subchart queries, named SQL binding, depth and timestamp validation, and flat-row-to-tree response assembly with tests.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant SubchartAPI
  participant SubchartRepo
  participant DomainAssembler
  Client->>SubchartAPI: Request subchart with depth and valid_at
  SubchartAPI->>SubchartRepo: Query visible hierarchy using recursive CTEs
  SubchartRepo-->>SubchartAPI: Return flat subchart nodes
  SubchartAPI->>DomainAssembler: Assemble forest or subtree
  DomainAssembler-->>SubchartAPI: Return hierarchical response
  SubchartAPI-->>Client: Return subchart JSON
Loading

Suggested reviewers: mitasovr

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main identity-resolution expansion and names the newly added domains and internal lookup.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/identity-resolution-domains-cutover

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
src/backend/services/identity-resolution/src/infra/db/person_roles_repo.rs (1)

185-259: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Guarded lock/update runs for every revoke, not only admin-role revokes — avoidable lock contention.

try_soft_delete_protecting_last_admin always takes a FOR UPDATE lock on every active-admin row in the tenant (Lines 194-204), even when the assignment being revoked isn't the admin role. Since the caller (person_roles.rs::delete_person_role) already fetched the target row and knows its role_id before calling this function, non-admin revokes could skip the lock/guard path entirely and go straight to a plain soft-delete, avoiding contention with concurrent admin-roster changes in busy tenants.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/identity-resolution/src/infra/db/person_roles_repo.rs`
around lines 185 - 259, The try_soft_delete_protecting_last_admin flow
unnecessarily locks the tenant’s active-admin rows for non-admin revocations.
Use the caller’s known target role_id to branch in delete_person_role: perform
the guarded lock/count/update only for admin-role revokes, and use the existing
plain soft-delete path for other roles; preserve the current last-admin
protection and reason update behavior.
src/backend/services/identity-resolution/src/api/handlers.rs (1)

95-132: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the service-principal gate.

Only the wire-shape of InternalPersonResponse is tested; the security-critical ctx.subject_type() != Some("service") → 403 branch (Line 108) has no test asserting a non-service caller is rejected. Since this is a SERVICE-ONLY login-bootstrap endpoint, a regression here would be high-impact and silent.

Also applies to: 367-393

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/identity-resolution/src/api/handlers.rs` around lines 95
- 132, The internal_person_by_email handler lacks coverage for rejecting
non-service principals. Add a test that invokes the handler with a
SecurityContext whose subject_type is not "service" and asserts it produces the
canonical 403 permission-denied response, while preserving the existing
successful response-shape test.
src/backend/services/identity-resolution/src/api/gate.rs (1)

41-58: 🔒 Security & Privacy | 🔵 Trivial

Consider logging denied admin-gate attempts.

Only the DB-error branch logs (tracing::error!); the 403 "not admin" branch is silent. Since this gate protects every admin-only endpoint (roles, person-roles, visibility, persons-seed), a log/metric on denial would help detect repeated unauthorized probing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/identity-resolution/src/api/gate.rs` around lines 41 -
58, Add a warning log or metric in require_admin for the !is_admin denial path,
including the caller and tenant context when available, while preserving the
existing permission_denied response and DB-error logging.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backend/services/identity-resolution/src/api/roles.rs`:
- Around line 70-93: Trim req.name before passing it to role_name_valid,
roles_repo::get_by_name, and the subsequent role creation/storage flow. Use the
trimmed value consistently for duplicate detection and persistence while
preserving the existing validation and error behavior.

In `@src/backend/services/identity-resolution/src/api/visibility.rs`:
- Around line 109-120: Update the visibility request validation near the
existing viewer_person_id check to reject Some(viewed_person_id) when that UUID
is nil, using the same invalid-argument field-violation pattern and appropriate
viewed_person_id field name. Preserve None as the whole-tree visibility case and
leave reason_valid validation unchanged.

In `@src/backend/services/identity-resolution/src/infra/db/persons_repo.rs`:
- Around line 80-115: Add a tenant-agnostic composite index for the lookup
performed by resolve_person_id_by_email_any_tenant, covering value_type and
value_id and preferably the created_at, id, and person_id columns used for
ranking and selection. Define it in the persons table schema or migration using
the project’s existing index conventions.

---

Nitpick comments:
In `@src/backend/services/identity-resolution/src/api/gate.rs`:
- Around line 41-58: Add a warning log or metric in require_admin for the
!is_admin denial path, including the caller and tenant context when available,
while preserving the existing permission_denied response and DB-error logging.

In `@src/backend/services/identity-resolution/src/api/handlers.rs`:
- Around line 95-132: The internal_person_by_email handler lacks coverage for
rejecting non-service principals. Add a test that invokes the handler with a
SecurityContext whose subject_type is not "service" and asserts it produces the
canonical 403 permission-denied response, while preserving the existing
successful response-shape test.

In `@src/backend/services/identity-resolution/src/infra/db/person_roles_repo.rs`:
- Around line 185-259: The try_soft_delete_protecting_last_admin flow
unnecessarily locks the tenant’s active-admin rows for non-admin revocations.
Use the caller’s known target role_id to branch in delete_person_role: perform
the guarded lock/count/update only for admin-role revokes, and use the existing
plain soft-delete path for other roles; preserve the current last-admin
protection and reason update behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 082ea410-0ca6-4251-bbbf-20ffd4d35e7c

📥 Commits

Reviewing files that changed from the base of the PR and between bcd79be and 071325c.

📒 Files selected for processing (18)
  • src/backend/services/identity-resolution/src/api/error.rs
  • src/backend/services/identity-resolution/src/api/gate.rs
  • src/backend/services/identity-resolution/src/api/handlers.rs
  • src/backend/services/identity-resolution/src/api/mod.rs
  • src/backend/services/identity-resolution/src/api/person_roles.rs
  • src/backend/services/identity-resolution/src/api/roles.rs
  • src/backend/services/identity-resolution/src/api/seed.rs
  • src/backend/services/identity-resolution/src/api/subchart.rs
  • src/backend/services/identity-resolution/src/api/visibility.rs
  • src/backend/services/identity-resolution/src/domain/mod.rs
  • src/backend/services/identity-resolution/src/domain/subchart.rs
  • src/backend/services/identity-resolution/src/infra/db/mod.rs
  • src/backend/services/identity-resolution/src/infra/db/person_roles_repo.rs
  • src/backend/services/identity-resolution/src/infra/db/persons_repo.rs
  • src/backend/services/identity-resolution/src/infra/db/roles_repo.rs
  • src/backend/services/identity-resolution/src/infra/db/sql_named.rs
  • src/backend/services/identity-resolution/src/infra/db/subchart_repo.rs
  • src/backend/services/identity-resolution/src/infra/db/visibility_repo.rs

Comment thread src/backend/services/identity-resolution/src/api/roles.rs Outdated
Comment thread src/backend/services/identity-resolution/src/api/visibility.rs
Comment on lines +80 to +115
pub async fn resolve_person_id_by_email_any_tenant(
db: &DatabaseConnection,
email: &str,
) -> anyhow::Result<Option<Uuid>> {
const SQL: &str = r"
WITH ranked AS (
SELECT
person_id,
id,
ROW_NUMBER() OVER (
PARTITION BY insight_tenant_id, insight_source_type, insight_source_id, value_type, value_id
ORDER BY created_at DESC, id DESC
) AS rn,
created_at
FROM persons
WHERE value_type = 'email'
AND value_id = ?
)
SELECT person_id
FROM ranked
WHERE rn = 1
ORDER BY created_at DESC, id DESC
LIMIT 1
";

let stmt =
Statement::from_sql_and_values(DbBackend::MySql, SQL, [email.trim().to_owned().into()]);

match db.query_one(stmt).await? {
Some(row) => {
let bytes: Vec<u8> = row.try_get("", "person_id")?;
Ok(Some(Uuid::from_slice(&bytes)?))
}
None => Ok(None),
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look for migration/DDL defining the `persons` table and its indexes.
fd -i migration
rg -n --type sql -i -A3 -B3 'CREATE TABLE .*persons|CREATE (UNIQUE )?INDEX' 
rg -n -i -A5 'persons' -g '*migration*' -g '*.sql'

Repository: constructorfabric/insight

Length of output: 565


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## candidates"
git ls-files | rg -n 'persons|migration|schema|migrations|migrat|db' | sed -n '1,200p'

echo "## persons references in tracked files"
rg -n --hidden -i '\bpersons\b' | sed -n '1,240p'

echo "## SQL DDL/INDEX references"
rg -n --hidden -i -C 2 'CREATE TABLE|CREATE UNIQUE INDEX|CREATE INDEX|INDEX|UNIQUE KEY|KEY|ALTER TABLE' --glob '*.sql' --glob '*.cs' --glob '*.rs' --glob '*.rs.inu' --glob '*.sql.*' --glob '*.fs' --glob '*.fsx' | sed -n '1,260p'

Repository: constructorfabric/insight

Length of output: 16231


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## persons migration SQL"
sed -n '1,240p' src/backend/services/identity/src/Insight.Identity.Infrastructure/Migrations/001_persons.sql
echo "---"
sed -n '1,240p' src/backend/services/identity/src/Insight.Identity.Infrastructure/Migrations/004_persons_relax_constraints.sql

echo "## entity/model"
sed -n '1,220p' src/backend/services/identity-resolution/src/infra/db/entities/persons.rs

echo "## local repository references"
rg -n -i '\bpersons\b|value_type|value_id|insight_tenant_id|create_unique_index|create_index|person_id' src/backend/services/identity src/backend/services/identity-resolution src/backend/services/analytics --glob '*.sql' --glob '*.rs' --glob '*.cs' | sed -n '1,360p'

echo "## migrations index DDL"
rg -n --type sql -C 3 'person|idx|index|unique|persons' src/backend/services/identity/src/Insight.Identity.Infrastructure/Migrations src/backend/services/identity-resolution src/backend/services/analytics/src/migration --glob '*.sql' --glob '*.rs' | sed -n '1,420p'

Repository: constructorfabric/insight

Length of output: 50382


Add a matching non-tenant index for tenant-agnostic email resolution.

persons only has tenant-prefixed indexes (insight_tenant_id, value_type, value_id) plus value columns, so resolve_person_id_by_email_any_tenant’s CTE cannot use those indexes for its value_type = 'email' AND value_id = ? filter. Add an index covering this lookup pattern, e.g. (value_type, value_id, created_at, id, person_id) or tenant-agnostic indexes on the value columns used in the CTE/ORDER BY.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/identity-resolution/src/infra/db/persons_repo.rs` around
lines 80 - 115, Add a tenant-agnostic composite index for the lookup performed
by resolve_person_id_by_email_any_tenant, covering value_type and value_id and
preferably the created_at, id, and person_id columns used for ranking and
selection. Define it in the persons table schema or migration using the
project’s existing index conventions.

mozhaev-dev and others added 2 commits July 23, 2026 07:00
- persons: add migration 013 idx_value_id_any_tenant (value_type, value_id) —
  the tenant-agnostic email lookup (GET /internal/persons/by-email) could not use
  the tenant-prefixed idx_value_id and full-scanned persons on every login.
- person-roles revoke: only admin-role revokes take the tenant-wide FOR UPDATE
  last-admin lock now; non-admin revokes use a plain (tenant-scoped) soft_delete,
  avoiding lock contention (the guard is a no-op for non-admin roles anyway).
- roles: trim the name before validate/store/duplicate-check so whitespace-only
  variants (" Admin " vs "Admin") don't accumulate in the global catalogue.
- visibility: reject a present nil viewed_person_id (400) — only an ABSENT target
  means whole-tree visibility.
- gate: extract require_service (service-principal 403 gate) out of the internal
  handler and unit-test it; warn-log denied admin-gate attempts (caller + tenant).

54 tests pass; clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
#1755)

Security parity fix (external review): the .NET service gates /v1/profiles
through VisibilityService.CanSeeAsync and masks a deny as 404; the Rust port
returned the assembled profile without any caller-visibility check, so any
authenticated tenant user could resolve profiles of people they cannot see.

Restore parity in resolve_profile: require an identified caller (401 otherwise),
then check subchart_repo::is_target_in_visible_set(caller → target) and mask a
deny as 404 (same shape as not-found, so existence does not leak). Reuses the
visible_set predicate already used by the subchart endpoints.

Also `cargo fmt` (the prior commit left person_roles.rs / visibility.rs
unformatted, failing the fmt CI gate).

54 tests pass; clippy -D warnings clean; fmt --check clean. Deny-as-404 needs an
integration test, tracked with the live-verification work (#1753).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/backend/services/identity-resolution/src/api/handlers.rs (1)

135-152: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject ambiguous email matches before resolving identities.

resolve_person_id_by_email_any_tenant ranks one latest row per (tenant, source, value_id) and then returns a single row across tenants. Duplicate normalized emails across tenants can therefore return the biggest/newest source-id match and avoid signaling ambiguity; detect multiple matched persons before returning one person_id.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/identity-resolution/src/api/handlers.rs` around lines
135 - 152, Update the by-email lookup around
resolve_person_id_by_email_any_tenant to retrieve and validate all matching
persons before selecting an identity, rejecting the request when multiple
distinct person matches exist rather than accepting the ranked result. Preserve
the existing internal error mapping, not-found response, and successful
InternalPersonResponse behavior for exactly one match.
🧹 Nitpick comments (1)
src/backend/services/identity/src/Insight.Identity.Infrastructure/Migrations/013_persons_email_any_tenant_idx.sql (1)

8-9: 🚀 Performance & Scalability | 🔵 Trivial

Verify online-DDL behavior before applying this index to production.

persons is likely a hot table, and index creation can acquire metadata or table locks depending on the database version and engine. Confirm the target migration strategy supports online DDL or schedule this migration outside peak traffic; Squawk’s PostgreSQL-style CONCURRENTLY suggestion should not be copied blindly to MariaDB/MySQL.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/backend/services/identity/src/Insight.Identity.Infrastructure/Migrations/013_persons_email_any_tenant_idx.sql`
around lines 8 - 9, Verify the production database engine/version and migration
strategy for the idx_value_id_any_tenant index before applying it. Ensure CREATE
INDEX in migration 013_persons_email_any_tenant_idx.sql uses the supported
online-DDL approach for MariaDB/MySQL, or explicitly schedule it outside peak
traffic; do not add PostgreSQL-specific CONCURRENTLY syntax.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backend/services/identity-resolution/src/api/handlers.rs`:
- Around line 41-71: The visibility check currently runs only after ambiguity
handling, leaking hidden matches and rejecting a uniquely visible candidate.
Update the `resolve_person_ids` flow to filter all candidate IDs through
`subchart_repo::is_target_in_visible_set` before matching on the results,
preserving the existing error mapping and 404 behavior for no visible
candidates; then perform the one-result versus ambiguity decision on the
filtered set.

---

Outside diff comments:
In `@src/backend/services/identity-resolution/src/api/handlers.rs`:
- Around line 135-152: Update the by-email lookup around
resolve_person_id_by_email_any_tenant to retrieve and validate all matching
persons before selecting an identity, rejecting the request when multiple
distinct person matches exist rather than accepting the ranked result. Preserve
the existing internal error mapping, not-found response, and successful
InternalPersonResponse behavior for exactly one match.

---

Nitpick comments:
In
`@src/backend/services/identity/src/Insight.Identity.Infrastructure/Migrations/013_persons_email_any_tenant_idx.sql`:
- Around line 8-9: Verify the production database engine/version and migration
strategy for the idx_value_id_any_tenant index before applying it. Ensure CREATE
INDEX in migration 013_persons_email_any_tenant_idx.sql uses the supported
online-DDL approach for MariaDB/MySQL, or explicitly schedule it outside peak
traffic; do not add PostgreSQL-specific CONCURRENTLY syntax.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f699fc58-c90d-4569-8aee-bbe505c73c8a

📥 Commits

Reviewing files that changed from the base of the PR and between 071325c and 71eed53.

📒 Files selected for processing (9)
  • src/backend/services/identity-resolution/src/api/gate.rs
  • src/backend/services/identity-resolution/src/api/handlers.rs
  • src/backend/services/identity-resolution/src/api/person_roles.rs
  • src/backend/services/identity-resolution/src/api/roles.rs
  • src/backend/services/identity-resolution/src/api/subchart.rs
  • src/backend/services/identity-resolution/src/api/visibility.rs
  • src/backend/services/identity-resolution/src/infra/db/person_roles_repo.rs
  • src/backend/services/identity-resolution/src/infra/db/subchart_repo.rs
  • src/backend/services/identity/src/Insight.Identity.Infrastructure/Migrations/013_persons_email_any_tenant_idx.sql
🚧 Files skipped from review as they are similar to previous changes (7)
  • src/backend/services/identity-resolution/src/infra/db/subchart_repo.rs
  • src/backend/services/identity-resolution/src/infra/db/person_roles_repo.rs
  • src/backend/services/identity-resolution/src/api/gate.rs
  • src/backend/services/identity-resolution/src/api/roles.rs
  • src/backend/services/identity-resolution/src/api/person_roles.rs
  • src/backend/services/identity-resolution/src/api/subchart.rs
  • src/backend/services/identity-resolution/src/api/visibility.rs

Comment thread src/backend/services/identity-resolution/src/api/handlers.rs Outdated
mozhaev-dev and others added 2 commits July 23, 2026 07:53
…ole 409 (#1755)

External review (Request changes) — the code-level items:

- valid_from on person-roles / visibility used sea_orm::DateTime
  (= chrono::NaiveDateTime), whose serde parser rejects the Z / offset forms the
  .NET `format: date-time` contract accepts, so a normal client value like
  2026-07-23T10:00:00Z would 400 before the handler. Add a shared flexible
  deserializer (RFC-3339 with Z/offset, zone-less, or date-only -> naive-UTC).
- role create: a concurrent same-name POST that loses the UNIQUE(name) race after
  the friendly pre-check surfaced as a generic 500; re-read on insert error and
  map the duplicate to already_exists (409).

Deferred to the cutover PR (#1753), with reasons: authenticator empty-tenant
service-token contract (cross-service); OpenAPI path/query param declarations
(pairs with wiring the Rust OpenAPI drift gate); person_roles->roles FK to close
the delete-vs-grant race (migration); CI registration + MariaDB integration /
parity suite. The subchart "synthetic root for unknown id" is faithful .NET
parity (the anchor is literal in Sql.Subchart.cs) — left as-is, documented.

55 tests pass; clippy -D warnings clean; fmt --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
)

Code-quality review — must-fixes:

- main.rs did not link `oidc_authn_plugin` (only 9 of the 10 system gears the
  analytics host links). Gears register via `inventory` at link time, so the
  unreferenced crate is dropped and never registers — auth could be silently
  absent at runtime. Add `use oidc_authn_plugin as _;` (runtime "no JWT -> 401"
  check tracked with live verification, #1753).
- Drop 8 stale `#![allow(dead_code)]` (kept only entities/, codegen) — the crate
  builds clean without them, so they were only masking future dead code.
- De-stale the lies: main.rs module doc / clap `about` / "auth disabled" comment
  now reflect reality (auth ON, full surface); handlers.rs ambiguous "flagged for
  review" -> statement; persons_repo `parent_person_id IS NOT NULL` comment was
  wrong (.NET's CurrentParentsForChild has the same predicate) -> provenance.

Minor: include the resolved ids in the ambiguous-profile 409 detail; nil-tenant
guard in require_caller (explicit 400 vs a silent nil-tenant query); seed
`?limit` u64->i64 + clamp (parity with sibling routes); remove the dead
`account_person_map` entity; README auth/endpoint wording.

55 tests pass; clippy -D warnings clean; fmt --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/backend/services/identity-resolution/src/api/datetime.rs (1)

54-88: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test the serde adapter at the deserialization boundary.

parse_flexible is covered, but deserialize_opt adds distinct behavior for missing, empty, and invalid values. Add a small serde_json-backed test matrix for these cases.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/identity-resolution/src/api/datetime.rs` around lines 54
- 88, The existing tests only cover parse_flexible and not the deserialize_opt
serde boundary. Add a concise serde_json-backed test matrix in the tests module
that exercises missing, empty, valid, and invalid values, asserting the expected
optional datetime results and deserialization errors for deserialize_opt.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/backend/services/identity-resolution/src/api/datetime.rs`:
- Around line 54-88: The existing tests only cover parse_flexible and not the
deserialize_opt serde boundary. Add a concise serde_json-backed test matrix in
the tests module that exercises missing, empty, valid, and invalid values,
asserting the expected optional datetime results and deserialization errors for
deserialize_opt.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6096952e-4625-48bf-b033-619e5468e85f

📥 Commits

Reviewing files that changed from the base of the PR and between 71eed53 and a3a82dc.

📒 Files selected for processing (21)
  • src/backend/services/identity-resolution/README.md
  • src/backend/services/identity-resolution/src/api/datetime.rs
  • src/backend/services/identity-resolution/src/api/gate.rs
  • src/backend/services/identity-resolution/src/api/handlers.rs
  • src/backend/services/identity-resolution/src/api/mod.rs
  • src/backend/services/identity-resolution/src/api/person_roles.rs
  • src/backend/services/identity-resolution/src/api/roles.rs
  • src/backend/services/identity-resolution/src/api/seed.rs
  • src/backend/services/identity-resolution/src/api/visibility.rs
  • src/backend/services/identity-resolution/src/domain/seed.rs
  • src/backend/services/identity-resolution/src/domain/seed_service.rs
  • src/backend/services/identity-resolution/src/infra/db/entities/account_person_map.rs
  • src/backend/services/identity-resolution/src/infra/db/entities/mod.rs
  • src/backend/services/identity-resolution/src/infra/db/ops_repo.rs
  • src/backend/services/identity-resolution/src/infra/db/person_roles_repo.rs
  • src/backend/services/identity-resolution/src/infra/db/persons_repo.rs
  • src/backend/services/identity-resolution/src/infra/db/roles_repo.rs
  • src/backend/services/identity-resolution/src/infra/db/seed_repo.rs
  • src/backend/services/identity-resolution/src/infra/db/visibility_repo.rs
  • src/backend/services/identity-resolution/src/infra/identity_inputs.rs
  • src/backend/services/identity-resolution/src/main.rs
💤 Files with no reviewable changes (10)
  • src/backend/services/identity-resolution/src/domain/seed_service.rs
  • src/backend/services/identity-resolution/src/infra/db/entities/account_person_map.rs
  • src/backend/services/identity-resolution/src/infra/db/entities/mod.rs
  • src/backend/services/identity-resolution/src/infra/identity_inputs.rs
  • src/backend/services/identity-resolution/src/infra/db/seed_repo.rs
  • src/backend/services/identity-resolution/src/domain/seed.rs
  • src/backend/services/identity-resolution/src/infra/db/ops_repo.rs
  • src/backend/services/identity-resolution/src/infra/db/visibility_repo.rs
  • src/backend/services/identity-resolution/src/infra/db/person_roles_repo.rs
  • src/backend/services/identity-resolution/src/infra/db/roles_repo.rs
🚧 Files skipped from review as they are similar to previous changes (7)
  • src/backend/services/identity-resolution/src/infra/db/persons_repo.rs
  • src/backend/services/identity-resolution/src/api/gate.rs
  • src/backend/services/identity-resolution/src/api/visibility.rs
  • src/backend/services/identity-resolution/src/api/mod.rs
  • src/backend/services/identity-resolution/src/api/handlers.rs
  • src/backend/services/identity-resolution/src/api/roles.rs
  • src/backend/services/identity-resolution/src/api/person_roles.rs

Comment thread src/backend/services/identity-resolution/src/api/gate.rs
Comment thread src/backend/services/identity-resolution/src/api/subchart.rs Outdated

@cyberantonz cyberantonz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some finding over code

mozhaev-dev and others added 2 commits July 24, 2026 05:43
… date parsing (#1755)

Address remaining review findings on #1862:

- resolve_profile: apply the caller's visibility gate to ALL candidate
  person_ids before deciding not-found / resolved / ambiguous, not only
  to the single-match branch. Previously a hidden candidate could leak
  its existence through the AMBIGUOUS_PROFILE id list, and a uniquely
  visible candidate among several matches was misreported as ambiguous
  instead of being resolved (CodeRabbit, Major).
- subchart::parse_valid_at duplicated datetime::parse_flexible's RFC-3339
  / zone-less / date-only parsing verbatim; drop it and call the shared
  helper instead (cyberantonz).
- datetime::deserialize_opt had no test at the serde boundary (missing/
  null/empty/blank/valid/invalid); add a matrix test (CodeRabbit, nitpick).

55 tests pass; clippy -D warnings clean; fmt --check clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

2 participants