From cdb77f4e6438bb809c0d15852c3d4af0ee7001e8 Mon Sep 17 00:00:00 2001 From: Sergei Mozhaev Date: Sun, 9 Aug 2026 07:28:56 +0300 Subject: [PATCH 1/7] feat(identity): emit the OpenAPI document offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The service had no way to publish its own contract, so the committed document under docs/components/backend/ stayed the retired .NET one: it declared routes the service answers 404 for, omitted the ones it serves, and knew nothing of the operator correction surface. Add the `openapi` subcommand and `api::openapi_document`, mirroring analytics and authenticator — same offline emit, reusing the very `build_operations` route table the live gear serves, so the document and the router cannot diverge. Declare the path and query parameters while here. Every templated route named its parameters in the path and described none of them, which is not a valid document: a generated client has nothing to fill `{source_id}` from. A test now holds that invariant for the whole table. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Sergei Mozhaev --- .../identity-resolution/src/api/mod.rs | 130 +++++++++++++++++- .../services/identity-resolution/src/main.rs | 14 ++ 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/src/backend/services/identity-resolution/src/api/mod.rs b/src/backend/services/identity-resolution/src/api/mod.rs index 7989b3192..7aaab554d 100644 --- a/src/backend/services/identity-resolution/src/api/mod.rs +++ b/src/backend/services/identity-resolution/src/api/mod.rs @@ -20,7 +20,7 @@ use axum::Extension; use axum::Router; use axum::http::StatusCode; use sea_orm::DatabaseConnection; -use toolkit::api::{OpenApiRegistry, OperationBuilder}; +use toolkit::api::{OpenApiInfo, OpenApiRegistry, OpenApiRegistryImpl, OperationBuilder}; use crate::config::GearConfig; use crate::domain::profile; @@ -52,6 +52,43 @@ pub fn register_routes( host_router.merge(api) } +/// Title/version/description of the emitted document. Kept in step with the +/// `openapi` block of `config/insight.yaml`, which the live gear reads: the two +/// describe the same surface and should not disagree. +fn openapi_info() -> OpenApiInfo { + OpenApiInfo { + title: "Insight Identity Resolution API".to_owned(), + version: "1.0.0".to_owned(), + description: Some( + "Person identity for the product: profile resolution, the operator \ + correction surface over account-to-person bindings, org-chart reads, \ + roles and visibility, plus the persons-seed and persons-sync \ + operation journals. The API Gateway mounts this service at \ + /api/identity." + .to_owned(), + ), + servers: Vec::new(), + } +} + +/// Build the identity-resolution `OpenAPI` document **offline** — no +/// `AppState`, DB or HTTP listener. Backs the `identity-resolution openapi` +/// subcommand (committed-doc regeneration + drift gate), reusing the exact +/// `build_operations` route table the live gear serves, so the two cannot +/// diverge. +/// +/// # Errors +/// +/// Returns an error if the registry cannot assemble the document. +pub fn openapi_document() -> anyhow::Result { + let openapi = OpenApiRegistryImpl::new(); + let _ = build_operations(Router::new(), &openapi); + + openapi + .build_openapi(&openapi_info()) + .map_err(|e| anyhow::anyhow!("failed to build identity-resolution OpenAPI document: {e}")) +} + /// Declare each operation via the toolkit `OperationBuilder` (records the route /// + its OpenAPI spec + auth/error metadata). #[allow(clippy::too_many_lines)] // one flat block per route — readability over splitting @@ -156,6 +193,13 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .summary("Accounts awaiting an operator decision, with the resolution rates") .authenticated() .no_license_required() + .query_param_typed( + "limit", + false, + "Cap on returned items (1..=1000, default 100). The rates always \ + cover every observed account, whatever the cap.", + "integer", + ) .json_response_with_schema::( openapi, StatusCode::OK, @@ -170,6 +214,9 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .summary("Current binding of an account and every decision behind it") .authenticated() .no_license_required() + .path_param("source", "Connector type, e.g. `github`") + .path_param("source_id", "Connector instance id") + .path_param("account_id", "Account id within that connector instance") .json_response_with_schema::( openapi, StatusCode::OK, @@ -184,6 +231,7 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .summary("Every account bound to a person, with the values behind each link") .authenticated() .no_license_required() + .path_param("person_id", "Person whose accounts to list") .json_response_with_schema::( openapi, StatusCode::OK, @@ -201,6 +249,7 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .operation_id("identity_resolution.persons_seed.get") .summary("Get a persons-seed operation") .authenticated() + .path_param("id", "Operation id") .no_license_required() .json_response_with_schema::( openapi, @@ -232,6 +281,7 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .operation_id("identity_resolution.persons_sync.get") .summary("Get a persons-sync operation") .authenticated() + .path_param("id", "Operation id") .no_license_required() .json_response_with_schema::( openapi, @@ -286,6 +336,7 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .operation_id("identity_resolution.roles.delete") .summary("Delete a role (admin)") .authenticated() + .path_param("id", "Role id") .no_license_required() .no_content_response(StatusCode::NO_CONTENT, "Role deleted") .standard_errors(openapi) @@ -326,6 +377,7 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .operation_id("identity_resolution.person_roles.delete") .summary("Revoke a role assignment (admin)") .authenticated() + .path_param("id", "Role assignment id") .no_license_required() .no_content_response(StatusCode::NO_CONTENT, "Assignment revoked") .standard_errors(openapi) @@ -366,6 +418,7 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .operation_id("identity_resolution.visibility.delete") .summary("Revoke a visibility grant (admin)") .authenticated() + .path_param("id", "Visibility grant id") .no_license_required() .no_content_response(StatusCode::NO_CONTENT, "Grant revoked") .standard_errors(openapi) @@ -391,6 +444,7 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .operation_id("identity_resolution.subchart.get") .summary("Depth-bounded org subtree rooted at a person") .authenticated() + .path_param("person_id", "Person the subtree is rooted at") .no_license_required() .json_response_with_schema::( openapi, @@ -416,3 +470,77 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .handler(visible_persons::filter_visible_persons) .register(router, openapi) } + +#[cfg(test)] +mod openapi_tests { + use utoipa::openapi::path::ParameterIn; + + use super::*; + + #[test] + fn the_document_builds_without_state_or_backends() -> anyhow::Result<()> { + let document = openapi_document()?; + + assert!( + document.paths.paths.contains_key("/v1/resolution/bind"), + "the correction surface must be described: {:?}", + document.paths.paths.keys().collect::>() + ); + + Ok(()) + } + + #[test] + fn the_internal_s2s_routes_stay_out_of_the_document() -> anyhow::Result<()> { + let document = openapi_document()?; + + for path in document.paths.paths.keys() { + assert!( + !path.starts_with("/internal/"), + "internal S2S route leaked into the published contract: {path}" + ); + } + + Ok(()) + } + + /// A templated path whose parameters are undeclared is not a valid OpenAPI + /// document: a generated client has nothing to fill `{source_id}` from. + #[test] + fn every_templated_path_declares_its_parameters() -> anyhow::Result<()> { + let document = openapi_document()?; + + for (path, item) in &document.paths.paths { + let templated = path.matches('{').count(); + if templated == 0 { + continue; + } + + let methods = [ + ("get", &item.get), + ("post", &item.post), + ("put", &item.put), + ("patch", &item.patch), + ("delete", &item.delete), + ]; + + for (method, operation) in methods { + let Some(operation) = operation else { continue }; + + let declared = operation.parameters.as_ref().map_or(0, |params| { + params + .iter() + .filter(|p| p.parameter_in == ParameterIn::Path) + .count() + }); + + assert_eq!( + declared, templated, + "{method} {path} templates {templated} parameter(s), declares {declared}" + ); + } + } + + Ok(()) + } +} diff --git a/src/backend/services/identity-resolution/src/main.rs b/src/backend/services/identity-resolution/src/main.rs index 852066366..3d8faa6c5 100644 --- a/src/backend/services/identity-resolution/src/main.rs +++ b/src/backend/services/identity-resolution/src/main.rs @@ -73,6 +73,10 @@ enum Commands { #[arg(long)] force: bool, }, + /// Print the OpenAPI document to stdout and exit. Offline — no config, + /// no backends, no logging subscriber, so stdout stays pure JSON. Backs + /// the committed-doc drift gate. + Openapi, /// Copy the `persons` log into ClickHouse `identity.identity_persons` /// (the metrics email→`person_id` resolve source) and exit. Same execution /// model as `seed` — Helm `CronJob` / manual Job; pairs naturally as @@ -100,6 +104,7 @@ async fn main() -> Result<()> { let config = AppConfig::load_or_default(cli.config.as_ref())?; match cli.command.unwrap_or(Commands::Run) { Commands::Run => run_server(config).await, + Commands::Openapi => print_openapi(), Commands::Migrate => { init_subcommand_logging(); gear::run_migrate(&config).await @@ -143,6 +148,15 @@ async fn main() -> Result<()> { } } +/// Print the `OpenAPI` document as pretty JSON. Offline — see +/// [`api::openapi_document`]. No logging subscriber is installed on this path, +/// so stdout stays pure JSON for the drift gate to consume. +fn print_openapi() -> Result<()> { + let doc = api::openapi_document()?; + println!("{}", serde_json::to_string_pretty(&doc)?); + Ok(()) +} + /// Plain stdout logging for the `migrate` / `seed` subcommands. The bootstrap /// runtime only installs its subscriber inside `run_server`, so without this /// every `tracing::…` on the subcommand paths is a silent no-op — and the From fe49bac49ac26e2f339e09c146aff31c7cf96dd2 Mon Sep 17 00:00:00 2001 From: Sergei Mozhaev Date: Sun, 9 Aug 2026 07:29:05 +0300 Subject: [PATCH 2/7] docs(identity): refresh the committed contract and gate it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerate the document from the service and add the drift gate beside the analytics and authenticator ones, so it cannot go stale again — which is the only reason the previous document survived the port. What the refresh corrects: the seven operator correction routes and both persons-sync operations appear for the first time; `/v1/persons/{email}` and `POST /v1/persons-seed` are gone, the service having retired them; the subchart parameter is spelled `{person_id}`, as the route actually is. The internal S2S resolvers stay out, registered raw exactly so. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Sergei Mozhaev --- .github/workflows/openapi-specs.yml | 42 +- .../backend/identity-resolution/openapi.json | 3728 +++++++++++++++-- 2 files changed, 3394 insertions(+), 376 deletions(-) diff --git a/.github/workflows/openapi-specs.yml b/.github/workflows/openapi-specs.yml index 535d6d125..2f699c96a 100644 --- a/.github/workflows/openapi-specs.yml +++ b/.github/workflows/openapi-specs.yml @@ -10,6 +10,9 @@ name: OpenAPI Specs # no HTTP listener; see api::openapi_document). # • authenticator — the offline `authenticator openapi` subcommand (no Redis, # no IdP; see api::openapi_document), same shape as analytics. +# • identity-resolution — the offline `identity-resolution openapi` +# subcommand (no MariaDB, no ClickHouse; see api::openapi_document), +# same shape as analytics. on: # Deliberately NO `paths:` filter — this is meant to be a required status @@ -99,9 +102,46 @@ jobs: if [ "$rc" -ne 0 ]; then echo "::error::Authenticator OpenAPI spec drift — $AUTHENTICATOR_SPEC is stale. Regenerate: (cd src/backend && cargo run -p authenticator --bin authenticator -- openapi) > dump.json && python3 scripts/ci/openapi_spec.py update --file $AUTHENTICATOR_SPEC --live-file dump.json"; fi exit "$rc" + # ─── identity-resolution — offline emit via the `openapi` subcommand ────── + identity-resolution: + name: Identity Resolution OpenAPI spec drift gate + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: src/backend -> target + key: identity-resolution-openapi + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Generate the OpenAPI doc offline and drift-check it + env: + IDENTITY_SPEC: docs/components/backend/identity-resolution/openapi.json + run: | + # `identity-resolution openapi` builds offline and prints the doc; a + # build failure here should abort loudly under the runner's default + # `set -e`. + (cd src/backend && cargo run --quiet -p identity-resolution --bin identity-resolution -- openapi) > identity.openapi.live.json + # Relax errexit from here: we capture the drift-check rc ourselves, + # write the committed-vs-generated diff to the step summary, and emit a + # friendly ::error:: before exiting non-zero. + set +e + python3 scripts/ci/openapi_spec.py check --file "$IDENTITY_SPEC" --live-file identity.openapi.live.json | tee identity_openapi_drift.txt + rc=${PIPESTATUS[0]} + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then { echo '```'; cat identity_openapi_drift.txt; echo '```'; } >> "$GITHUB_STEP_SUMMARY"; fi + if [ "$rc" -ne 0 ]; then echo "::error::Identity Resolution OpenAPI spec drift — $IDENTITY_SPEC is stale. Regenerate: (cd src/backend && cargo run -p identity-resolution --bin identity-resolution -- openapi) > dump.json && python3 scripts/ci/openapi_spec.py update --file $IDENTITY_SPEC --live-file dump.json"; fi + exit "$rc" + # ─── the models generated FROM those documents ──────────────────────────── # `tests/stand/api/schemas/` is generated from the same committed documents - # the two jobs above gate, so it belongs in this workflow rather than beside + # the jobs above gate, so it belongs in this workflow rather than beside # the stand suite: a spec change and its models go stale in the same commit, # and this is where that commit is already being checked. No Rust — it reads # the committed documents, not the services. diff --git a/docs/components/backend/identity-resolution/openapi.json b/docs/components/backend/identity-resolution/openapi.json index c7f02e495..d19d90a4d 100644 --- a/docs/components/backend/identity-resolution/openapi.json +++ b/docs/components/backend/identity-resolution/openapi.json @@ -1,35 +1,197 @@ { "components": { "schemas": { - "CreatePersonRoleCommandModel": { + "AccountBindingResponse": { "properties": { + "account_id": { + "type": "string" + }, + "history": { + "items": { + "$ref": "#/components/schemas/HistoryEntry" + }, + "type": "array" + }, "person_id": { + "description": "The binding in force now, if the account has one.", "format": "uuid", + "type": [ + "string", + "null" + ] + }, + "source": { "type": "string" }, - "reason": { - "nullable": true, + "source_id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "source", + "source_id", + "account_id", + "history" + ], + "type": "object" + }, + "AccountRef": { + "description": "A source-native account, as named by the caller.\n\nAddressing by an observed value (e-mail / username) instead of the account\ntriple is the reserved extension for importing a prepared matching table:\nthe fields arrive optional, exactly one form is required per item, a value\nresolving to zero or several active accounts is reported per item and never\nguessed. The response already carries per-item outcomes, so adding it does\nnot change the shape of this contract.", + "properties": { + "id": { + "description": "Account id within that instance.", + "type": "string" + }, + "source": { + "description": "Connector type, e.g. `github`.", + "type": "string" + }, + "source_id": { + "description": "Connector instance id.", + "format": "uuid", + "type": "string" + } + }, + "required": [ + "source", + "source_id", + "id" + ], + "type": "object" + }, + "AccountRequest": { + "properties": { + "account": { + "$ref": "#/components/schemas/AccountRef" + }, + "comment": { + "type": "string" + } + }, + "required": [ + "account" + ], + "type": "object" + }, + "AttentionResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/QueueItemResponse" + }, + "type": "array" + }, + "rates": { + "$ref": "#/components/schemas/ResolutionRatesResponse" + } + }, + "required": [ + "items", + "rates" + ], + "type": "object" + }, + "BindItem": { + "properties": { + "account": { + "$ref": "#/components/schemas/AccountRef" + }, + "person_id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "account", + "person_id" + ], + "type": "object" + }, + "BindRequest": { + "properties": { + "bindings": { + "description": "One or more bindings; a prepared matching table is submitted as one call.", + "items": { + "$ref": "#/components/schemas/BindItem" + }, + "type": "array" + }, + "comment": { + "type": "string" + } + }, + "required": [ + "bindings" + ], + "type": "object" + }, + "CorrectionResponse": { + "properties": { + "already_decided": { + "minimum": 0, + "type": "integer" + }, + "applied": { + "minimum": 0, + "type": "integer" + }, + "items": { + "items": { + "$ref": "#/components/schemas/ItemResult" + }, + "type": "array" + }, + "new_person_id": { + "description": "Set by `detach` when the account reached the new person; absent when\nthe write was refused, since no binding points at that id.", + "format": "uuid", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "applied", + "already_decided", + "items" + ], + "type": "object" + }, + "CreatePersonRoleRequest": { + "description": "Body of `POST /v1/person-roles` — grant a role to a person.", + "properties": { + "person_id": { + "format": "uuid", "type": "string" }, + "reason": { + "type": [ + "string", + "null" + ] + }, "role_id": { "format": "uuid", "type": "string" }, "valid_from": { + "description": "Optional assignment start; defaults to now when omitted. Accepts RFC-3339\n(`Z`/offset), zone-less, or date-only, normalised to naive-UTC.", "format": "date-time", - "nullable": true, - "type": "string" + "type": [ + "string", + "null" + ] } }, "required": [ "person_id", - "role_id", - "valid_from", - "reason" + "role_id" ], "type": "object" }, - "CreateRoleCommandModel": { + "CreateRoleRequest": { + "description": "Body of `POST /v1/roles`.", "properties": { "name": { "type": "string" @@ -40,21 +202,29 @@ ], "type": "object" }, - "CreateVisibilityCommandModel": { + "CreateVisibilityRequest": { + "description": "Body of `POST /v1/visibility` — grant a viewer visibility over a target\n(or the whole tree when `viewed_person_id` is omitted).", "properties": { "reason": { - "nullable": true, - "type": "string" + "type": [ + "string", + "null" + ] }, "valid_from": { + "description": "Optional grant start; defaults to now when omitted. Accepts RFC-3339\n(`Z`/offset), zone-less, or date-only, normalised to naive-UTC.", "format": "date-time", - "nullable": true, - "type": "string" + "type": [ + "string", + "null" + ] }, "viewed_person_id": { "format": "uuid", - "nullable": true, - "type": "string" + "type": [ + "string", + "null" + ] }, "viewer_person_id": { "format": "uuid", @@ -62,312 +232,2432 @@ } }, "required": [ - "viewer_person_id", - "viewed_person_id", - "valid_from", - "reason" + "viewer_person_id" ], "type": "object" }, - "PersonsSeedRequest": { - "nullable": true, + "HistoryEntry": { + "description": "One decision in an account's history.", "properties": { - "mode": { - "nullable": true, + "author_person_id": { + "format": "uuid", + "type": "string" + }, + "by_operator": { + "description": "`true` when a person made this decision, `false` for automation.", + "type": "boolean" + }, + "person_id": { + "format": "uuid", + "type": "string" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "recorded_at": { "type": "string" } }, "required": [ - "mode" + "person_id", + "author_person_id", + "by_operator", + "recorded_at" ], "type": "object" }, - "ResolveProfileCommandModel": { + "ItemResult": { + "description": "What happened to one requested account.", "properties": { - "insight_source_id": { - "format": "uuid", - "nullable": true, + "account_id": { "type": "string" }, - "insight_source_type": { - "nullable": true, + "outcome": { + "description": "`applied` — the binding is in force;\n`already_decided` — the same operator decision was already recorded;\n`refused` — the write could not place the row (a concurrent operation\nheld the key); the account keeps its previous binding.\nOpen vocabulary: value-addressed items will report their skip reasons\n(`ambiguous_value`, `unknown_value`) here.", "type": "string" }, - "value": { - "nullable": true, + "source": { "type": "string" }, - "value_type": { - "description": "Which key `value` carries: an email (tenant-wide), a source-native account id (needs both source fields), or the canonical person UUID.", - "enum": [ - "email", - "id", - "person_id" - ], - "nullable": true, + "source_id": { + "format": "uuid", "type": "string" } }, "required": [ - "value_type", - "value", - "insight_source_type", - "insight_source_id" + "source", + "source_id", + "account_id", + "outcome" ], "type": "object" }, - "RevokeReasonModel": { - "nullable": true, + "MergeRequest": { "properties": { - "reason": { - "nullable": true, + "comment": { + "type": "string" + }, + "source_person_id": { + "description": "The person being absorbed — its accounts move to the target.", + "format": "uuid", + "type": "string" + }, + "target_person_id": { + "description": "The surviving person, named explicitly by the operator.", + "format": "uuid", "type": "string" } }, "required": [ - "reason" + "source_person_id", + "target_person_id" ], "type": "object" }, - "VisiblePersonsCommandModel": { + "PersonAccountEntry": { "properties": { - "person_ids": { - "items": { - "format": "uuid", - "type": "string" - }, - "maxItems": 1000, - "minItems": 1, - "type": "array" + "account_id": { + "type": "string" + }, + "bound_by_operator": { + "description": "`true` when the account's current binding was made by a person.", + "type": "boolean" + }, + "email": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": "string" + }, + "source_id": { + "format": "uuid", + "type": "string" + }, + "username": { + "type": [ + "string", + "null" + ] } }, "required": [ - "person_ids" + "source", + "source_id", + "account_id", + "bound_by_operator" ], "type": "object" }, - "VisiblePersonsResponse": { + "PersonAccountsResponse": { "properties": { - "visible": { + "accounts": { "items": { - "format": "uuid", - "type": "string" + "$ref": "#/components/schemas/PersonAccountEntry" }, "type": "array" + }, + "person_id": { + "format": "uuid", + "type": "string" } }, "required": [ - "visible" + "person_id", + "accounts" ], "type": "object" - } - } - }, - "info": { - "description": "Resolves people, org-chart parent/subordinates, roles, and row-level visibility for Insight. Backed by MariaDB (identity tables) with a ClickHouse-sourced bulk re-seed. Fronted by the API Gateway.", - "title": "Identity API", - "version": "1.0.0" - }, - "openapi": "3.0.1", - "paths": { - "/health": { - "get": { - "responses": { - "200": { - "description": "OK" - } - }, - "tags": [ - "Insight.Identity.Api" - ] - } - }, - "/healthz": { - "get": { - "responses": { - "200": { - "description": "OK" - } - }, - "tags": [ - "Insight.Identity.Api" - ] - } - }, - "/v1/person-roles": { - "get": { - "parameters": [ - { - "in": "query", - "name": "person", - "schema": { - "format": "uuid", - "type": "string" - } + }, + "PersonResponse": { + "description": "A person node in the org tree (subordinate of a profile), matching the .NET\n`PersonResponse`. Unlike `ProfileResponse`, the attribute fields are plain\nstrings (empty when absent, not omitted) and the `supervisor_*`/`parent_*`\nfields serialize as `null` rather than being dropped.", + "properties": { + "department": { + "type": "string" }, - { - "in": "query", - "name": "role", - "schema": { - "format": "uuid", - "type": "string" - } + "display_name": { + "type": "string" }, - { - "in": "query", - "name": "active", - "schema": { - "type": "boolean" - } + "division": { + "type": "string" }, - { - "in": "query", - "name": "limit", - "schema": { - "format": "int32", - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "OK" + "email": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "job_title": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "parent_email": { + "type": [ + "string", + "null" + ] + }, + "parent_id": { + "type": [ + "string", + "null" + ] + }, + "parent_person_id": { + "format": "uuid", + "type": [ + "string", + "null" + ] + }, + "person_id": { + "format": "uuid", + "type": "string" + }, + "status": { + "type": "string" + }, + "subordinates": { + "items": { + "$ref": "#/components/schemas/PersonResponse" + }, + "type": "array" + }, + "supervisor_email": { + "type": [ + "string", + "null" + ] + }, + "supervisor_name": { + "type": [ + "string", + "null" + ] } }, - "tags": [ - "Insight.Identity.Api" - ] + "required": [ + "person_id", + "email", + "display_name", + "first_name", + "last_name", + "department", + "division", + "job_title", + "status", + "subordinates" + ], + "type": "object" }, - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreatePersonRoleCommandModel" - } - } + "PersonRoleListResponse": { + "description": "List wrapper.", + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/PersonRoleResponse" + }, + "type": "array" }, - "required": true - }, - "responses": { - "200": { - "description": "OK" + "next_cursor": { + "description": "Wire parity with the .NET `ListResponse`: the cursor is declared\nbut pagination is not implemented — always `null` (both\nimplementations return every row; consumers already tolerate it).", + "type": [ + "string", + "null" + ] } }, - "tags": [ - "Insight.Identity.Api" - ] - } - }, - "/v1/person-roles/{id}": { - "delete": { - "parameters": [ - { - "in": "path", - "name": "id", + "required": [ + "items" + ], + "type": "object" + }, + "PersonRoleResponse": { + "description": "One role assignment.", + "properties": { + "author_person_id": { + "format": "uuid", + "type": "string" + }, + "created_at": { + "type": "string" + }, + "insight_tenant_id": { + "format": "uuid", + "type": "string" + }, + "person_id": { + "format": "uuid", + "type": "string" + }, + "person_role_id": { + "format": "uuid", + "type": "string" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "role_id": { + "format": "uuid", + "type": "string" + }, + "valid_from": { + "type": "string" + }, + "valid_to": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "person_role_id", + "insight_tenant_id", + "person_id", + "role_id", + "valid_from", + "author_person_id", + "created_at" + ], + "type": "object" + }, + "PersonsSeedListResponse": { + "description": "List response wrapper (typed for OpenAPI).", + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/PersonsSeedOperationResponse" + }, + "type": "array" + }, + "next_cursor": { + "description": "Wire parity with the .NET `ListResponse`: the cursor is declared\nbut pagination is not implemented — always `null` (both\nimplementations return every row; consumers already tolerate it).", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "PersonsSeedOperationResponse": { + "description": "One operation's status. Wire shape mirrors the .NET\n`PersonsSeedOperationResponse`: `request` and `summary` are surfaced as\nparsed JSON (not double-encoded strings), the tenant/author ids are\nincluded, timestamps are ISO-8601, and null fields are emitted (the .NET\nserializer does not drop nulls).", + "properties": { + "author_person_id": { + "format": "uuid", + "type": "string" + }, + "completed_at": { + "type": [ + "string", + "null" + ] + }, + "error_message": { + "type": [ + "string", + "null" + ] + }, + "insight_tenant_id": { + "format": "uuid", + "type": "string" + }, + "operation_id": { + "format": "uuid", + "type": "string" + }, + "operation_type": { + "type": "string" + }, + "request": { + "type": [ + "object", + "null" + ] + }, + "started_at": { + "type": "string" + }, + "status": { + "type": "string" + }, + "summary": { + "type": [ + "object", + "null" + ] + } + }, + "required": [ + "operation_id", + "operation_type", + "status", + "insight_tenant_id", + "author_person_id", + "started_at" + ], + "type": "object" + }, + "PersonsSyncListResponse": { + "description": "List response wrapper (typed for OpenAPI). `next_cursor` is declared but\nalways `null` — same non-paginating contract as the seed journal.", + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/PersonsSyncOperationResponse" + }, + "type": "array" + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "PersonsSyncOperationResponse": { + "description": "One operation's status. Wire shape matches the seed journal's:\n`request` and `summary` surfaced as parsed JSON, ISO-8601 timestamps,\nnull fields emitted.", + "properties": { + "author_person_id": { + "format": "uuid", + "type": "string" + }, + "completed_at": { + "type": [ + "string", + "null" + ] + }, + "error_message": { + "type": [ + "string", + "null" + ] + }, + "insight_tenant_id": { + "format": "uuid", + "type": "string" + }, + "operation_id": { + "format": "uuid", + "type": "string" + }, + "operation_type": { + "type": "string" + }, + "request": { + "type": [ + "object", + "null" + ] + }, + "started_at": { + "type": "string" + }, + "status": { + "type": "string" + }, + "summary": { + "description": "On completion: the [`SyncSummary`] — rows copied, `max_id` /\n`max_created_at` watermarks, `synced_at`.\n\n[`SyncSummary`]: crate::domain::sync_service::SyncSummary", + "type": [ + "object", + "null" + ] + } + }, + "required": [ + "operation_id", + "operation_type", + "status", + "insight_tenant_id", + "author_person_id", + "started_at" + ], + "type": "object" + }, + "Problem": { + "description": "RFC 9457 problem+json. `context` varies by error category.", + "properties": { + "context": { + "type": "object" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "format": "int32", + "type": "integer" + }, + "title": { + "type": "string" + }, + "trace_id": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "title", + "status", + "detail", + "context" + ], + "type": "object" + }, + "ProfileIdEntry": { + "description": "One source-native account id bound to the person — the latest\n`value_type='id'` observation per source instance. Ported from the .NET\n`ProfileIdEntry`.", + "properties": { + "insight_source_id": { + "format": "uuid", + "type": "string" + }, + "insight_source_type": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "insight_source_type", + "insight_source_id", + "value" + ], + "type": "object" + }, + "ProfileResponse": { + "description": "Response body of `POST /v1/profiles` — the resolved person's profile:\ncurrent attributes, the org tree (`supervisor_*` / `parent_*` /\n`subordinates[]`), and every current source-native id (`ids[]`). Null\nattribute fields are omitted from JSON; `subordinates`/`ids` are always\npresent (empty when none), matching the .NET contract.", + "properties": { + "department": { + "type": [ + "string", + "null" + ] + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "division": { + "type": [ + "string", + "null" + ] + }, + "email": { + "type": [ + "string", + "null" + ] + }, + "employee_id": { + "type": [ + "string", + "null" + ] + }, + "first_name": { + "type": [ + "string", + "null" + ] + }, + "ids": { + "description": "Every current source-native id for the person (one per source instance).\nAlways serialized — an empty array when the person has no ids — matching\nthe .NET contract (unlike the attributes above, which are omitted).", + "items": { + "$ref": "#/components/schemas/ProfileIdEntry" + }, + "type": "array" + }, + "insight_tenant_id": { + "format": "uuid", + "type": "string" + }, + "job_title": { + "type": [ + "string", + "null" + ] + }, + "last_name": { + "type": [ + "string", + "null" + ] + }, + "parent_email": { + "type": [ + "string", + "null" + ] + }, + "parent_id": { + "type": [ + "string", + "null" + ] + }, + "parent_person_id": { + "format": "uuid", + "type": [ + "string", + "null" + ] + }, + "person_id": { + "format": "uuid", + "type": "string" + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "subordinates": { + "description": "Recursive subordinates subtree (direct reports and their reports), on the\nconfigured `org_chart` source. Always serialized (empty when none).", + "items": { + "$ref": "#/components/schemas/PersonResponse" + }, + "type": "array" + }, + "supervisor_email": { + "type": [ + "string", + "null" + ] + }, + "supervisor_name": { + "type": [ + "string", + "null" + ] + }, + "username": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "person_id", + "insight_tenant_id", + "subordinates", + "ids" + ], + "type": "object" + }, + "QueueItemResponse": { + "properties": { + "account_id": { + "type": "string" + }, + "candidates": { + "description": "Persons this account could belong to, if any are known.", + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "email": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "description": "`contested` | `binding_conflict` | `no_evidence`.", + "type": "string" + }, + "source": { + "type": "string" + }, + "source_id": { + "format": "uuid", + "type": "string" + }, + "username": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "source", + "source_id", + "account_id", + "candidates" + ], + "type": "object" + }, + "ResolutionRatesResponse": { + "description": "Share of observed accounts per resolution state — the operator-visible match\nrate.", + "properties": { + "bound": { + "minimum": 0, + "type": "integer" + }, + "excluded": { + "minimum": 0, + "type": "integer" + }, + "no_evidence": { + "minimum": 0, + "type": "integer" + }, + "observed": { + "minimum": 0, + "type": "integer" + }, + "pending": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "observed", + "bound", + "pending", + "no_evidence", + "excluded" + ], + "type": "object" + }, + "ResolveProfileRequest": { + "description": "Body of `POST /v1/profiles`. `value_type = \"email\"` matches across all\nsources for the tenant; `value_type = \"id\"` matches a source-native account\nid within one source instance (needs `insight_source_type` + `insight_source_id`);\n`value_type = \"person_id\"` takes the canonical person UUID itself — the key\nthe metrics runtime and its routes use since the identity cutover.", + "properties": { + "insight_source_id": { + "description": "Required when `value_type = \"id\"`.", + "format": "uuid", + "type": [ + "string", + "null" + ] + }, + "insight_source_type": { + "description": "Required when `value_type = \"id\"` — the source instance to scope to.", + "type": [ + "string", + "null" + ] + }, + "value": { + "type": "string" + }, + "value_type": { + "type": "string" + } + }, + "required": [ + "value_type", + "value" + ], + "type": "object" + }, + "RoleListResponse": { + "description": "List wrapper.", + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/RoleResponse" + }, + "type": "array" + }, + "next_cursor": { + "description": "Wire parity with the .NET `ListResponse`: the cursor is declared\nbut pagination is not implemented — always `null` (both\nimplementations return every row; consumers already tolerate it).", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "RoleResponse": { + "description": "One role in the catalogue.", + "properties": { + "name": { + "type": "string" + }, + "role_id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "role_id", + "name" + ], + "type": "object" + }, + "SubchartForestResponse": { + "description": "`{ \"roots\": [ … ] }` — forest wrapper (#344). Empty when the caller has no\nvisible-in-source membership.", + "properties": { + "roots": { + "items": { + "$ref": "#/components/schemas/SubchartNode" + }, + "type": "array" + } + }, + "required": [ + "roots" + ], + "type": "object" + }, + "SubchartNode": { + "description": "One node in the org subchart tree.", + "properties": { + "display_name": { + "type": [ + "string", + "null" + ] + }, + "email": { + "type": [ + "string", + "null" + ] + }, + "job_title": { + "type": [ + "string", + "null" + ] + }, + "person_id": { + "format": "uuid", + "type": "string" + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "subordinates": { + "items": { + "$ref": "#/components/schemas/SubchartNode" + }, + "type": "array" + } + }, + "required": [ + "person_id", + "subordinates" + ], + "type": "object" + }, + "SubchartResponse": { + "description": "`{ \"root\": { … } }` — single-root wrapper (locked by the #348 acceptance\ncriteria so the response can gain sibling fields without breaking clients).", + "properties": { + "root": { + "$ref": "#/components/schemas/SubchartNode" + } + }, + "required": [ + "root" + ], + "type": "object" + }, + "VisibilityListResponse": { + "description": "List wrapper.", + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/VisibilityResponse" + }, + "type": "array" + }, + "next_cursor": { + "description": "Wire parity with the .NET `ListResponse`: the cursor is declared\nbut pagination is not implemented — always `null` (both\nimplementations return every row; consumers already tolerate it).", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "VisibilityResponse": { + "description": "One visibility grant.", + "properties": { + "author_person_id": { + "format": "uuid", + "type": "string" + }, + "created_at": { + "type": "string" + }, + "insight_tenant_id": { + "format": "uuid", + "type": "string" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "valid_from": { + "type": "string" + }, + "valid_to": { + "type": [ + "string", + "null" + ] + }, + "viewed_person_id": { + "format": "uuid", + "type": [ + "string", + "null" + ] + }, + "viewer_person_id": { + "format": "uuid", + "type": "string" + }, + "visibility_id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "visibility_id", + "insight_tenant_id", + "viewer_person_id", + "valid_from", + "author_person_id", + "created_at" + ], + "type": "object" + }, + "VisiblePersonsRequest": { + "description": "Canonical person UUIDs to check (the metric runtime's key since the\nidentity cutover — the earlier email-based draft of this endpoint never\nshipped).", + "properties": { + "person_ids": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "person_ids" + ], + "type": "object" + }, + "VisiblePersonsResponse": { + "properties": { + "visible": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "visible" + ], + "type": "object" + } + }, + "securitySchemes": { + "bearerAuth": { + "bearerFormat": "JWT", + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "description": "Person identity for the product: profile resolution, the operator correction surface over account-to-person bindings, org-chart reads, roles and visibility, plus the persons-seed and persons-sync operation journals. The API Gateway mounts this service at /api/identity.", + "title": "Insight Identity Resolution API", + "version": "1.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/v1/person-roles": { + "get": { + "operationId": "identity_resolution.person_roles.list", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonRoleListResponse" + } + } + }, + "description": "Assignments" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List role assignments (admin)" + }, + "post": { + "operationId": "identity_resolution.person_roles.create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePersonRoleRequest" + } + } + }, + "description": "Assignment to create", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonRoleResponse" + } + } + }, + "description": "Created assignment" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Grant a role to a person (admin)" + } + }, + "/v1/person-roles/{id}": { + "delete": { + "operationId": "identity_resolution.person_roles.delete", + "parameters": [ + { + "description": "Role assignment id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Assignment revoked" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Revoke a role assignment (admin)" + } + }, + "/v1/persons-seed": { + "get": { + "operationId": "identity_resolution.persons_seed.list", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonsSeedListResponse" + } + } + }, + "description": "Operations" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List persons-seed operations" + } + }, + "/v1/persons-seed/{id}": { + "get": { + "operationId": "identity_resolution.persons_seed.get", + "parameters": [ + { + "description": "Operation id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonsSeedOperationResponse" + } + } + }, + "description": "Operation status" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get a persons-seed operation" + } + }, + "/v1/persons-sync": { + "get": { + "operationId": "identity_resolution.persons_sync.list", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonsSyncListResponse" + } + } + }, + "description": "Operations" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List persons-sync operations" + } + }, + "/v1/persons-sync/{id}": { + "get": { + "operationId": "identity_resolution.persons_sync.get", + "parameters": [ + { + "description": "Operation id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonsSyncOperationResponse" + } + } + }, + "description": "Operation status" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get a persons-sync operation" + } + }, + "/v1/profiles": { + "post": { + "operationId": "identity_resolution.profiles.resolve", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveProfileRequest" + } + } + }, + "description": "Identity to resolve", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileResponse" + } + } + }, + "description": "Resolved person" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Resolve a profile by email or source-native id" + } + }, + "/v1/resolution/accounts/{source}/{source_id}/{account_id}": { + "get": { + "operationId": "identity_resolution.resolution.account_binding", + "parameters": [ + { + "description": "Connector type, e.g. `github`", + "in": "path", + "name": "source", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Connector instance id", + "in": "path", + "name": "source_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Account id within that connector instance", + "in": "path", + "name": "account_id", "required": true, "schema": { - "format": "uuid", "type": "string" } } ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountBindingResponse" + } + } + }, + "description": "Binding and history" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Current binding of an account and every decision behind it" + } + }, + "/v1/resolution/attention": { + "get": { + "operationId": "identity_resolution.resolution.attention", + "parameters": [ + { + "description": "Cap on returned items (1..=1000, default 100). The rates always cover every observed account, whatever the cap.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AttentionResponse" + } + } + }, + "description": "Queue items and rates" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Accounts awaiting an operator decision, with the resolution rates" + } + }, + "/v1/resolution/bind": { + "post": { + "operationId": "identity_resolution.resolution.bind", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BindRequest" + } + } + }, + "description": "Bindings to record", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CorrectionResponse" + } + } + }, + "description": "Per-account outcomes" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Bind accounts to persons (single or bulk; also confirms an automatic binding)" + } + }, + "/v1/resolution/detach": { + "post": { + "operationId": "identity_resolution.resolution.detach", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountRequest" + } + } + }, + "description": "Account to detach", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CorrectionResponse" + } + } + }, + "description": "Outcome and the new person id" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Detach an account into a freshly minted person" + } + }, + "/v1/resolution/exclude": { + "post": { + "operationId": "identity_resolution.resolution.exclude", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RevokeReasonModel" + "$ref": "#/components/schemas/AccountRequest" } } - } + }, + "description": "Account to exclude", + "required": true }, "responses": { "200": { - "description": "OK" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CorrectionResponse" + } + } + }, + "description": "Outcome" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" } }, - "tags": [ - "Insight.Identity.Api" - ] - } - }, - "/v1/persons-seed": { - "get": { - "parameters": [ - { - "in": "query", - "name": "status", - "schema": { - "type": "string" - } - }, + "security": [ { - "in": "query", - "name": "limit", - "schema": { - "format": "int32", - "type": "integer" - } + "bearerAuth": [] } ], - "responses": { - "200": { - "description": "OK" - } - }, - "tags": [ - "Insight.Identity.Api" - ] - }, + "summary": "Exclude an account as not a person (bot, CI, service account)" + } + }, + "/v1/resolution/merge": { "post": { + "operationId": "identity_resolution.resolution.merge", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PersonsSeedRequest" + "$ref": "#/components/schemas/MergeRequest" } } - } + }, + "description": "Persons to merge", + "required": true }, "responses": { "200": { - "description": "OK" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CorrectionResponse" + } + } + }, + "description": "Per-account outcomes" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" } }, - "tags": [ - "Insight.Identity.Api" - ] - } - }, - "/v1/persons-seed/{id}": { - "get": { - "parameters": [ + "security": [ { - "in": "path", - "name": "id", - "required": true, - "schema": { - "format": "uuid", - "type": "string" - } + "bearerAuth": [] } ], - "responses": { - "200": { - "description": "OK" - } - }, - "tags": [ - "Insight.Identity.Api" - ] + "summary": "Merge two persons: rebind every account of the absorbed person" } }, - "/v1/persons/{email}": { + "/v1/resolution/persons/{person_id}/accounts": { "get": { + "operationId": "identity_resolution.resolution.person_accounts", "parameters": [ { + "description": "Person whose accounts to list", "in": "path", - "name": "email", + "name": "person_id", "required": true, "schema": { "type": "string" @@ -376,267 +2666,887 @@ ], "responses": { "200": { - "description": "OK" - } - }, - "tags": [ - "Insight.Identity.Api" - ] - } - }, - "/v1/profiles": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResolveProfileCommandModel" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonAccountsResponse" + } } - } + }, + "description": "Accounts of the person" }, - "required": true - }, - "responses": { - "200": { - "description": "OK" + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" } }, - "tags": [ - "Insight.Identity.Api" - ] + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Every account bound to a person, with the values behind each link" } }, "/v1/roles": { "get": { + "operationId": "identity_resolution.roles.list", "responses": { "200": { - "description": "OK" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoleListResponse" + } + } + }, + "description": "Roles" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" } }, - "tags": [ - "Insight.Identity.Api" - ] + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List roles (admin)" }, "post": { + "operationId": "identity_resolution.roles.create", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateRoleCommandModel" + "$ref": "#/components/schemas/CreateRoleRequest" } } }, + "description": "Role to create", "required": true }, "responses": { - "200": { - "description": "OK" + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoleResponse" + } + } + }, + "description": "Created role" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" } }, - "tags": [ - "Insight.Identity.Api" - ] + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Create a role (admin)" } }, "/v1/roles/{id}": { "delete": { + "operationId": "identity_resolution.roles.delete", "parameters": [ { + "description": "Role id", "in": "path", "name": "id", "required": true, "schema": { - "format": "uuid", "type": "string" } } ], "responses": { - "200": { - "description": "OK" + "204": { + "description": "Role deleted" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" } }, - "tags": [ - "Insight.Identity.Api" - ] + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Delete a role (admin)" } }, "/v1/subchart": { "get": { - "parameters": [ - { - "in": "query", - "name": "depth", - "schema": { - "format": "int32", - "type": "integer" - } - }, - { - "in": "query", - "name": "valid_at", - "schema": { - "format": "date-time", - "type": "string" - } - } - ], + "operationId": "identity_resolution.subchart.forest", "responses": { "200": { - "description": "OK" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubchartForestResponse" + } + } + }, + "description": "Visible forest" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" } }, - "tags": [ - "Insight.Identity.Api" - ] + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Org forest the caller can see" } }, - "/v1/subchart/{personId}": { + "/v1/subchart/{person_id}": { "get": { + "operationId": "identity_resolution.subchart.get", "parameters": [ { + "description": "Person the subtree is rooted at", "in": "path", - "name": "personId", + "name": "person_id", "required": true, "schema": { - "format": "uuid", "type": "string" } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubchartResponse" + } + } + }, + "description": "Subchart" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" }, - { - "in": "query", - "name": "depth", - "schema": { - "format": "int32", - "type": "integer" - } + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ { - "in": "query", - "name": "valid_at", - "schema": { - "format": "date-time", - "type": "string" - } + "bearerAuth": [] } ], - "responses": { - "200": { - "description": "OK" - } - }, - "tags": [ - "Insight.Identity.Api" - ] + "summary": "Depth-bounded org subtree rooted at a person" } }, "/v1/visibility": { "get": { - "parameters": [ - { - "in": "query", - "name": "viewer", - "schema": { - "format": "uuid", - "type": "string" - } + "operationId": "identity_resolution.visibility.list", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VisibilityListResponse" + } + } + }, + "description": "Grants" }, - { - "in": "query", - "name": "viewed", - "schema": { - "format": "uuid", - "type": "string" - } + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" }, - { - "in": "query", - "name": "active", - "schema": { - "type": "boolean" - } + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ { - "in": "query", - "name": "limit", - "schema": { - "format": "int32", - "type": "integer" - } + "bearerAuth": [] } ], - "responses": { - "200": { - "description": "OK" - } - }, - "tags": [ - "Insight.Identity.Api" - ] + "summary": "List visibility grants (admin)" }, "post": { + "operationId": "identity_resolution.visibility.create", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateVisibilityCommandModel" + "$ref": "#/components/schemas/CreateVisibilityRequest" } } }, + "description": "Grant to create", "required": true }, "responses": { - "200": { - "description": "OK" + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VisibilityResponse" + } + } + }, + "description": "Created grant" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" } }, - "tags": [ - "Insight.Identity.Api" - ] + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Create a visibility grant (admin)" } }, "/v1/visibility/{id}": { "delete": { + "operationId": "identity_resolution.visibility.delete", "parameters": [ { + "description": "Visibility grant id", "in": "path", "name": "id", "required": true, "schema": { - "format": "uuid", "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RevokeReasonModel" + "responses": { + "204": { + "description": "Grant revoked" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } } - } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" } }, - "responses": { - "200": { - "description": "OK" + "security": [ + { + "bearerAuth": [] } - }, - "tags": [ - "Insight.Identity.Api" - ] + ], + "summary": "Revoke a visibility grant (admin)" } }, "/v1/visible-persons": { "post": { + "operationId": "identity_resolution.visible_persons.create", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VisiblePersonsCommandModel" + "$ref": "#/components/schemas/VisiblePersonsRequest" } } }, + "description": "Person ids to check", "required": true }, "responses": { @@ -648,18 +3558,86 @@ } } }, - "description": "OK" + "description": "Visible subset" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" } }, - "tags": [ - "Insight.Identity.Api" - ] + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Filter person ids to the ones the caller may see" } } - }, - "tags": [ - { - "name": "Insight.Identity.Api" - } - ] + } } From 2a1721bd6fbf9ba8e2c76c5879c39071239f8d3f Mon Sep 17 00:00:00 2001 From: Sergei Mozhaev Date: Sun, 9 Aug 2026 07:29:13 +0300 Subject: [PATCH 3/7] test(stand): generate the identity models from the contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generator carried a tripwire for exactly this moment: identity was listed as Untrusted because its committed document was the .NET one, and `--check` fails the entry the moment that stops being true. It now does, so promote it to Generated. The models stop being a transcription of the Rust DTOs and become a contract test — a validation failure now says the service and its published document disagree, which is the whole point of generating them. Two consequences handled here. The `/internal/persons/*` resolvers are excluded from the document by design, so `IdentityValue` has no generated counterpart and moves to `identity_internal.py`, still hand-written. And the two operation journals, field-identical when one hand-written model served both, now have a model each — the admin listing test names the right one per path. The names are the contract's; the package re-exports them under the ones the suite already uses, so the rename stops at the schemas package. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Sergei Mozhaev --- tests/generate_schemas.py | 40 +- tests/lib/insight_stand/coverage.py | 14 +- tests/pyproject.toml | 6 +- tests/stand/api/identity/test_admin.py | 7 +- tests/stand/api/schemas/__init__.py | 97 ++- tests/stand/api/schemas/identity.py | 636 +++++++++++++------ tests/stand/api/schemas/identity_internal.py | 34 + 7 files changed, 605 insertions(+), 229 deletions(-) create mode 100644 tests/stand/api/schemas/identity_internal.py diff --git a/tests/generate_schemas.py b/tests/generate_schemas.py index d99a5b63d..f91d8ee0d 100644 --- a/tests/generate_schemas.py +++ b/tests/generate_schemas.py @@ -132,6 +132,32 @@ ''' +IDENTITY_HEADER = '''"""Identity Resolution response shapes — GENERATED, do not edit. + +Regenerate with: + + uv run --project tests --frozen python tests/generate_schemas.py + +Source: `docs/components/backend/identity-resolution/openapi.json`, generated +offline by `cargo run -p identity-resolution -- openapi` and drift-gated in CI +beside the analytics and authenticator documents. Until that subcommand existed +this module was hand-written from the Rust DTOs, because the committed contract +was still the retired .NET one; these models now describe the structs that +serialize the wire, so a validation failure is a contract disagreement rather +than a stale transcription. + +The names are the contract's, not the suite's: `SubchartResponse` where the +hand-written module said `Subchart`. `stand/api/schemas/__init__.py` re-exports +them under the names the tests already use, so the rename stops at this package. + +BODIES ONLY — no status code comes from this document. Its per-operation lists +are stamped uniformly by `.standard_errors` and describe nothing (#1669), the +same limitation the analytics and authenticator documents carry. +""" + +''' + + @dataclass(frozen=True) class Generated: """A service whose document describes bodies: models are generated and committed.""" @@ -229,19 +255,11 @@ def declares_only_200(spec_path: Path) -> str | None: spec=_SPECS / "gateway" / "openapi.json", reason="publishes no OpenAPI document (NGINX + Lua; `GET /healthz` is its only own route)", ), - # The committed document is still the retired .NET one — it declares routes - # the service does not serve, omits ones it does, and lists only `200` - # everywhere (enumerated in `stand/api/schemas/__init__.py`). `identity.py` is - # hand-written from the Rust DTOs until identity grows an `openapi` - # subcommand of its own, as analytics and authenticator have. - # - # NOT `Bodyless`: the document does describe one body (`POST - # /v1/visible-persons`), and body count says nothing about provenance. - Untrusted( + Generated( name="identity-resolution", spec=_SPECS / "identity-resolution" / "openapi.json", - reason="the committed document is the retired .NET contract", - still_the_wrong_document=declares_only_200, + output=_SCHEMAS / "identity.py", + header=IDENTITY_HEADER, ), ) diff --git a/tests/lib/insight_stand/coverage.py b/tests/lib/insight_stand/coverage.py index 0c00158b4..b7af5d15e 100644 --- a/tests/lib/insight_stand/coverage.py +++ b/tests/lib/insight_stand/coverage.py @@ -17,12 +17,14 @@ precisely the mistake this gate exists to prevent. 2. Did every status code the analytics CONTRACT declares get observed? -Only analytics is gated on its spec. The committed identity document is the -retired .NET contract — it declares only `200` on every operation, lists routes -the service answers 404 for, and omits ones it serves — so gating against it -would demand codes that cannot exist and miss everything real. Identity is held -to (1) instead, which needs no trustworthy document. Same judgement, and for the -same reason, as `Untrusted` in `tests/generate_schemas.py`. +Only analytics is gated on its spec. That was once because the committed +identity document was the retired .NET contract; it no longer is — identity +emits its own document and CI drift-gates it beside analytics. What still blocks +(2) for identity is the other side of the comparison: every status code the +document declares has to be OBSERVED, and `.standard_errors` stamps the full +error set onto every operation. Identity stays held to (1) until the suite +either observes those codes or the gate learns to discount the stamped ones +(#1669), which is a change to the gate rather than to this note. This is a port of `src/ingestion/tests/e2e/lib/api_coverage.py`. The universal table agrees with it — the rig dropped 401 from its own exclusions once its host diff --git a/tests/pyproject.toml b/tests/pyproject.toml index c9dbfa9ee..2f98faaa5 100644 --- a/tests/pyproject.toml +++ b/tests/pyproject.toml @@ -90,7 +90,11 @@ target-version = "py313" # from this one because `ruff check --fix` would edit the files and then # `generate_schemas.py --check` reports them stale — the two checks would fight # every time, and the drift check is the one that matters. -extend-exclude = ["stand/api/schemas/analytics.py", "stand/api/schemas/authenticator.py"] +extend-exclude = [ + "stand/api/schemas/analytics.py", + "stand/api/schemas/authenticator.py", + "stand/api/schemas/identity.py", +] [tool.ruff.lint] # Same pragmatic strict set as src/ingestion/tools/seed/pyproject.toml. diff --git a/tests/stand/api/identity/test_admin.py b/tests/stand/api/identity/test_admin.py index 177ef49d5..73c32092a 100644 --- a/tests/stand/api/identity/test_admin.py +++ b/tests/stand/api/identity/test_admin.py @@ -26,13 +26,14 @@ from .. import scratch from ..schemas import ( - OperationList, PersonRole, PersonRoleList, ProblemDocument, Role, RoleList, + SeedOperationList, SubchartForest, + SyncOperationList, Visibility, VisibilityList, ) @@ -45,8 +46,8 @@ ("/v1/roles", RoleList), ("/v1/person-roles", PersonRoleList), ("/v1/visibility", VisibilityList), - ("/v1/persons-seed", OperationList), - ("/v1/persons-sync", OperationList), + ("/v1/persons-seed", SeedOperationList), + ("/v1/persons-sync", SyncOperationList), ) diff --git a/tests/stand/api/schemas/__init__.py b/tests/stand/api/schemas/__init__.py index 6f1e3cb47..2f762f184 100644 --- a/tests/stand/api/schemas/__init__.py +++ b/tests/stand/api/schemas/__init__.py @@ -4,20 +4,20 @@ * `common.py` — the error envelope and the listing wrapper, hand-written from the bodies the stand returns. -* `identity.py` — hand-written from the Rust DTOs in - `src/backend/services/identity-resolution/src/api/`. **Not** generated, - because the committed contract for that service is still the .NET document: - it declares `/v1/persons/{email}` (which identity answers 404 for), declares - `POST /v1/persons-seed` (405), spells the - subchart parameter `{personId}` where the service serves `{person_id}`, omits - both persons-sync operations, and lists only `200` for all 18 operations. - Generating from it would record every one of those errors as fact. -* `analytics.py`, `authenticator.py` — GENERATED from documents the services - emit themselves (`cargo run -p -- openapi`) and CI drift-gates in - `.github/workflows/openapi-specs.yml`, so the models describe the very structs - that serialize the wire. `authenticator.py` is currently just the error - envelope, because that document declares every `/auth/*` success body as a bare - `type: object`. +* `identity_internal.py` — hand-written from the Rust DTO, because the two + `/internal/persons/*` S2S routes are registered raw and stay out of the + generated document by design. +* `analytics.py`, `authenticator.py`, `identity.py` — GENERATED from documents + the services emit themselves (`cargo run -p -- openapi`) and CI + drift-gates in `.github/workflows/openapi-specs.yml`, so the models describe + the very structs that serialize the wire. `authenticator.py` is currently just + the error envelope, because that document declares every `/auth/*` success + body as a bare `type: object`. + + The generated identity models carry the contract's names — `SubchartResponse`, + `ProfileResponse` — where the hand-written module said `Subchart`, `Profile`. + This package re-exports them under the names the suite already uses, so the + rename stops here. **Bodies from the spec; status codes never.** The per-operation status-code lists are stamped uniformly by `.standard_errors` and describe nothing (#1669) @@ -26,9 +26,8 @@ That asymmetry is a real difference in what the models mean. The generated ones are a **contract test** — a mismatch says the service and its published contract -disagree. The hand-written ones are a **description of observed behaviour**, and -they should be deleted in favour of generated ones once the identity contract is -regenerated from the service. +disagree. The hand-written ones are a **description of observed behaviour**, kept +only where no document describes the route at all. The strictness follows from that. Generated models set `extra="forbid"`: they are regenerated in the same change that adds a field, so strictness costs nothing and @@ -65,25 +64,59 @@ ProblemDocument, ) from .identity import ( - IdentityValue, - Operation, - OperationList, - PersonRole, - PersonRoleList, - Profile, - Role, - RoleList, - Subchart, - SubchartForest, + AccountBindingResponse, + AttentionResponse, + CorrectionResponse, + PersonAccountsResponse, SubchartNode, - Visibility, - VisibilityList, - VisiblePersons, ) +from .identity import ( + PersonRoleListResponse as PersonRoleList, +) +from .identity import ( + PersonRoleResponse as PersonRole, +) +from .identity import ( + PersonsSeedListResponse as SeedOperationList, +) +from .identity import ( + PersonsSeedOperationResponse as Operation, +) +from .identity import ( + PersonsSyncListResponse as SyncOperationList, +) +from .identity import ( + ProfileResponse as Profile, +) +from .identity import ( + RoleListResponse as RoleList, +) +from .identity import ( + RoleResponse as Role, +) +from .identity import ( + SubchartForestResponse as SubchartForest, +) +from .identity import ( + SubchartResponse as Subchart, +) +from .identity import ( + VisibilityListResponse as VisibilityList, +) +from .identity import ( + VisibilityResponse as Visibility, +) +from .identity import ( + VisiblePersonsResponse as VisiblePersons, +) +from .identity_internal import IdentityValue __all__: Sequence[str] = ( "EXTRACTOR_REJECTION_CONTENT_TYPE", "PROBLEM_CONTENT_TYPE", + "AccountBindingResponse", + "AttentionResponse", + "CorrectionResponse", "CustomMetric", "CustomMetricInput", "CustomMetricListResponse", @@ -96,8 +129,8 @@ "MetricDefinitionListResponse", "MetricResultsResponse", "Operation", - "OperationList", "PeriodView", + "PersonAccountsResponse", "PersonRole", "PersonRoleList", "ProblemDocument", @@ -107,9 +140,11 @@ "RunResponse", "SavedQuery", "SavedQueryListResponse", + "SeedOperationList", "Subchart", "SubchartForest", "SubchartNode", + "SyncOperationList", "Visibility", "VisibilityList", "VisiblePersons", diff --git a/tests/stand/api/schemas/identity.py b/tests/stand/api/schemas/identity.py index 6b3e3d559..47f2c2c9e 100644 --- a/tests/stand/api/schemas/identity.py +++ b/tests/stand/api/schemas/identity.py @@ -1,251 +1,533 @@ -"""identity-resolution response shapes, hand-written from the Rust DTOs. - -Sources, field for field: - - domain/subchart.rs SubchartNode · SubchartResponse · SubchartForestResponse - domain/profile.rs ProfileResponse - api/roles.rs RoleResponse - api/person_roles.rs PersonRoleResponse - api/visibility.rs VisibilityResponse - api/seed.rs PersonsSeedOperationResponse - api/sync.rs PersonsSyncOperationResponse - -**Not generated**, and not from the committed contract — see `schemas/__init__.py` -for why `docs/components/backend/identity-resolution/openapi.json` cannot be -trusted. These models therefore describe OBSERVED behaviour, and `extra` is left -at its default so an added field does not fail the suite. When that contract is -regenerated from the service, this module should be deleted in favour of -generated models with `extra="forbid"`. - -Timestamps stay `str`. The service serialises them itself (`api/datetime.rs` -normalises to naive-UTC on the way in), and coercing to `datetime` here would -make the tests assert a parse this suite does not perform — the wire format is -the contract, not Python's reading of it. +"""Identity Resolution response shapes — GENERATED, do not edit. + +Regenerate with: + + uv run --project tests --frozen python tests/generate_schemas.py + +Source: `docs/components/backend/identity-resolution/openapi.json`, generated +offline by `cargo run -p identity-resolution -- openapi` and drift-gated in CI +beside the analytics and authenticator documents. Until that subcommand existed +this module was hand-written from the Rust DTOs, because the committed contract +was still the retired .NET one; these models now describe the structs that +serialize the wire, so a validation failure is a contract disagreement rather +than a stale transcription. + +The names are the contract's, not the suite's: `SubchartResponse` where the +hand-written module said `Subchart`. `stand/api/schemas/__init__.py` re-exports +them under the names the tests already use, so the rename stops at this package. + +BODIES ONLY — no status code comes from this document. Its per-operation lists +are stamped uniformly by `.standard_errors` and describe nothing (#1669), the +same limitation the analytics and authenticator documents carry. """ from __future__ import annotations - -from collections.abc import Sequence +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field from uuid import UUID +from typing import Any -from insight_stand import JsonValue -from pydantic import BaseModel, Field -from .common import ListResponse +class AccountRef(BaseModel): + """ + A source-native account, as named by the caller. + + Addressing by an observed value (e-mail / username) instead of the account + triple is the reserved extension for importing a prepared matching table: + the fields arrive optional, exactly one form is required per item, a value + resolving to zero or several active accounts is reported per item and never + guessed. The response already carries per-item outcomes, so adding it does + not change the shape of this contract. + """ + model_config = ConfigDict( + extra='forbid', + ) + id: str = Field(..., description='Account id within that instance.') + source: str = Field(..., description='Connector type, e.g. `github`.') + source_id: UUID = Field(..., description='Connector instance id.') + + +class AccountRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + account: AccountRef + comment: str | None = None + + +class BindItem(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + account: AccountRef + person_id: UUID -# --------------------------------------------------------------------------- -# Org subchart -# --------------------------------------------------------------------------- +class BindRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + bindings: list[BindItem] = Field(..., description='One or more bindings; a prepared matching table is submitted as one call.') + comment: str | None = None -class SubchartNode(BaseModel): - """One person in an org tree. Self-referential through `subordinates`. - Everything but `person_id` is nullable: a node exists because an `org_chart` - edge points at it, and the person's attributes are a separate observation - that may be absent. +class CreatePersonRoleRequest(BaseModel): """ - + Body of `POST /v1/person-roles` — grant a role to a person. + """ + model_config = ConfigDict( + extra='forbid', + ) person_id: UUID - email: str | None = None - display_name: str | None = None - job_title: str | None = None - status: str | None = None - subordinates: list[SubchartNode] = Field(default_factory=list) + reason: str | None = None + role_id: UUID + valid_from: AwareDatetime | None = Field(None, description='Optional assignment start; defaults to now when omitted. Accepts RFC-3339\n(`Z`/offset), zone-less, or date-only, normalised to naive-UTC.') + - def walk(self) -> list[SubchartNode]: - """This node and every descendant, at any depth.""" - found = [self] - for child in self.subordinates: - found += child.walk() - return found +class CreateRoleRequest(BaseModel): + """ + Body of `POST /v1/roles`. + """ + model_config = ConfigDict( + extra='forbid', + ) + name: str - def emails(self) -> set[str]: - """Every email in this subtree — the shape scope assertions compare.""" - return {node.email for node in self.walk() if node.email} +class CreateVisibilityRequest(BaseModel): + """ + Body of `POST /v1/visibility` — grant a viewer visibility over a target + (or the whole tree when `viewed_person_id` is omitted). + """ + model_config = ConfigDict( + extra='forbid', + ) + reason: str | None = None + valid_from: AwareDatetime | None = Field(None, description='Optional grant start; defaults to now when omitted. Accepts RFC-3339\n(`Z`/offset), zone-less, or date-only, normalised to naive-UTC.') + viewed_person_id: UUID | None = None + viewer_person_id: UUID -class SubchartForest(BaseModel): - """`GET /v1/subchart` — the forest the CALLER can see. - Empty when the caller has no visible membership, which is the normal state - for an account outside the org chart rather than an error. +class HistoryEntry(BaseModel): """ + One decision in an account's history. + """ + model_config = ConfigDict( + extra='forbid', + ) + author_person_id: UUID + by_operator: bool = Field(..., description='`true` when a person made this decision, `false` for automation.') + person_id: UUID + reason: str | None = None + recorded_at: str - roots: list[SubchartNode] = Field(default_factory=list) - def emails(self) -> set[str]: - return {email for root in self.roots for email in root.emails()} +class ItemResult(BaseModel): + """ + What happened to one requested account. + """ + model_config = ConfigDict( + extra='forbid', + ) + account_id: str + outcome: str = Field(..., description='`applied` — the binding is in force;\n`already_decided` — the same operator decision was already recorded;\n`refused` — the write could not place the row (a concurrent operation\nheld the key); the account keeps its previous binding.\nOpen vocabulary: value-addressed items will report their skip reasons\n(`ambiguous_value`, `unknown_value`) here.') + source: str + source_id: UUID + + +class MergeRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + comment: str | None = None + source_person_id: UUID = Field(..., description='The person being absorbed — its accounts move to the target.') + target_person_id: UUID = Field(..., description='The surviving person, named explicitly by the operator.') + + +class PersonAccountEntry(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + account_id: str + bound_by_operator: bool = Field(..., description="`true` when the account's current binding was made by a person.") + email: str | None = None + source: str + source_id: UUID + username: str | None = None -class Subchart(BaseModel): - """`GET /v1/subchart/{person_id}` — one named person's subtree. +class PersonAccountsResponse(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + accounts: list[PersonAccountEntry] + person_id: UUID - A `root` object rather than a bare node so the response can gain sibling - fields without breaking clients. + +class PersonResponse(BaseModel): + """ + A person node in the org tree (subordinate of a profile), matching the .NET + `PersonResponse`. Unlike `ProfileResponse`, the attribute fields are plain + strings (empty when absent, not omitted) and the `supervisor_*`/`parent_*` + fields serialize as `null` rather than being dropped. """ + model_config = ConfigDict( + extra='forbid', + ) + department: str + display_name: str + division: str + email: str + first_name: str + job_title: str + last_name: str + parent_email: str | None = None + parent_id: str | None = None + parent_person_id: UUID | None = None + person_id: UUID + status: str + subordinates: list[PersonResponse] + supervisor_email: str | None = None + supervisor_name: str | None = None - root: SubchartNode - def emails(self) -> set[str]: - return self.root.emails() +class PersonRoleResponse(BaseModel): + """ + One role assignment. + """ + model_config = ConfigDict( + extra='forbid', + ) + author_person_id: UUID + created_at: str + insight_tenant_id: UUID + person_id: UUID + person_role_id: UUID + reason: str | None = None + role_id: UUID + valid_from: str + valid_to: str | None = None -# --------------------------------------------------------------------------- -# Profiles -# --------------------------------------------------------------------------- +class PersonsSeedOperationResponse(BaseModel): + """ + One operation's status. Wire shape mirrors the .NET + `PersonsSeedOperationResponse`: `request` and `summary` are surfaced as + parsed JSON (not double-encoded strings), the tenant/author ids are + included, timestamps are ISO-8601, and null fields are emitted (the .NET + serializer does not drop nulls). + """ + model_config = ConfigDict( + extra='forbid', + ) + author_person_id: UUID + completed_at: str | None = None + error_message: str | None = None + insight_tenant_id: UUID + operation_id: UUID + operation_type: str + request: dict[str, Any] | None = None + started_at: str + status: str + summary: dict[str, Any] | None = None -class Profile(BaseModel): - """`POST /v1/profiles` — a person resolved by email or source-native id. +class PersonsSyncOperationResponse(BaseModel): + """ + One operation's status. Wire shape matches the seed journal's: + `request` and `summary` surfaced as parsed JSON, ISO-8601 timestamps, + null fields emitted. + """ + model_config = ConfigDict( + extra='forbid', + ) + author_person_id: UUID + completed_at: str | None = None + error_message: str | None = None + insight_tenant_id: UUID + operation_id: UUID + operation_type: str + request: dict[str, Any] | None = None + started_at: str + status: str + summary: dict[str, Any] | None = Field(None, description='On completion: the [`SyncSummary`] — rows copied, `max_id` /\n`max_created_at` watermarks, `synced_at`.\n\n[`SyncSummary`]: crate::domain::sync_service::SyncSummary') - Only the fields the tests assert are declared. The DTO carries many more - attribute fields, all omitted from JSON when null, and modelling them would - be describing the seed rather than the contract. + +class Problem(BaseModel): + """ + RFC 9457 problem+json. `context` varies by error category. """ + model_config = ConfigDict( + extra='forbid', + ) + context: dict[str, Any] + detail: str + instance: str | None = None + status: int + title: str + trace_id: str | None = None + type: str + + +class ProfileIdEntry(BaseModel): + """ + One source-native account id bound to the person — the latest + `value_type='id'` observation per source instance. Ported from the .NET + `ProfileIdEntry`. + """ + model_config = ConfigDict( + extra='forbid', + ) + insight_source_id: UUID + insight_source_type: str + value: str - person_id: UUID + +class ProfileResponse(BaseModel): + """ + Response body of `POST /v1/profiles` — the resolved person's profile: + current attributes, the org tree (`supervisor_*` / `parent_*` / + `subordinates[]`), and every current source-native id (`ids[]`). Null + attribute fields are omitted from JSON; `subordinates`/`ids` are always + present (empty when none), matching the .NET contract. + """ + model_config = ConfigDict( + extra='forbid', + ) + department: str | None = None + display_name: str | None = None + division: str | None = None + email: str | None = None + employee_id: str | None = None + first_name: str | None = None + ids: list[ProfileIdEntry] = Field(..., description='Every current source-native id for the person (one per source instance).\nAlways serialized — an empty array when the person has no ids — matching\nthe .NET contract (unlike the attributes above, which are omitted).') insight_tenant_id: UUID + job_title: str | None = None + last_name: str | None = None + parent_email: str | None = None + parent_id: str | None = None + parent_person_id: UUID | None = None + person_id: UUID + status: str | None = None + subordinates: list[PersonResponse] = Field(..., description='Recursive subordinates subtree (direct reports and their reports), on the\nconfigured `org_chart` source. Always serialized (empty when none).') + supervisor_email: str | None = None + supervisor_name: str | None = None + username: str | None = None + + +class QueueItemResponse(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + account_id: str + candidates: list[UUID] = Field(..., description='Persons this account could belong to, if any are known.') email: str | None = None - display_name: str | None = None - + kind: str = Field(..., description='`contested` | `binding_conflict` | `no_evidence`.') + source: str + source_id: UUID + username: str | None = None -class IdentityValue(BaseModel): - """`GET /internal/persons/by-email/{email}` — the login-bootstrap lookup. - NOT a `Profile`, though both are "a person looked up by email". This route - answers the identity VALUE that matched — the alias row, pointing at what it - resolved to — because at login the caller has an email and needs to learn - which person it belongs to, not to read that person's attributes. Hence - `insight_source_id` rather than `person_id`, and no tenant at all: the - tenant is exactly what is still unknown at that point. +class ResolutionRatesResponse(BaseModel): + """ + Share of observed accounts per resolution state — the operator-visible match + rate. + """ + model_config = ConfigDict( + extra='forbid', + ) + bound: int = Field(..., ge=0) + excluded: int = Field(..., ge=0) + no_evidence: int = Field(..., ge=0) + observed: int = Field(..., ge=0) + pending: int = Field(..., ge=0) +class ResolveProfileRequest(BaseModel): """ - - value_type: str + Body of `POST /v1/profiles`. `value_type = "email"` matches across all + sources for the tenant; `value_type = "id"` matches a source-native account + id within one source instance (needs `insight_source_type` + `insight_source_id`); + `value_type = "person_id"` takes the canonical person UUID itself — the key + the metrics runtime and its routes use since the identity cutover. + """ + model_config = ConfigDict( + extra='forbid', + ) + insight_source_id: UUID | None = Field(None, description='Required when `value_type = "id"`.') + insight_source_type: str | None = Field(None, description='Required when `value_type = "id"` — the source instance to scope to.') value: str - insight_source_type: str - insight_source_id: UUID + value_type: str -# --------------------------------------------------------------------------- -# Admin: roles, assignments, visibility -# --------------------------------------------------------------------------- +class RoleResponse(BaseModel): + """ + One role in the catalogue. + """ + model_config = ConfigDict( + extra='forbid', + ) + name: str + role_id: UUID -class Role(BaseModel): - """An entry in the global role catalogue. Deleted, not revoked.""" +class SubchartNode(BaseModel): + """ + One node in the org subchart tree. + """ + model_config = ConfigDict( + extra='forbid', + ) + display_name: str | None = None + email: str | None = None + job_title: str | None = None + person_id: UUID + status: str | None = None + subordinates: list[SubchartNode] - role_id: UUID - name: str +class SubchartResponse(BaseModel): + """ + `{ "root": { … } }` — single-root wrapper (locked by the #348 acceptance + criteria so the response can gain sibling fields without breaking clients). + """ + model_config = ConfigDict( + extra='forbid', + ) + root: SubchartNode -class PersonRole(BaseModel): - """A role assignment. Temporal: `DELETE` sets `valid_to` rather than removing. - `valid_to is None` is therefore the only meaning of "in force", and it is - what the leak sweep in `scratch.py` checks. +class VisibilityResponse(BaseModel): """ - - person_role_id: UUID + One visibility grant. + """ + model_config = ConfigDict( + extra='forbid', + ) + author_person_id: UUID + created_at: str insight_tenant_id: UUID - person_id: UUID - role_id: UUID + reason: str | None = None valid_from: str valid_to: str | None = None - author_person_id: UUID - reason: str | None = None - created_at: str + viewed_person_id: UUID | None = None + viewer_person_id: UUID + visibility_id: UUID - @property - def in_force(self) -> bool: - return self.valid_to is None +class VisiblePersonsRequest(BaseModel): + """ + Canonical person UUIDs to check (the metric runtime's key since the + identity cutover — the earlier email-based draft of this endpoint never + shipped). + """ + model_config = ConfigDict( + extra='forbid', + ) + person_ids: list[UUID] -class Visibility(BaseModel): - """A visibility grant. Temporal, exactly like `PersonRole`. - `viewed_person_id is None` is a grant over everything the viewer's source - membership covers rather than one named person. - """ +class VisiblePersonsResponse(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + visible: list[UUID] + + +class AccountBindingResponse(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + account_id: str + history: list[HistoryEntry] + person_id: UUID | None = Field(None, description='The binding in force now, if the account has one.') + source: str + source_id: UUID - visibility_id: UUID - insight_tenant_id: UUID - viewer_person_id: UUID - viewed_person_id: UUID | None = None - valid_from: str - valid_to: str | None = None - author_person_id: UUID - reason: str | None = None - created_at: str - @property - def in_force(self) -> bool: - return self.valid_to is None +class AttentionResponse(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + items: list[QueueItemResponse] + rates: ResolutionRatesResponse -class VisiblePersons(BaseModel): - """`POST /v1/visible-persons` — the subset of the asked-about person ids. +class CorrectionResponse(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + already_decided: int = Field(..., ge=0) + applied: int = Field(..., ge=0) + items: list[ItemResult] + new_person_id: UUID | None = Field(None, description='Set by `detach` when the account reached the new person; absent when\nthe write was refused, since no binding points at that id.') - A list of what survived, not a per-id verdict: a person the caller may not - see is absent rather than present-and-false, which is the same - non-disclosure choice `/v1/subchart/{id}` makes by answering 404. - Person UUIDs since the identity cutover (#2098), like every other - person-keyed route. +class PersonRoleListResponse(BaseModel): """ + List wrapper. + """ + model_config = ConfigDict( + extra='forbid', + ) + items: list[PersonRoleResponse] + next_cursor: str | None = Field(None, description='Wire parity with the .NET `ListResponse`: the cursor is declared\nbut pagination is not implemented — always `null` (both\nimplementations return every row; consumers already tolerate it).') - visible: list[UUID] +class PersonsSeedListResponse(BaseModel): + """ + List response wrapper (typed for OpenAPI). + """ + model_config = ConfigDict( + extra='forbid', + ) + items: list[PersonsSeedOperationResponse] + next_cursor: str | None = Field(None, description='Wire parity with the .NET `ListResponse`: the cursor is declared\nbut pagination is not implemented — always `null` (both\nimplementations return every row; consumers already tolerate it).') -# --------------------------------------------------------------------------- -# Seed / sync journals -# --------------------------------------------------------------------------- +class PersonsSyncListResponse(BaseModel): + """ + List response wrapper (typed for OpenAPI). `next_cursor` is declared but + always `null` — same non-paginating contract as the seed journal. + """ + model_config = ConfigDict( + extra='forbid', + ) + items: list[PersonsSyncOperationResponse] + next_cursor: str | None = None -class Operation(BaseModel): - """One persons-seed or persons-sync run. - The two DTOs are field-identical, so one model serves both journals. `request` - and `summary` are free-form objects the service echoes back, kept as - `JsonValue` rather than modelled — their shape belongs to whichever seed - version wrote them. +class RoleListResponse(BaseModel): + """ + List wrapper. """ + model_config = ConfigDict( + extra='forbid', + ) + items: list[RoleResponse] + next_cursor: str | None = Field(None, description='Wire parity with the .NET `ListResponse`: the cursor is declared\nbut pagination is not implemented — always `null` (both\nimplementations return every row; consumers already tolerate it).') - operation_id: UUID - operation_type: str - status: str - insight_tenant_id: UUID - author_person_id: UUID - request: JsonValue = None - summary: JsonValue = None - error_message: str | None = None - started_at: str - completed_at: str | None = None + +class SubchartForestResponse(BaseModel): + """ + `{ "roots": [ … ] }` — forest wrapper (#344). Empty when the caller has no + visible-in-source membership. + """ + model_config = ConfigDict( + extra='forbid', + ) + roots: list[SubchartNode] + + +class VisibilityListResponse(BaseModel): + """ + List wrapper. + """ + model_config = ConfigDict( + extra='forbid', + ) + items: list[VisibilityResponse] + next_cursor: str | None = Field(None, description='Wire parity with the .NET `ListResponse`: the cursor is declared\nbut pagination is not implemented — always `null` (both\nimplementations return every row; consumers already tolerate it).') -# --------------------------------------------------------------------------- -# Listings -# --------------------------------------------------------------------------- - -RoleList = ListResponse[Role] -PersonRoleList = ListResponse[PersonRole] -VisibilityList = ListResponse[Visibility] -OperationList = ListResponse[Operation] - - -__all__: Sequence[str] = ( - "Operation", - "OperationList", - "PersonRole", - "PersonRoleList", - "Profile", - "Role", - "RoleList", - "Subchart", - "SubchartForest", - "SubchartNode", - "Visibility", - "VisibilityList", -) +PersonResponse.model_rebuild() +SubchartNode.model_rebuild() diff --git a/tests/stand/api/schemas/identity_internal.py b/tests/stand/api/schemas/identity_internal.py new file mode 100644 index 000000000..5d5e44f9d --- /dev/null +++ b/tests/stand/api/schemas/identity_internal.py @@ -0,0 +1,34 @@ +"""Identity shapes that no OpenAPI document describes — hand-written. + +The service registers its two `/internal/persons/*` S2S resolvers as raw routes, +deliberately kept out of the generated document (the .NET contract excluded them +the same way). They therefore cannot be generated into `identity.py`, and a model +for them has to be written from the Rust DTO by hand. + +`extra` stays at its default here, unlike the generated models: nothing +regenerates this file when the DTO gains a field, so forbidding the unknown would +turn a benign addition into a failing suite. +""" + +from __future__ import annotations + +from uuid import UUID + +from pydantic import BaseModel + + +class IdentityValue(BaseModel): + """`GET /internal/persons/by-external-id` — the login-bootstrap lookup. + + NOT a `ProfileResponse`, though both are "a person looked up". This route + answers the identity VALUE that matched — the alias row, pointing at what it + resolved to — because at login the caller has an identifier and needs to + learn which person it belongs to, not to read that person's attributes. + Hence `insight_source_id` rather than `person_id`, and no tenant at all: the + tenant is exactly what is still unknown at that point. + """ + + value_type: str + value: str + insight_source_type: str + insight_source_id: UUID From 15dcec71e3c715156ddf155cc674b041de5031ff Mon Sep 17 00:00:00 2001 From: Sergei Mozhaev Date: Sun, 9 Aug 2026 10:21:13 +0300 Subject: [PATCH 4/7] fix(stand): keep the model behaviour the generated DTOs dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-written identity models carried more than fields: `walk()` and `emails()` over a subchart, and `in_force` on the two temporal journals. Generated models carry the contract and nothing else, so re-exporting them under the old names left four suites calling methods that no longer exist — an AttributeError at run time, invisible to collection. Move that behaviour to free functions beside the tests that use it. Deliberately not wrapper subclasses: those would have to re-declare the fields they keep, and a hand-maintained field list beside a generated one is the drift the generator exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Sergei Mozhaev --- tests/stand/api/identity/test_admin.py | 13 ++--- tests/stand/api/identity/test_conflicts.py | 3 +- .../api/identity/test_query_contracts.py | 7 +-- tests/stand/api/identity/test_subchart.py | 11 ++-- tests/stand/api/identity/views.py | 53 +++++++++++++++++++ 5 files changed, 72 insertions(+), 15 deletions(-) create mode 100644 tests/stand/api/identity/views.py diff --git a/tests/stand/api/identity/test_admin.py b/tests/stand/api/identity/test_admin.py index 73c32092a..c38a8d42b 100644 --- a/tests/stand/api/identity/test_admin.py +++ b/tests/stand/api/identity/test_admin.py @@ -37,6 +37,7 @@ Visibility, VisibilityList, ) +from .views import forest_emails, in_force #: Each admin listing with the model that describes it. Parametrising over the #: pair rather than over the path alone is what makes the 200 cases assert a @@ -268,7 +269,7 @@ def test_person_role_grant_and_revoke_round_trip( assert created.status_code == 201, f"grant role: {created.status_code} {created.text[:300]}" assignment = created.parse(PersonRole) assert str(assignment.person_id) == subject.uuid - assert assignment.in_force, f"a fresh assignment is already revoked: {assignment}" + assert in_force(assignment), f"a fresh assignment is already revoked: {assignment}" assignment_id = scratch.track( identity_path("/v1/person-roles"), "person_role_id", str(assignment.person_role_id) ) @@ -284,7 +285,7 @@ def test_person_role_grant_and_revoke_round_trip( journal = client.get(identity_path("/v1/person-roles")).parse(PersonRoleList) after = [item for item in journal.items if str(item.person_role_id) == assignment_id] assert len(after) == 1, f"the revoked assignment vanished from the journal: {after}" - assert not after[0].in_force, f"the assignment is still in force after a 204 revoke: {after[0]}" + assert not in_force(after[0]), f"the assignment is still in force after a 204 revoke: {after[0]}" @pytest.mark.requires_seed("admin_operator", "dev_lead") @@ -310,7 +311,7 @@ def test_a_visibility_grant_changes_what_the_grantee_can_see( client = admin_operator_session.client viewed = stand_manifest.fixture("dev_lead") - before = _forest(admin_operator_session).emails() + before = forest_emails(_forest(admin_operator_session)) assert before == set(), ( f"the operator already sees {sorted(before)} — this test needs it to start with " "an empty forest, so an earlier grant leaked" @@ -327,13 +328,13 @@ def test_a_visibility_grant_changes_what_the_grantee_can_see( assert created.status_code == 201, f"create grant: {created.status_code} {created.text[:300]}" grant = created.parse(Visibility) assert str(grant.viewer_person_id) == admin_operator_session.person.uuid - assert grant.in_force, f"a fresh grant is already revoked: {grant}" + assert in_force(grant), f"a fresh grant is already revoked: {grant}" grant_id = scratch.track( identity_path("/v1/visibility"), "visibility_id", str(grant.visibility_id) ) try: - visible = _forest(admin_operator_session).emails() + visible = forest_emails(_forest(admin_operator_session)) assert viewed.email in visible, ( f"after being granted sight of {viewed.email} the operator sees " f"{sorted(visible)} — the grant was stored but is not applied" @@ -342,7 +343,7 @@ def test_a_visibility_grant_changes_what_the_grantee_can_see( revoked = client.delete(identity_path(f"/v1/visibility/{grant_id}")) assert revoked.status_code == 204, f"revoke: {revoked.status_code} {revoked.text[:300]}" - after = _forest(admin_operator_session).emails() + after = forest_emails(_forest(admin_operator_session)) assert after == set(), ( f"the operator still sees {sorted(after)} after the grant was revoked — " "revocation is not applied" diff --git a/tests/stand/api/identity/test_conflicts.py b/tests/stand/api/identity/test_conflicts.py index c216f7260..b8824d91d 100644 --- a/tests/stand/api/identity/test_conflicts.py +++ b/tests/stand/api/identity/test_conflicts.py @@ -25,6 +25,7 @@ from insight_stand import ApiClient, Manifest, PersonaSession, identity_path from ..schemas import PersonRole, PersonRoleList, ProblemDocument, Role, RoleList +from .views import in_force #: identity's own role, in `person_roles` — NOT `insight_stand.ADMIN_ROLE`, #: which is the KEYCLOAK REALM role (`insight-admin`). They are different @@ -64,7 +65,7 @@ def _active_admin_assignments(client: ApiClient) -> list[PersonRole]: return [ item for item in response.parse(PersonRoleList).items - if str(item.role_id) == role_id and item.in_force + if str(item.role_id) == role_id and in_force(item) ] diff --git a/tests/stand/api/identity/test_query_contracts.py b/tests/stand/api/identity/test_query_contracts.py index e754124eb..1eea7007b 100644 --- a/tests/stand/api/identity/test_query_contracts.py +++ b/tests/stand/api/identity/test_query_contracts.py @@ -37,6 +37,7 @@ Visibility, VisibilityList, ) +from .views import in_force PERSON_ROLES = identity_path("/v1/person-roles") VISIBILITY = identity_path("/v1/visibility") @@ -90,7 +91,7 @@ def test_person_roles_filtered_by_person_shows_only_that_person( ) theirs = _person_roles(client, f"?person={lead.uuid}&active=true") - assert [row for row in theirs.items if row.in_force] == [], ( + assert [row for row in theirs.items if in_force(row)] == [], ( f"?person={lead.uuid}&active=true returned {len(theirs.items)} grants in force " "for somebody who holds none" ) @@ -113,7 +114,7 @@ def test_person_roles_filtered_by_role_and_active_narrows_on_both( rows = _person_roles(client, f"?role={role_id}&active=true").items assert all(str(row.role_id) == role_id for row in rows), "the role filter was not applied" - assert all(row.in_force for row in rows), ( + assert all(in_force(row) for row in rows), ( "active=true returned a revoked grant — valid_to is set on at least one row" ) @@ -171,7 +172,7 @@ def test_visibility_filters_narrow_by_viewer_and_by_viewed( assert any(str(row.visibility_id) == grant_id for row in by_viewed), ( "the grant is absent from a filter naming its target" ) - assert all(row.in_force for row in by_viewed), "active=true returned a revoked grant" + assert all(in_force(row) for row in by_viewed), "active=true returned a revoked grant" finally: client.delete(f"{VISIBILITY}/{grant_id}") diff --git a/tests/stand/api/identity/test_subchart.py b/tests/stand/api/identity/test_subchart.py index e844b1581..50933b7b0 100644 --- a/tests/stand/api/identity/test_subchart.py +++ b/tests/stand/api/identity/test_subchart.py @@ -40,6 +40,7 @@ from ..schemas import ProblemDocument, Subchart, SubchartForest from ..scratch import UNKNOWN_ID +from .views import forest_emails, walk #: Caller-derived org subchart — takes no person argument, so what comes back #: identifies whoever the session belongs to. 401 anonymous (swept in @@ -76,7 +77,7 @@ def test_the_session_belongs_to_the_persona_who_logged_in(lead_session: PersonaS human: Keycloak authenticated them, the authenticator mapped the token to a person, and identity found that person in the seeded roster. """ - nodes = [node for root in _forest(lead_session).roots for node in root.walk()] + nodes = [node for root in _forest(lead_session).roots for node in walk(root)] mine = [node for node in nodes if node.email == lead_session.email] assert len(mine) == 1, ( f"the caller-derived org chart for {lead_session.name} contains " @@ -116,9 +117,9 @@ def test_org_visibility_scope_differs_by_persona( "the realm admin and the lead resolved to the same persona" ) - admin_view = _forest(realm_admin_session).emails() - lead_view = _forest(lead_session).emails() - member_view = _forest(member_session).emails() + admin_view = forest_emails(_forest(realm_admin_session)) + lead_view = forest_emails(_forest(lead_session)) + member_view = forest_emails(_forest(member_session)) assert member_view == set(), ( f"a plain member sees {sorted(member_view)} in the org chart; expected nothing" @@ -146,7 +147,7 @@ def test_two_leads_of_different_teams_see_different_people( dev, sales = session_for("dev_lead"), session_for("sales_lead") assert dev.person.team != sales.person.team - dev_view, sales_view = _forest(dev).emails(), _forest(sales).emails() + dev_view, sales_view = forest_emails(_forest(dev)), forest_emails(_forest(sales)) assert dev_view and sales_view, "expected both leads to see somebody" assert dev_view != sales_view, ( diff --git a/tests/stand/api/identity/views.py b/tests/stand/api/identity/views.py new file mode 100644 index 000000000..529ec2ab5 --- /dev/null +++ b/tests/stand/api/identity/views.py @@ -0,0 +1,53 @@ +"""Reading helpers over the generated identity models. + +The models in `schemas/identity.py` are generated from the service's own OpenAPI +document, so they carry the contract's fields and nothing else. The behaviour the +suite used to reach for as methods — walking a subtree, collecting its emails, +asking whether a temporal row is still in force — lives here instead, as free +functions over those models. + +Deliberately not subclasses. A wrapper model would have to re-declare the fields +it wants to keep, and a hand-maintained field list beside a generated one is the +drift the generator exists to prevent. These are the suite's conveniences, not +part of the contract, and the split says so. +""" + +from __future__ import annotations + +from typing import Protocol + +from ..schemas import Subchart, SubchartForest, SubchartNode + + +def walk(node: SubchartNode) -> list[SubchartNode]: + """This node and every descendant, at any depth.""" + found = [node] + for child in node.subordinates: + found += walk(child) + return found + + +def node_emails(node: SubchartNode) -> set[str]: + """Every email in this subtree — the shape scope assertions compare.""" + return {found.email for found in walk(node) if found.email} + + +def forest_emails(forest: SubchartForest) -> set[str]: + return {email for root in forest.roots for email in node_emails(root)} + + +def subchart_emails(subchart: Subchart) -> set[str]: + return node_emails(subchart.root) + + +class Temporal(Protocol): + """A row closed by setting `valid_to` rather than by deletion — what role + assignments and visibility grants have in common.""" + + valid_to: str | None + + +def in_force(row: Temporal) -> bool: + """Revocation is a `valid_to` stamp, so an open end is what "still applies" + means for both journals.""" + return row.valid_to is None From 1615f7bb3703616ad53594bf33af2fe36f110b55 Mon Sep 17 00:00:00 2001 From: Sergei Mozhaev Date: Sun, 9 Aug 2026 10:21:20 +0300 Subject: [PATCH 5/7] fix(identity): describe the filters the handlers accept Declaring only the path parameters left every list route's filters undocumented, so a generated client could reach the routes but not narrow them: `status`/`limit` on both operation journals, `person`/`role`/ `active`/`limit` on role assignments, `viewer`/`viewed`/`active`/`limit` on visibility grants, `depth`/`valid_at` on both subchart reads. Both revoke routes also accept an optional body carrying the reason, which the document did not mention at all. `RevokeReasonRequest` gains the schema derives the builder needs to describe it. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Sergei Mozhaev --- .../backend/identity-resolution/openapi.json | 186 ++++++++++++++++++ .../identity-resolution/src/api/mod.rs | 63 ++++++ .../src/api/person_roles.rs | 3 +- .../identity-resolution/src/api/visibility.rs | 3 +- tests/stand/api/schemas/identity.py | 10 + 5 files changed, 263 insertions(+), 2 deletions(-) diff --git a/docs/components/backend/identity-resolution/openapi.json b/docs/components/backend/identity-resolution/openapi.json index d19d90a4d..798369dea 100644 --- a/docs/components/backend/identity-resolution/openapi.json +++ b/docs/components/backend/identity-resolution/openapi.json @@ -984,6 +984,18 @@ ], "type": "object" }, + "RevokeReasonRequest": { + "description": "Optional `DELETE` body carrying a revoke reason.", + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "RoleListResponse": { "description": "List wrapper.", "properties": { @@ -1221,6 +1233,44 @@ "/v1/person-roles": { "get": { "operationId": "identity_resolution.person_roles.list", + "parameters": [ + { + "description": "Only assignments of this person", + "in": "query", + "name": "person", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Only assignments of this role", + "in": "query", + "name": "role", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Only assignments still in force", + "in": "query", + "name": "active", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Cap on returned assignments; a value below 1 clamps to 1", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + } + ], "responses": { "200": { "content": { @@ -1427,6 +1477,16 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevokeReasonRequest" + } + } + }, + "description": "Why the assignment is revoked" + }, "responses": { "204": { "description": "Assignment revoked" @@ -1513,6 +1573,26 @@ "/v1/persons-seed": { "get": { "operationId": "identity_resolution.persons_seed.list", + "parameters": [ + { + "description": "Filter by operation status", + "in": "query", + "name": "status", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Cap on returned operations; a value below 1 clamps to 1", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + } + ], "responses": { "200": { "content": { @@ -1710,6 +1790,26 @@ "/v1/persons-sync": { "get": { "operationId": "identity_resolution.persons_sync.list", + "parameters": [ + { + "description": "Filter by operation status", + "in": "query", + "name": "status", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Cap on returned operations; a value below 1 clamps to 1", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + } + ], "responses": { "200": { "content": { @@ -3049,6 +3149,26 @@ "/v1/subchart": { "get": { "operationId": "identity_resolution.subchart.forest", + "parameters": [ + { + "description": "Max descent depth (>= 0); capped at the server's maximum, which is also the default", + "in": "query", + "name": "depth", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Point-in-time lens (ISO-8601 / RFC-3339); absent reads the current state", + "in": "query", + "name": "valid_at", + "required": false, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "content": { @@ -3151,6 +3271,24 @@ "schema": { "type": "string" } + }, + { + "description": "Max descent depth (>= 0); capped at the server's maximum, which is also the default", + "in": "query", + "name": "depth", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Point-in-time lens (ISO-8601 / RFC-3339); absent reads the current state", + "in": "query", + "name": "valid_at", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -3246,6 +3384,44 @@ "/v1/visibility": { "get": { "operationId": "identity_resolution.visibility.list", + "parameters": [ + { + "description": "Only grants held by this viewer", + "in": "query", + "name": "viewer", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Only grants over this person", + "in": "query", + "name": "viewed", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Only grants still in force", + "in": "query", + "name": "active", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Cap on returned grants; a value below 1 clamps to 1", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + } + ], "responses": { "200": { "content": { @@ -3452,6 +3628,16 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevokeReasonRequest" + } + } + }, + "description": "Why the grant is revoked" + }, "responses": { "204": { "description": "Grant revoked" diff --git a/src/backend/services/identity-resolution/src/api/mod.rs b/src/backend/services/identity-resolution/src/api/mod.rs index 7aaab554d..452dc84a4 100644 --- a/src/backend/services/identity-resolution/src/api/mod.rs +++ b/src/backend/services/identity-resolution/src/api/mod.rs @@ -264,6 +264,13 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .operation_id("identity_resolution.persons_seed.list") .summary("List persons-seed operations") .authenticated() + .query_param("status", false, "Filter by operation status") + .query_param_typed( + "limit", + false, + "Cap on returned operations; a value below 1 clamps to 1", + "integer", + ) .no_license_required() .json_response_with_schema::( openapi, @@ -296,6 +303,13 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .operation_id("identity_resolution.persons_sync.list") .summary("List persons-sync operations") .authenticated() + .query_param("status", false, "Filter by operation status") + .query_param_typed( + "limit", + false, + "Cap on returned operations; a value below 1 clamps to 1", + "integer", + ) .no_license_required() .json_response_with_schema::( openapi, @@ -363,6 +377,20 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .operation_id("identity_resolution.person_roles.list") .summary("List role assignments (admin)") .authenticated() + .query_param("person", false, "Only assignments of this person") + .query_param("role", false, "Only assignments of this role") + .query_param_typed( + "active", + false, + "Only assignments still in force", + "boolean", + ) + .query_param_typed( + "limit", + false, + "Cap on returned assignments; a value below 1 clamps to 1", + "integer", + ) .no_license_required() .json_response_with_schema::( openapi, @@ -378,6 +406,8 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .summary("Revoke a role assignment (admin)") .authenticated() .path_param("id", "Role assignment id") + .json_request::(openapi, "Why the assignment is revoked") + .request_optional() .no_license_required() .no_content_response(StatusCode::NO_CONTENT, "Assignment revoked") .standard_errors(openapi) @@ -404,6 +434,15 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .operation_id("identity_resolution.visibility.list") .summary("List visibility grants (admin)") .authenticated() + .query_param("viewer", false, "Only grants held by this viewer") + .query_param("viewed", false, "Only grants over this person") + .query_param_typed("active", false, "Only grants still in force", "boolean") + .query_param_typed( + "limit", + false, + "Cap on returned grants; a value below 1 clamps to 1", + "integer", + ) .no_license_required() .json_response_with_schema::( openapi, @@ -419,6 +458,8 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .summary("Revoke a visibility grant (admin)") .authenticated() .path_param("id", "Visibility grant id") + .json_request::(openapi, "Why the grant is revoked") + .request_optional() .no_license_required() .no_content_response(StatusCode::NO_CONTENT, "Grant revoked") .standard_errors(openapi) @@ -430,6 +471,17 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .operation_id("identity_resolution.subchart.forest") .summary("Org forest the caller can see") .authenticated() + .query_param_typed( + "depth", + false, + "Max descent depth (>= 0); capped at the server's maximum, which is also the default", + "integer", + ) + .query_param( + "valid_at", + false, + "Point-in-time lens (ISO-8601 / RFC-3339); absent reads the current state", + ) .no_license_required() .json_response_with_schema::( openapi, @@ -445,6 +497,17 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .summary("Depth-bounded org subtree rooted at a person") .authenticated() .path_param("person_id", "Person the subtree is rooted at") + .query_param_typed( + "depth", + false, + "Max descent depth (>= 0); capped at the server's maximum, which is also the default", + "integer", + ) + .query_param( + "valid_at", + false, + "Point-in-time lens (ISO-8601 / RFC-3339); absent reads the current state", + ) .no_license_required() .json_response_with_schema::( openapi, diff --git a/src/backend/services/identity-resolution/src/api/person_roles.rs b/src/backend/services/identity-resolution/src/api/person_roles.rs index 72033e6e6..90a25a69e 100644 --- a/src/backend/services/identity-resolution/src/api/person_roles.rs +++ b/src/backend/services/identity-resolution/src/api/person_roles.rs @@ -88,11 +88,12 @@ pub struct PersonRoleListResponse { impl toolkit::api::api_dto::ResponseApiDto for PersonRoleListResponse {} /// Optional `DELETE` body carrying a revoke reason. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct RevokeReasonRequest { #[serde(default)] pub reason: Option, } +impl toolkit::api::api_dto::RequestApiDto for RevokeReasonRequest {} #[derive(Debug, Deserialize)] pub struct ListParams { diff --git a/src/backend/services/identity-resolution/src/api/visibility.rs b/src/backend/services/identity-resolution/src/api/visibility.rs index 57e5ab729..1699b0f63 100644 --- a/src/backend/services/identity-resolution/src/api/visibility.rs +++ b/src/backend/services/identity-resolution/src/api/visibility.rs @@ -87,11 +87,12 @@ pub struct VisibilityListResponse { impl toolkit::api::api_dto::ResponseApiDto for VisibilityListResponse {} /// Optional `DELETE` body carrying a revoke reason. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct RevokeReasonRequest { #[serde(default)] pub reason: Option, } +impl toolkit::api::api_dto::RequestApiDto for RevokeReasonRequest {} #[derive(Debug, Deserialize)] pub struct ListParams { diff --git a/tests/stand/api/schemas/identity.py b/tests/stand/api/schemas/identity.py index 47f2c2c9e..e50135c0f 100644 --- a/tests/stand/api/schemas/identity.py +++ b/tests/stand/api/schemas/identity.py @@ -359,6 +359,16 @@ class ResolveProfileRequest(BaseModel): value_type: str +class RevokeReasonRequest(BaseModel): + """ + Optional `DELETE` body carrying a revoke reason. + """ + model_config = ConfigDict( + extra='forbid', + ) + reason: str | None = None + + class RoleResponse(BaseModel): """ One role in the catalogue. From 9e59e3f4ebe10537b0a604687c40232803ae7b79 Mon Sep 17 00:00:00 2001 From: Sergei Mozhaev Date: Sun, 9 Aug 2026 10:48:03 +0300 Subject: [PATCH 6/7] fix(identity): make the openapi emit truly offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subcommand is documented as needing no config, and builds the document from the route table alone — but the config was loaded before the command was chosen, so `--config openapi` failed on a file it never reads. Behaviour and documentation disagreed. Load the config per command instead. The commands that do need one keep validating the path exactly as before; only the offline emit stops depending on it. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Sergei Mozhaev --- .../services/identity-resolution/src/main.rs | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/backend/services/identity-resolution/src/main.rs b/src/backend/services/identity-resolution/src/main.rs index 3d8faa6c5..6bc128977 100644 --- a/src/backend/services/identity-resolution/src/main.rs +++ b/src/backend/services/identity-resolution/src/main.rs @@ -97,20 +97,26 @@ const EXIT_SEED_GUARD: i32 = 3; #[tokio::main] async fn main() -> Result<()> { - let cli = Cli::parse(); - // Layered config: defaults -> YAML -> env (APP__*). Logging/OTel are - // initialized by the bootstrap runtime for the server path; subcommands - // run outside it and install their own plain subscriber. - let config = AppConfig::load_or_default(cli.config.as_ref())?; - match cli.command.unwrap_or(Commands::Run) { - Commands::Run => run_server(config).await, + let mut cli = Cli::parse(); + let command = cli.command.take().unwrap_or(Commands::Run); + + // Layered config: defaults -> YAML -> env (APP__*). Loaded per command + // rather than up front, so the offline `openapi` emit cannot fail on a + // config file it never reads. Logging/OTel are initialized by the bootstrap + // runtime for the server path; subcommands run outside it and install their + // own plain subscriber. + let load_config = || AppConfig::load_or_default(cli.config.as_ref()); + + match command { Commands::Openapi => print_openapi(), + Commands::Run => run_server(load_config()?).await, Commands::Migrate => { init_subcommand_logging(); - gear::run_migrate(&config).await + gear::run_migrate(&load_config()?).await } Commands::Seed { mode, force } => { init_subcommand_logging(); + let config = load_config()?; match gear::run_seed(&config, &mode, force).await { Ok(()) => Ok(()), Err(seed_runner::SeedRunError::LockBusy) => { @@ -129,6 +135,7 @@ async fn main() -> Result<()> { } Commands::Sync { force } => { init_subcommand_logging(); + let config = load_config()?; match gear::run_sync(&config, force).await { Ok(()) => Ok(()), Err(sync_runner::SyncRunError::LockBusy) => { From f90dee3f87a2ef6c134cf5cc65879443d490b9b1 Mon Sep 17 00:00:00 2001 From: Sergei Mozhaev Date: Sun, 9 Aug 2026 11:11:37 +0300 Subject: [PATCH 7/7] docs(identity): name both bounds on every list limit The `limit` descriptions stated the lower clamp and stopped there, so a generated client could not learn the page size it would actually get. All four list routes clamp to 1..=500 and default to 50; say so, in the form the review queue's own description already uses. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Sergei Mozhaev --- docs/components/backend/identity-resolution/openapi.json | 8 ++++---- src/backend/services/identity-resolution/src/api/mod.rs | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/components/backend/identity-resolution/openapi.json b/docs/components/backend/identity-resolution/openapi.json index 798369dea..bb9607e9f 100644 --- a/docs/components/backend/identity-resolution/openapi.json +++ b/docs/components/backend/identity-resolution/openapi.json @@ -1262,7 +1262,7 @@ } }, { - "description": "Cap on returned assignments; a value below 1 clamps to 1", + "description": "Cap on returned assignments (1..=500, default 50)", "in": "query", "name": "limit", "required": false, @@ -1584,7 +1584,7 @@ } }, { - "description": "Cap on returned operations; a value below 1 clamps to 1", + "description": "Cap on returned operations (1..=500, default 50)", "in": "query", "name": "limit", "required": false, @@ -1801,7 +1801,7 @@ } }, { - "description": "Cap on returned operations; a value below 1 clamps to 1", + "description": "Cap on returned operations (1..=500, default 50)", "in": "query", "name": "limit", "required": false, @@ -3413,7 +3413,7 @@ } }, { - "description": "Cap on returned grants; a value below 1 clamps to 1", + "description": "Cap on returned grants (1..=500, default 50)", "in": "query", "name": "limit", "required": false, diff --git a/src/backend/services/identity-resolution/src/api/mod.rs b/src/backend/services/identity-resolution/src/api/mod.rs index 452dc84a4..d965cb820 100644 --- a/src/backend/services/identity-resolution/src/api/mod.rs +++ b/src/backend/services/identity-resolution/src/api/mod.rs @@ -268,7 +268,7 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .query_param_typed( "limit", false, - "Cap on returned operations; a value below 1 clamps to 1", + "Cap on returned operations (1..=500, default 50)", "integer", ) .no_license_required() @@ -307,7 +307,7 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .query_param_typed( "limit", false, - "Cap on returned operations; a value below 1 clamps to 1", + "Cap on returned operations (1..=500, default 50)", "integer", ) .no_license_required() @@ -388,7 +388,7 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .query_param_typed( "limit", false, - "Cap on returned assignments; a value below 1 clamps to 1", + "Cap on returned assignments (1..=500, default 50)", "integer", ) .no_license_required() @@ -440,7 +440,7 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .query_param_typed( "limit", false, - "Cap on returned grants; a value below 1 clamps to 1", + "Cap on returned grants (1..=500, default 50)", "integer", ) .no_license_required()