Skip to content

GRAPH 1: Identity resolution — port the C# service to Rust #1602

Description

@mitasovr

Goal

Port the existing .NET (C#) identity-resolution service to Rust at behavioural
parity, cut all traffic over to it via api-gateway, and decommission the .NET
service. The matching/mapping logic itself (how accounts collapse into a
canonical person) is specified in its own epic — see Relations; this epic is the
service reimplementation, not new resolution behaviour.

Context

The identity store is already live (BambooHR + Entra with a reconcile loop). What
remains is moving the running implementation off .NET onto Rust without changing
observable behaviour, then retiring the old service.

Hard constraints — no administration surface, and no administrator exists

Two separate facts, both verified against main (2026-07-27). Together they
determine what can actually be tested today.

1. There is no administrative user interface. Everything below is a service
capability, not a screen
. The product has no administration area: no screen for
access grants, roles, people/linked-account management, or triggering an identity
refresh. What the UI does show: a person + team view (/ic/<person>), a metrics
view, a what's-new page — all read-only consumers of identity data.

2. There is no administrator in any environment. The mechanism is fully built
(admin-gated operations reject callers without an active admin role), but no role
holder is ever created: seeding the first admin is optional config, is not set
anywhere
in the deployment configuration, and when unset the seeding step is a
silent no-op (it also requires a default company id, itself a placeholder).
Net effect: nobody is an administrator, so every administrative operation is
refused for everyone.

What this means for testing

  • "Administrator" is a role attached to a request, not a person with a console.
    Do not write criteria as "the administrator opens X and clicks Y".
  • Every requirement below is testable only at service level.
  • Testable with no setup: BR-7 (see yourself), BR-8 (own reporting chain, any
    depth), BR-9/BR-15 (refused for anyone outside it). Prioritise these — they are
    what actually governs access today.
  • Blocked without seeding an admin first: acceptance criteria 4, 5, 6, 11.
    Without that step they cannot pass, and that is current expected behaviour,
    not a defect
    .
  • The grant-based half of the access model (BR-10, BR-11) is not exercisable in
    a default install
    .
  • This is a known product gap, not a defect of this migration. The administration
    surface is separate work, called out as required in GRAPH 1b: Identity mapping engine — resolve accounts to a canonical person #1873.

Scope

  • Port the read side: persons, roles, resolve (JWT-claim → person_id) endpoints.
  • Port remaining domains: org-chart (/v1/subchart*), roles + person-roles
    (/v1/roles*, /v1/person-roles*), visibility (/v1/visibility*). (identity-resolution: port remaining domains + cutover #1755)
  • Cutover: route all endpoints to the Rust service via api-gateway; verify parity;
    decommission the .NET identity service.
  • OpenAPI/Swagger contract stays stable across the port.

Out of scope

Business requirements (as-is)

Stated in business terms so acceptance criteria can be written and tested without
reading code. This is current behaviour — the port must preserve all of it.
BR-x ids are usable as test-case ids.

Actors

Actor Definition
Employee Any signed-in person in the company
Manager An employee with people reporting to them, directly or further down
Grantee An employee explicitly given access to see another person
Administrator Holder of the admin role — permits changing grants and roles. Service-level only; see Hard constraints
System Insight's own internal sign-in process

What the product knows about a person

  • BR-1 For each employee Insight maintains one canonical person record,
    regardless of how many separate accounts they hold in connected tools.
  • BR-2 A person record carries: name (full, first, last), work email, job
    title, department, division, employment status, employee id, username, manager.
  • BR-3 A person record lists all of that person's known accounts across
    connected systems
    — one entry per system and account. This is the mapping that
    makes every metric attributable to a real human.
  • BR-4 A person record lists that person's direct reports.
  • BR-5 One human must resolve to exactly one person record. If a lookup
    could mean two different people, Insight refuses to answer and reports the
    conflict
    , naming the conflicting records. It must never quietly pick one — a
    wrong pick attributes someone's work to another person.
  • BR-6 A person can be looked up either by work email or by an account
    identifier from a connected system
    ; both routes return the same record.

Who is allowed to see whom

  • BR-7 (see yourself) Every employee can always see their own record.
  • BR-8 (managers see their org) A manager can see everyone below them in the
    reporting chain, at any depth
    — not only direct reports.
  • BR-9 (nothing else by default) By default an employee cannot see their
    manager, their peers, or anyone outside their own reporting chain.
  • BR-10 (explicit grant) A grant can be created giving an employee access to
    another person. It carries the whole reporting chain below that person, not
    just the one person — granting on a department head grants the department.
  • BR-11 (company-wide grant) A grant can be created giving access to
    everyone in the company.
  • BR-12 (administrator is not a viewer) Holding admin permits changing
    grants and roles. It does not grant access to see people's data. An
    administrator with no grant sees only themself and their own chain. Deliberate —
    confirm it, don't file it as a bug.
  • BR-12a Because no administrator exists (see Hard constraints), all
    administrative operations are currently refused for every caller until one is
    seeded. This is current expected behaviour.
  • BR-13 (time-bounded) Every grant has a start, can be revoked, and grant
    history is retained.
  • BR-13a (as-of a past date — org chart only) "How did the organisation and
    access look on date D" can be asked only of the organisation-chart views.
    Person/profile lookups always evaluate access as of now and accept no past
    date; a future date is rejected as invalid.
  • BR-14 (auditability) Every grant records who created it and optionally why.
    Grants are never edited or erased — a revoke is recorded as an end date.
  • BR-15 (no existence leak) When an employee asks about a person they may not
    see, Insight answers exactly as if that person did not exist. "No such
    person" and "not allowed" must be indistinguishable — the difference alone would
    leak the org structure.

Viewing the organisation chart

  • BR-16 An employee can view the reporting tree from any person they may see,
    limiting how many levels deep.

  • BR-17 With no depth limit, the whole chain below that person is returned.

  • BR-18 Asking for "my organisation" with no named root returns exactly:

    The employee's access What they see
    No grants One tree: themself + their own chain
    Grant on a peer Two trees: their own, plus the peer's
    Grant on their manager One tree rooted at that manager (they appear inside it)
    Company-wide grant Every top-level tree in the company
  • BR-19 An employee with no place in the organisation data gets an empty
    result
    , not an error.

  • BR-20 People with no manager and no reports are not shown as one-person
    "trees". The record is still kept — "employee in HR with no manager assigned" is
    a data-quality signal — but it is not presented as an organisation.

  • BR-21 Each node shows name, work email, job title, employment status.

Keeping identity data current

  • BR-22 Identity data is populated from connected HR/directory systems. A
    refresh can be triggered, runs in the background, and its outcome can be checked
    — the requester is not blocked.
  • BR-23 Reporting structure is stored with history, so past states remain
    answerable (BR-13a).

The migration itself

  • BR-24 From a business standpoint the requirement is no observable
    change
    : every rule BR-1…BR-23 must behave identically before and after, for the
    same inputs. Any behavioural difference is a defect, not an improvement.
  • BR-25 (one deliberate exception) The old email-in-the-URL lookup is
    intentionally not carried over — superseded by a lookup that does not put an
    email address into a web address. This is the single accepted difference.

Current state of this migration — verify before testing

Verified against main, 2026-07-27. Both services are deployed side by side.
The new service is enabled by default, runs against the same identity database,
and its migration step now owns that schema.

Question Answer today
New service built and deployed? Yes — enabled by default, side by side
Does it receive user traffic? No, not by default
Who serves users? The old service, unless an environment is explicitly flipped

The cutover is a switch: one configuration value points the consumers
(analytics, sign-in) at either implementation. Empty = the old service (historical
default), so existing environments are untouched. Flipping it plus the gateway
route moves traffic; clearing it rolls back (consumers read the value at start-up
and must be restarted).

Consequences for QA:

  • By default, tests exercise the old service. Confirm which implementation the
    target environment points at — otherwise a green run says nothing about the port.
  • Test both sides of the switch, including rollback.
  • Because both services share one database and the new one owns the schema,
    include a check that the old service still behaves correctly after the new one
    has migrated it — a one-sided parity test would miss this.
  • An implementation-agnostic contract suite already exists (test(e2e): identity API contract suite — implementation-agnostic (#1753) #1897) and runs
    against either service. Reuse it rather than writing parity tests from scratch.
  • "Old service retired" is not done. Side-by-side deploy and a working switch
    are done.

Not true today — do not claim in acceptance criteria

Acceptance Criteria

All service-level tests — there is no administrative screen, so setup and
verification are done by calling the service. Criteria 4, 5, 6 and 11 require
seeding a first administrator
; without it they cannot pass and must not be
reported as failures. Criteria 1, 2, 3 and 10 need no setup — run those first.

  1. An employee can retrieve their own record, with accounts and direct reports
    listed. (BR-1…BR-4, BR-7)
  2. A manager can retrieve records for people two or more levels below them.
    (BR-8)
  3. An employee cannot retrieve records for their manager, a peer, or an unrelated
    person, and the response is indistinguishable from "no such person".
    (BR-9, BR-15)
  4. After a grant on a department head is created, the grantee can retrieve every
    person in that department; after revoke, they cannot. (BR-10, BR-13)
  5. A company-wide grant lets the grantee retrieve any person. (BR-11)
  6. A newly created administrator with no grant can still only see themself and
    their own chain. (BR-12)
  7. Asking for the organisation chart as of a past date reflects the grants and
    reporting lines in force then, not now; a future date is rejected. Person and
    profile lookups offer no past-date option. (BR-13, BR-13a)
  8. A lookup matching two people returns a conflict naming both records, and no
    profile is returned for either. (BR-5)
  9. A person is found both by email and by a connected-system account id, and both
    return the same record. (BR-6)
  10. "My organisation" returns exactly the tree set in the BR-18 table for each of
    the four access states, an empty result for an employee outside the org data,
    and no one-person trees. (BR-18…BR-20)
  11. Identity refresh can be triggered and its outcome inspected without blocking
    the caller. (BR-22)
  12. Every rule above yields the same result on the new implementation as the old,
    for the same fixture data — with the email-in-the-URL lookup being the one
    accepted, documented removal. Precondition: confirm which implementation the
    environment points at. (BR-24, BR-25)
  13. Switching the environment to the new implementation, and switching it back,
    both leave the product working — no user-visible change either way. (BR-24)
  14. The old implementation still behaves correctly after the new one has taken
    ownership of the shared database schema. (BR-24)

Sub-tasks

Testing

Vectors define the metrics quality must reach; the tests below are the automation that fulfills them — one test may cover several vectors. The Rust port must resolve accounts to one human at behavioural parity with the C# service — merging two people corrupts every metric downstream, so parity leads the risk. Speed and memory run on the 3,000-person demo org.

Quality vectors — at a glance

Vector Metric Target Collected today
Reliability API coverage, both implementations all documented operations · all answerable codes C# 18/18 ops, 100% codes · Rust 17/18, 94.4% — the single gap is GET /v1/persons/{email}, the endpoint the port deliberately dropped (BR-25, accepted exemption)
Versatility Not applicable
Performance Person-lookup latency P95 < 100 ms at every concurrency level not collected — no load harness in the repo
Efficiency Rust service CPU/RAM vs the C# service ≤ 100% of C# for both · 0 MB/h growth not collected — same missing harness; must run while both services are still deployed
Security Findings in the service's own files 0 critical collected — 0 (one low: missing HEALTHCHECK in services/identity-resolution/Dockerfile)

Tests

  1. Identity contract suite, both sides of the switch — run the implementation-agnostic e2e suite (test(e2e): identity API contract suite — implementation-agnostic (#1753) #1897) against the C# and the Rust service, asserting every documented operation and response code via the two blocking endpoint-coverage gates.
    Covers: Reliability · Status: done — both gates green on main.

  2. Lookup latency ramp — k6 email/login → person lookups against the 3,000-user org, single-user baseline first then ramped concurrency, asserting P95 < 100 ms at every level.
    Covers: Performance · Status: to write — shared harness with INGEST 1: Unified metric system (Metrics catalog) #1561/Split Insight into an engineering (data contract) layer and a presentation layer #1803.

  3. Dual-service soak — drive ≥ 1 h of continuous lookups on the same org against the Rust and the C# service in turn, recording CPU/RSS (Rust ≤ 100% of C#) and memory growth (0 MB/h) in the same run.
    Covers: Efficiency · Status: to write — run it before the .NET service is decommissioned, or the baseline is gone.

Security needs no separate test: Semgrep (main, report-only #1797) and Trivy (nightly) already scan the port's files — 0 critical; insight-identity and insight-identity-resolution images are scanned nightly and clean.

Relations

Planning:
Assignee: Sergey Mozhaev
Estimation (days): TBD — re-split from the original 25 across #1602 + GRAPH 1b
Estimation for QA (days): TBD — re-split from the original 6 across #1602 + GRAPH 1b

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions