diff --git a/AGENTS.md b/AGENTS.md index 526b7a8f..fcf184fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,6 +141,8 @@ Set `ADMIN_SECRET` on first boot to create the password credential for `atom-adm **Email change** (`POST /auth/email/change/request` / `POST /auth/email/change/confirm`, `src/identity/service.rs`) — the dedicated verify-before-apply flow that lets a global human change the login/recovery email PR #109 deliberately kept out of the self-profile allowlist (issue #110). Scope: global humans only (`tenant_id IS NULL`), matching the self-profile restriction above; tenant-local human identities (#99) need this bound to a home tenant, which this flow does not yet do. Request requires a real session (`AuthContext::require_session` — no access token, scoped or unscoped) no older than `ATOM_EMAIL_CHANGE_MAX_SESSION_AGE_SECS` (default 900s) — the deliberate stand-in for step-up reauthentication, since Atom has no dedicated mechanism for it. Request mutates nothing; it only mints a single-use `atomc_`-prefixed token (`email_change_tokens`, mirroring `email_verification_tokens`/`password_reset_tokens`) bound to the entity, the email captured as current *at request time*, and the proposed email, superseding any still-pending token for the entity — issuance serialized on the same entity lock the confirm transaction takes, so two overlapping requests can never each miss the other's uncommitted insert and leave two honourable pending tokens. Enumeration-resistant: a proposed email already live elsewhere returns the same 202 without minting a token or sending mail. A bootstrap-provisioned identity (`managed_by = 'config'`) is refused by the shared ownership guard at request (fail fast — no dead-end token is ever minted or mailed) and again inside the confirm transaction (the authoritative gate): the YAML owns that identity's email. Confirmation is unauthenticated (the token, provable only by receipt at the proposed mailbox, is the credential) and deliberately does not require the requesting session to still be alive. Its transaction locks the entity then the canonical `entity_emails` row (same order as `sync_entity_email_from_attrs_in_tx`), fails safely — without consuming the token — if the live email no longer matches what the token captured, rechecks case-insensitive uniqueness explicitly (`lower(email)`, independent of the case-sensitive unique index, since that index's case-insensitive behavior is only an emergent property of every writer normalizing first), then atomically updates `entity_emails`, the active password credential's `identifier`, and — only if already present — the `entities.attributes.email` compatibility mirror; invalidates old-state verification/reset tokens; and revokes every session for the entity (fresh login required everywhere, same as `reset_password`). The symmetry holds in reverse: `reset_password`'s own transaction consumes any still-pending email-change token for the entity — a password recovery is a full identity-state reset, and a pre-reset pending change left alive would let a briefly-compromised session's attacker wait out the owner's recovery, then confirm and re-point password recovery at their own mailbox. Publishes the existing frozen `entity.update` event rather than a new event name (`domain-event-catalog.json`'s compatibility rule freezes the event-name set for v1; `details` gaining an optional `field` key is within that rule). `identity::service::upsert_oauth_identity`'s auto-link-by-email lookup locks both the `entities` and `entity_emails` rows it reads (`FOR UPDATE OF e, ee`, not `e` alone) specifically so it cannot commit a link against an email this flow is concurrently moving away — the same fix closes an equivalent pre-existing gap against the admin `sync_entity_email_from_attrs_in_tx` path. +**Legacy identity audit** (`AdminQuery.legacy{UnverifiedEmails,CredentialIdentifierMismatches,OauthEmailMismatches,AttributesEmailMismatches}`, `src/identity/repo.rs`, issue #110 workstream B) — four read-only, platform-admin-only (`manage` on `Scope::Platform`) reports surfacing pre-existing identity-state drift: live unverified `entity_emails` rows; active password credentials whose `identifier` disagrees with the canonical email (or which has none); `oauth_identities` whose claimed email doesn't match an active, verified canonical email for the linked entity (including no canonical email, or one that exists but is unverified); and live human entities whose legacy `attributes.email` disagrees with the canonical row. Every row carries `pendingTokens` — counts of unexpired, unconsumed verification/reset/email-change/invitation tokens naming the specific anomalous email, never a token value — so an operator can see a self-service path may already be in flight before reaching for manual recovery. This ships the dry-run report only; remediation (backfilling `verified_at`, revoking suspicious OAuth links, reconciling identifiers) is a deliberately separate, approval-gated tool per the issue's delivery order — nothing here mutates any row, and no migration may blanket-convert an unverified row to verified merely because an email exists. + ## Database - All PKs are UUIDs (`gen_random_uuid()` via pgcrypto). diff --git a/api/v1/contracts-v1.0.0.sha384 b/api/v1/contracts-v1.0.0.sha384 index d971a39b..00cd2de3 100644 --- a/api/v1/contracts-v1.0.0.sha384 +++ b/api/v1/contracts-v1.0.0.sha384 @@ -4,12 +4,12 @@ bd31f2c034f2a08eea2eb74c6e614fc04f073950e063f0994365aea4156df1ed524952909a1b2c4e c971c3c28b05ae6a613484af5415529a7d04780c3d314f8dc0fac0722355947d7c12aeac965e1ce5e00beaba87ba8f03 api/v1/deployment-config.json 20a441099803d7d31453ff79eb8500fdffa62bbc004838fe779f07ac28f6dc7497e03085efdca18ab5d67b7672d42db8 api/v1/domain-event-catalog.json 2b65455dcb0a179a46c3af87fb13b746da2d609cebbaf33817e05cc48f773cd5f63a020e763f2569791f914eddc3197d api/v1/domain-event.schema.json -ed1caabc1d8e333cb346ed8a621fae19ba1982f33a6ee9264fbd1a5591322f90fff2d31c31ac7c8c01d36393e00b7e32 api/v1/graphql-auth-matrix.json +805c9d69b9e21efd32061120a262083d7c6379571a1964f82c82461909492f82bd5f6cb08b6326eaaa3bb63eebf1c27f api/v1/graphql-auth-matrix.json 253b551efff402abebd3172485a507b8dfc434de2477ba93376de542e3d3a2841262150eba39265fa9907780d3a9b354 api/v1/jwt-contract.json 6b970143d470e6247fc58ba24d1b8811347547af63f18c9d81b56866046ee4ae27ab8a50f1323caa320b01487bdd0a64 api/v1/migrations-v1.0.0.sha384 006fee5f7f23a9f01721a5210fb940bfb1f8c204254dbe6581b7395416ca24657917d1934a95f6f9fef0432b6c08788b api/v1/persisted-semantics.json ea2eebad61b18cc6144ab66281c96192940c5882727d0438696194cc55fff4945507cc8e589ee35f5f28d3b6392cbcb3 apidocs/openapi.yaml -8428976231b157eb9d189862e37ecb8611c3089495c998b086415c57554568e071640084dc15ab6c11a25cefafaeac23 apidocs/graphql-schema.graphql +30ca7a810e30623ade73ceb185a6423d1ac58143d967ef87718c05ace777ea2cd07e212ab08a4355c2f06727d9af15d9 apidocs/graphql-schema.graphql 76309eaef4ce4ec758173331c12b38ba2e187fdd09ebd6be475924bb865cca05cd182cda2d34f1e540e57f2efb22a4e5 proto/atom/v1/atom.proto 5af9ce98f8ce5061c961ea0e60f0eb66132fc8d0032367052c608d4c0d5e5de8ad38d763dc40b4f9efd6f9ca06e69aba proto/atom/v1/callout.proto 7e9342b673b00f1aa288468cf6c852c78d83991a54eb989ecc376252739da0102e656e3984e305636d7e7dfcbea213f3 proto/broker/v1/auth.proto diff --git a/api/v1/graphql-auth-matrix.json b/api/v1/graphql-auth-matrix.json index 343d2fdc..8661ce1b 100644 --- a/api/v1/graphql-auth-matrix.json +++ b/api/v1/graphql-auth-matrix.json @@ -4,7 +4,7 @@ "compatibilityRule": "A root operation may not become less authenticated or change its authorization profile in v1. Tightening an existing gate can also break callers and requires explicit compatibility review.", "rootOperations": { "query": [ - "objectCoordinationVersion","validateObjectLease","health","session","tenants","tenant","tenantMembers","tenantAssignableEntities","tenantInvitations","myTenantRoles","myTenantInvitations","profiles","profile","profileVersions","ownedEntities","entity","entities","resourceKinds","resources","resource","apiEndpoints","apiEndpoint","apiEndpointExecutions","groups","group","groupMembers","entityGroups","childGroups","objectGroups","principalGroups","credentials","accessTokens","certificates","certificate","pkiAuthority","pkiAuthorities","authorizedObjectIds","roles","role","actions","actionApplicability","actionAssignmentRules","action","permissionBlocks","permissionBlock","roleAssignments","directPolicies","auditLogs","entityAuditLogs","orphanPolicies","expiringCredentials","systemStatus","signingKeys" + "objectCoordinationVersion","validateObjectLease","health","session","tenants","tenant","tenantMembers","tenantAssignableEntities","tenantInvitations","myTenantRoles","myTenantInvitations","profiles","profile","profileVersions","ownedEntities","entity","entities","resourceKinds","resources","resource","apiEndpoints","apiEndpoint","apiEndpointExecutions","groups","group","groupMembers","entityGroups","childGroups","objectGroups","principalGroups","credentials","accessTokens","certificates","certificate","pkiAuthority","pkiAuthorities","authorizedObjectIds","roles","role","actions","actionApplicability","actionAssignmentRules","action","permissionBlocks","permissionBlock","roleAssignments","directPolicies","auditLogs","entityAuditLogs","orphanPolicies","expiringCredentials","legacyUnverifiedEmails","legacyCredentialIdentifierMismatches","legacyOauthEmailMismatches","legacyAttributesEmailMismatches","systemStatus","signingKeys" ], "mutation": [ "login","signup","logout","refreshSession","refreshToken","createTenant","updateTenant","deleteTenant","restoreTenant","purgeTenant","enableTenant","disableTenant","freezeTenant","createTenantInvitation","acceptTenantInvitation","acceptTenantInvitationToken","rejectTenantInvitation","revokeTenantInvitation","removeTenantMember","addTenantMember","createProfile","createProfileVersion","updateProfile","updateProfileVersion","createEntity","updateEntity","deleteEntity","restoreEntity","purgeEntity","addEntityToObjectGroup","removeEntityFromObjectGroup","clearEntityObjectGroups","enableEntity","disableEntity","addOwnership","removeOwnership","createResource","updateResource","deleteResource","restoreResource","purgeResource","addResourceToObjectGroup","removeResourceFromObjectGroup","clearResourceObjectGroups","createApiEndpoint","updateApiEndpoint","enableApiEndpoint","disableApiEndpoint","createGroup","createObjectGroup","createPrincipalGroup","updateGroup","enableGroup","disableGroup","suspendGroup","setGroupParent","setObjectGroupParent","removeGroupParent","removeObjectGroupParent","deleteGroup","restoreGroup","purgeGroup","addGroupMember","removeGroupMember","changeOwnPassword","createPassword","createAccessToken","replaceAccessTokenPermissions","revokeAccessToken","createSharedKey","revealSharedKey","revokeCredential","issueGeneratedCertificateV2","issueCertificateFromCsrV2","renewCertificateFromCsrV2","renewGeneratedCertificateV2","revokeCertificateV2","revokeEntityCertificates","bulkRevokeCertificates","beginTenantAuthorityProvisioning","provisionTenantAuthorityAutomatically","beginAuthorityRetirement","completeAuthorityRetirement","createRole","updateRole","replaceRolePermissionBlocks","deleteRole","restoreRole","purgeRole","createAction","addActionApplicability","removeActionApplicability","createActionAssignmentRule","deleteActionAssignmentRule","updateAction","deleteAction","createPermissionBlock","deletePermissionBlock","createRoleAssignment","deleteRoleAssignment","createDirectPolicy","deleteDirectPolicy","authzCheck","authzExplain","authzBulkCheck","rotateSigningKeys","commitObjectChanges","acquireObjectLease","renewObjectLease","releaseObjectLease" @@ -31,7 +31,7 @@ "authenticated_self_service": ["objectCoordinationVersion","logout","refreshSession","myTenantRoles","myTenantInvitations","acceptTenantInvitationToken","rejectTenantInvitation","changeOwnPassword"], "canonical_read_gate": ["session","tenants","tenant","tenantMembers","tenantAssignableEntities","tenantInvitations","profiles","profile","profileVersions","ownedEntities","entity","entities","resourceKinds","resources","resource","apiEndpoints","apiEndpoint","apiEndpointExecutions","groups","group","groupMembers","entityGroups","childGroups","objectGroups","principalGroups","credentials","accessTokens","certificates","certificate","pkiAuthority","pkiAuthorities","roles","role","actions","actionApplicability","actionAssignmentRules","action","permissionBlocks","permissionBlock","roleAssignments","directPolicies","auditLogs","entityAuditLogs"], "scoped_control_plane_gate": ["signingKeys","rotateSigningKeys"], - "platform_manage": ["restoreTenant","purgeTenant","restoreEntity","purgeEntity","restoreResource","purgeResource","restoreGroup","purgeGroup","restoreRole","purgeRole","createApiEndpoint","updateApiEndpoint","enableApiEndpoint","disableApiEndpoint","orphanPolicies","expiringCredentials","systemStatus"], + "platform_manage": ["restoreTenant","purgeTenant","restoreEntity","purgeEntity","restoreResource","purgeResource","restoreGroup","purgeGroup","restoreRole","purgeRole","createApiEndpoint","updateApiEndpoint","enableApiEndpoint","disableApiEndpoint","orphanPolicies","expiringCredentials","legacyUnverifiedEmails","legacyCredentialIdentifierMismatches","legacyOauthEmailMismatches","legacyAttributesEmailMismatches","systemStatus"], "authz_decision": ["authorizedObjectIds","authzCheck","authzExplain","authzBulkCheck"], "resolver_specific": ["validateObjectLease","commitObjectChanges","acquireObjectLease","renewObjectLease","releaseObjectLease","createTenant","updateTenant","deleteTenant","enableTenant","disableTenant","freezeTenant","createTenantInvitation","acceptTenantInvitation","revokeTenantInvitation","removeTenantMember","addTenantMember","createProfile","createProfileVersion","updateProfile","updateProfileVersion","createEntity","updateEntity","deleteEntity","addEntityToObjectGroup","removeEntityFromObjectGroup","clearEntityObjectGroups","enableEntity","disableEntity","addOwnership","removeOwnership","createResource","updateResource","deleteResource","addResourceToObjectGroup","removeResourceFromObjectGroup","clearResourceObjectGroups","createGroup","createObjectGroup","createPrincipalGroup","updateGroup","enableGroup","disableGroup","suspendGroup","setGroupParent","setObjectGroupParent","removeGroupParent","removeObjectGroupParent","deleteGroup","addGroupMember","removeGroupMember","createPassword","createAccessToken","replaceAccessTokenPermissions","revokeAccessToken","createSharedKey","revealSharedKey","revokeCredential","issueGeneratedCertificateV2","issueCertificateFromCsrV2","renewCertificateFromCsrV2","renewGeneratedCertificateV2","revokeCertificateV2","revokeEntityCertificates","bulkRevokeCertificates","beginTenantAuthorityProvisioning","provisionTenantAuthorityAutomatically","beginAuthorityRetirement","completeAuthorityRetirement","createRole","updateRole","replaceRolePermissionBlocks","deleteRole","createAction","addActionApplicability","removeActionApplicability","createActionAssignmentRule","deleteActionAssignmentRule","updateAction","deleteAction","createPermissionBlock","deletePermissionBlock","createRoleAssignment","deleteRoleAssignment","createDirectPolicy","deleteDirectPolicy"] }, diff --git a/apidocs/graphql-schema.graphql b/apidocs/graphql-schema.graphql index cb7babb7..a2eb3016 100644 --- a/apidocs/graphql-schema.graphql +++ b/apidocs/graphql-schema.graphql @@ -754,6 +754,46 @@ A scalar that can represent any JSON value. """ scalar JSON +type LegacyAttributesEmailMismatch { + entityId: ID! + attributesEmail: String! + canonicalEmail: String + canonicalVerifiedAt: String + entityUpdatedAt: String + pendingTokens: PendingTokenCounts! +} + +type LegacyCredentialIdentifierMismatch { + credentialId: ID! + entityId: ID! + identifier: String + canonicalEmail: String + canonicalVerifiedAt: String + credentialCreatedAt: String! + pendingTokens: PendingTokenCounts! +} + +type LegacyOauthEmailMismatch { + entityId: ID! + provider: String! + subject: String! + oauthEmail: String! + oauthEmailVerified: Boolean! + canonicalEmail: String + canonicalVerifiedAt: String + linkedAt: String! + pendingTokens: PendingTokenCounts! +} + +type LegacyUnverifiedEmail { + entityId: ID! + entityKind: EntityKind! + entityStatus: EntityStatus! + email: String! + emailCreatedAt: String! + pendingTokens: PendingTokenCounts! +} + input LoginInput { identifier: String! secret: String! @@ -1051,6 +1091,13 @@ type Ownership { createdAt: String! } +type PendingTokenCounts { + verification: Int! + passwordReset: Int! + emailChange: Int! + invitation: Int! +} + type PermissionBlock { id: ID! tenantId: ID @@ -1185,6 +1232,10 @@ type QueryRoot { entityAuditLogs(entityId: ID!): AuditLogList! orphanPolicies(limit: Int, offset: Int): [OrphanPolicy!]! expiringCredentials(days: Int, entityId: ID, kind: CredentialKind, limit: Int, offset: Int): [Credential!]! + legacyUnverifiedEmails(entityId: ID, limit: Int, offset: Int): [LegacyUnverifiedEmail!]! + legacyCredentialIdentifierMismatches(entityId: ID, limit: Int, offset: Int): [LegacyCredentialIdentifierMismatch!]! + legacyOauthEmailMismatches(entityId: ID, limit: Int, offset: Int): [LegacyOauthEmailMismatch!]! + legacyAttributesEmailMismatches(entityId: ID, limit: Int, offset: Int): [LegacyAttributesEmailMismatch!]! systemStatus: SystemStatus! signingKeys: [SigningKey!]! } diff --git a/src/graphql/admin.rs b/src/graphql/admin.rs index 98586b0b..800aab57 100644 --- a/src/graphql/admin.rs +++ b/src/graphql/admin.rs @@ -6,6 +6,7 @@ use crate::{ auth::{has_capability_in_scope, require_capability, AuthContext, Scope}, authz::repo as authz_repo, error::AppError, + identity::repo as identity_repo, models::access::{AdminPageQuery, AuditQuery, ExpiringCredentialsQuery}, state::AppState, }; @@ -15,7 +16,8 @@ use super::{ types::{ parse_id, parse_optional_audit_outcome, parse_optional_credential_kind, parse_optional_id, parse_optional_timestamp, AuditLog, AuditLogList, Credential, GqlAuditOutcome, - GqlCredentialKind, OrphanPolicy, + GqlCredentialKind, LegacyAttributesEmailMismatch, LegacyCredentialIdentifierMismatch, + LegacyOauthEmailMismatch, LegacyUnverifiedEmail, OrphanPolicy, }, }; @@ -151,6 +153,127 @@ impl AdminQuery { .map(Credential::from) .collect()) } + + // ─── Legacy identity audit (issue #110, workstream B) ────────────────── + // + // Read-only findings — see AGENTS.md. Remediation is a deliberately + // separate, approval-gated tool, not exposed here. + + async fn legacy_unverified_emails( + &self, + ctx: &Context<'_>, + entity_id: Option, + limit: Option, + offset: Option, + ) -> Result> { + let auth = require_auth(ctx)?; + let state = ctx.data::()?; + require_capability(state.pool(), &auth, "manage", Scope::Platform) + .await + .map_err(gql_error)?; + let report = identity_repo::legacy_unverified_emails( + state.pool(), + parse_optional_id(entity_id, "entityId")?, + AdminPageQuery { + limit: limit.map(i64::from).unwrap_or(50), + offset: offset.map(i64::from).unwrap_or(0), + }, + ) + .await + .map_err(gql_error)?; + Ok(report + .items + .into_iter() + .map(LegacyUnverifiedEmail::from) + .collect()) + } + + async fn legacy_credential_identifier_mismatches( + &self, + ctx: &Context<'_>, + entity_id: Option, + limit: Option, + offset: Option, + ) -> Result> { + let auth = require_auth(ctx)?; + let state = ctx.data::()?; + require_capability(state.pool(), &auth, "manage", Scope::Platform) + .await + .map_err(gql_error)?; + let report = identity_repo::legacy_credential_identifier_mismatches( + state.pool(), + parse_optional_id(entity_id, "entityId")?, + AdminPageQuery { + limit: limit.map(i64::from).unwrap_or(50), + offset: offset.map(i64::from).unwrap_or(0), + }, + ) + .await + .map_err(gql_error)?; + Ok(report + .items + .into_iter() + .map(LegacyCredentialIdentifierMismatch::from) + .collect()) + } + + async fn legacy_oauth_email_mismatches( + &self, + ctx: &Context<'_>, + entity_id: Option, + limit: Option, + offset: Option, + ) -> Result> { + let auth = require_auth(ctx)?; + let state = ctx.data::()?; + require_capability(state.pool(), &auth, "manage", Scope::Platform) + .await + .map_err(gql_error)?; + let report = identity_repo::legacy_oauth_email_mismatches( + state.pool(), + parse_optional_id(entity_id, "entityId")?, + AdminPageQuery { + limit: limit.map(i64::from).unwrap_or(50), + offset: offset.map(i64::from).unwrap_or(0), + }, + ) + .await + .map_err(gql_error)?; + Ok(report + .items + .into_iter() + .map(LegacyOauthEmailMismatch::from) + .collect()) + } + + async fn legacy_attributes_email_mismatches( + &self, + ctx: &Context<'_>, + entity_id: Option, + limit: Option, + offset: Option, + ) -> Result> { + let auth = require_auth(ctx)?; + let state = ctx.data::()?; + require_capability(state.pool(), &auth, "manage", Scope::Platform) + .await + .map_err(gql_error)?; + let report = identity_repo::legacy_attributes_email_mismatches( + state.pool(), + parse_optional_id(entity_id, "entityId")?, + AdminPageQuery { + limit: limit.map(i64::from).unwrap_or(50), + offset: offset.map(i64::from).unwrap_or(0), + }, + ) + .await + .map_err(gql_error)?; + Ok(report + .items + .into_iter() + .map(LegacyAttributesEmailMismatch::from) + .collect()) + } } async fn audit_tenant_filter( diff --git a/src/graphql/types/mod.rs b/src/graphql/types/mod.rs index 9b76923a..ad87f22e 100644 --- a/src/graphql/types/mod.rs +++ b/src/graphql/types/mod.rs @@ -1,4 +1,4 @@ -use async_graphql::{Context, Enum, InputObject, MaybeUndefined, Object, Result, ID}; +use async_graphql::{Context, Enum, InputObject, MaybeUndefined, Object, Result, SimpleObject, ID}; use chrono::{DateTime, Utc}; use serde_json::Value; use uuid::Uuid; @@ -1867,6 +1867,188 @@ impl From for OrphanPolicy { } } +// ─── Legacy identity audit (issue #110, workstream B) ────────────────────── + +#[derive(SimpleObject)] +#[graphql(name = "PendingTokenCounts")] +pub struct GqlPendingTokenCounts { + pub verification: i64, + pub password_reset: i64, + pub email_change: i64, + pub invitation: i64, +} + +impl From for GqlPendingTokenCounts { + fn from(counts: access_model::PendingTokenCounts) -> Self { + Self { + verification: counts.verification, + password_reset: counts.password_reset, + email_change: counts.email_change, + invitation: counts.invitation, + } + } +} + +pub struct LegacyUnverifiedEmail(pub access_model::LegacyUnverifiedEmailItem); + +#[Object] +impl LegacyUnverifiedEmail { + async fn entity_id(&self) -> ID { + id(self.0.entity_id) + } + + async fn entity_kind(&self) -> GqlEntityKind { + GqlEntityKind::from(&self.0.entity_kind) + } + + async fn entity_status(&self) -> GqlEntityStatus { + GqlEntityStatus::from(&self.0.entity_status) + } + + async fn email(&self) -> &str { + &self.0.email + } + + async fn email_created_at(&self) -> String { + timestamp(self.0.email_created_at) + } + + async fn pending_tokens(&self) -> GqlPendingTokenCounts { + self.0.pending_tokens.into() + } +} + +impl From for LegacyUnverifiedEmail { + fn from(item: access_model::LegacyUnverifiedEmailItem) -> Self { + Self(item) + } +} + +pub struct LegacyCredentialIdentifierMismatch( + pub access_model::LegacyCredentialIdentifierMismatchItem, +); + +#[Object] +impl LegacyCredentialIdentifierMismatch { + async fn credential_id(&self) -> ID { + id(self.0.credential_id) + } + + async fn entity_id(&self) -> ID { + id(self.0.entity_id) + } + + async fn identifier(&self) -> Option<&str> { + self.0.identifier.as_deref() + } + + async fn canonical_email(&self) -> Option<&str> { + self.0.canonical_email.as_deref() + } + + async fn canonical_verified_at(&self) -> Option { + self.0.canonical_verified_at.map(timestamp) + } + + async fn credential_created_at(&self) -> String { + timestamp(self.0.credential_created_at) + } + + async fn pending_tokens(&self) -> GqlPendingTokenCounts { + self.0.pending_tokens.into() + } +} + +impl From + for LegacyCredentialIdentifierMismatch +{ + fn from(item: access_model::LegacyCredentialIdentifierMismatchItem) -> Self { + Self(item) + } +} + +pub struct LegacyOauthEmailMismatch(pub access_model::LegacyOauthEmailMismatchItem); + +#[Object] +impl LegacyOauthEmailMismatch { + async fn entity_id(&self) -> ID { + id(self.0.entity_id) + } + + async fn provider(&self) -> &str { + &self.0.provider + } + + async fn subject(&self) -> &str { + &self.0.subject + } + + async fn oauth_email(&self) -> &str { + &self.0.oauth_email + } + + async fn oauth_email_verified(&self) -> bool { + self.0.oauth_email_verified + } + + async fn canonical_email(&self) -> Option<&str> { + self.0.canonical_email.as_deref() + } + + async fn canonical_verified_at(&self) -> Option { + self.0.canonical_verified_at.map(timestamp) + } + + async fn linked_at(&self) -> String { + timestamp(self.0.linked_at) + } + + async fn pending_tokens(&self) -> GqlPendingTokenCounts { + self.0.pending_tokens.into() + } +} + +impl From for LegacyOauthEmailMismatch { + fn from(item: access_model::LegacyOauthEmailMismatchItem) -> Self { + Self(item) + } +} + +pub struct LegacyAttributesEmailMismatch(pub access_model::LegacyAttributesEmailMismatchItem); + +#[Object] +impl LegacyAttributesEmailMismatch { + async fn entity_id(&self) -> ID { + id(self.0.entity_id) + } + + async fn attributes_email(&self) -> &str { + &self.0.attributes_email + } + + async fn canonical_email(&self) -> Option<&str> { + self.0.canonical_email.as_deref() + } + + async fn canonical_verified_at(&self) -> Option { + self.0.canonical_verified_at.map(timestamp) + } + + async fn entity_updated_at(&self) -> Option { + self.0.entity_updated_at.map(timestamp) + } + + async fn pending_tokens(&self) -> GqlPendingTokenCounts { + self.0.pending_tokens.into() + } +} + +impl From for LegacyAttributesEmailMismatch { + fn from(item: access_model::LegacyAttributesEmailMismatchItem) -> Self { + Self(item) + } +} + #[derive(InputObject)] pub struct LoginInput { pub identifier: String, diff --git a/src/identity/repo.rs b/src/identity/repo.rs index 8acb8c3e..3c3a9f71 100644 --- a/src/identity/repo.rs +++ b/src/identity/repo.rs @@ -7,6 +7,13 @@ use crate::{ db::DbTransaction, error::{db_err, entity_write_conflict, restore_conflict, AppError}, models::{ + access::{ + AdminPageQuery, LegacyAttributesEmailMismatchItem, + LegacyAttributesEmailMismatchesResponse, LegacyCredentialIdentifierMismatchItem, + LegacyCredentialIdentifierMismatchesResponse, LegacyOauthEmailMismatchItem, + LegacyOauthEmailMismatchesResponse, LegacyUnverifiedEmailItem, + LegacyUnverifiedEmailsResponse, PendingTokenCounts, + }, entity::{CreateEntity, Entity, EntityList, ListEntities, Ownership, UpdateEntity}, enums::{EntityKind, EntityOrderField, GroupOrderField, SortDir}, group::{CreateGroup, Group, GroupList, ListGroups, UpdateGroup}, @@ -3418,3 +3425,275 @@ fn search_pattern(q: Option) -> Option { .filter(|value| !value.is_empty()) .map(|value| format!("%{value}%")) } + +// ─── Legacy identity audit (issue #110, workstream B) ────────────────────── +// +// Every function here is read-only: plain SELECTs, no locks, no writes. +// "Live" means `deleted_at IS NULL` throughout — a tombstoned row is not a +// finding an operator can act on. Each row carries `PendingTokenCounts` +// (unexpired, unconsumed tokens naming the specific anomalous email this row +// flags) so an operator can see a self-service path may already be in +// flight before reaching for manual recovery — see AGENTS.md. + +fn pending_token_counts_from_row(row: &crate::db::Row) -> Result { + Ok(PendingTokenCounts { + verification: row.try_get("pending_verification").map_err(db_err)?, + password_reset: row.try_get("pending_reset").map_err(db_err)?, + email_change: row.try_get("pending_email_change").map_err(db_err)?, + invitation: row.try_get("pending_invitation").map_err(db_err)?, + }) +} + +/// Live `entity_emails` rows with `verified_at IS NULL`. `entity_id` +/// narrows to one account (operator follow-up on a specific report row, and +/// what the adversarial tests use against the shared test database). +pub async fn legacy_unverified_emails( + pool: &Database, + entity_id: Option, + params: AdminPageQuery, +) -> Result { + let limit = params.limit.clamp(1, 200); + let offset = params.offset.max(0); + const WHERE: &str = r#" + FROM entity_emails ee + JOIN entities e ON e.id = ee.entity_id AND e.deleted_at IS NULL + WHERE ee.verified_at IS NULL AND ee.deleted_at IS NULL + AND ($1::uuid IS NULL OR ee.entity_id = $1)"#; + let rows = crate::db::query(&format!( + r#"SELECT ee.entity_id, e.kind AS entity_kind, e.status AS entity_status, + ee.email, ee.created_at AS email_created_at, + (SELECT COUNT(*) FROM email_verification_tokens t + WHERE t.entity_id = ee.entity_id AND t.consumed_at IS NULL AND t.expires_at > now()) AS pending_verification, + (SELECT COUNT(*) FROM password_reset_tokens t + WHERE t.entity_id = ee.entity_id AND t.consumed_at IS NULL AND t.expires_at > now()) AS pending_reset, + (SELECT COUNT(*) FROM email_change_tokens t + WHERE t.entity_id = ee.entity_id AND t.consumed_at IS NULL AND t.expires_at > now()) AS pending_email_change, + (SELECT COUNT(*) FROM tenant_invitations ti + WHERE lower(ti.invitee_email) = lower(ee.email) + AND ti.accepted_at IS NULL AND ti.rejected_at IS NULL AND ti.revoked_at IS NULL + AND (ti.expires_at IS NULL OR ti.expires_at >= now())) AS pending_invitation + {WHERE} + ORDER BY ee.created_at ASC + LIMIT $2 OFFSET $3"# + )) + .bind(entity_id) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await + .map_err(db_err)?; + let total: i64 = crate::db::query_scalar::(&format!("SELECT COUNT(*) {WHERE}")) + .bind(entity_id) + .fetch_one(pool) + .await + .map_err(db_err)?; + let items = rows + .into_iter() + .map(|row| { + Ok(LegacyUnverifiedEmailItem { + entity_id: row.try_get("entity_id").map_err(db_err)?, + entity_kind: row.try_get("entity_kind").map_err(db_err)?, + entity_status: row.try_get("entity_status").map_err(db_err)?, + email: row.try_get("email").map_err(db_err)?, + email_created_at: row.try_get("email_created_at").map_err(db_err)?, + pending_tokens: pending_token_counts_from_row(&row)?, + }) + }) + .collect::, AppError>>()?; + Ok(LegacyUnverifiedEmailsResponse { items, total }) +} + +/// Active password credentials whose `identifier` differs from the entity's +/// live canonical email (including entities with no canonical email row at +/// all — `identifier` naming an address `entity_emails` does not). +pub async fn legacy_credential_identifier_mismatches( + pool: &Database, + entity_id: Option, + params: AdminPageQuery, +) -> Result { + let limit = params.limit.clamp(1, 200); + let offset = params.offset.max(0); + const WHERE: &str = r#" + FROM credentials c + JOIN entities e ON e.id = c.entity_id AND e.deleted_at IS NULL + LEFT JOIN entity_emails ee ON ee.entity_id = c.entity_id AND ee.deleted_at IS NULL + WHERE c.kind = 'password' AND c.status = 'active' + AND (ee.email IS NULL OR c.identifier IS DISTINCT FROM ee.email) + AND ($1::uuid IS NULL OR c.entity_id = $1)"#; + let rows = crate::db::query(&format!( + r#"SELECT c.id AS credential_id, c.entity_id, c.identifier, c.created_at AS credential_created_at, + ee.email AS canonical_email, ee.verified_at AS canonical_verified_at, + (SELECT COUNT(*) FROM email_verification_tokens t + WHERE t.entity_id = c.entity_id AND t.consumed_at IS NULL AND t.expires_at > now()) AS pending_verification, + (SELECT COUNT(*) FROM password_reset_tokens t + WHERE t.entity_id = c.entity_id AND t.consumed_at IS NULL AND t.expires_at > now()) AS pending_reset, + (SELECT COUNT(*) FROM email_change_tokens t + WHERE t.entity_id = c.entity_id AND t.consumed_at IS NULL AND t.expires_at > now()) AS pending_email_change, + (SELECT COUNT(*) FROM tenant_invitations ti + WHERE lower(ti.invitee_email) = lower(c.identifier) + AND ti.accepted_at IS NULL AND ti.rejected_at IS NULL AND ti.revoked_at IS NULL + AND (ti.expires_at IS NULL OR ti.expires_at >= now())) AS pending_invitation + {WHERE} + ORDER BY c.created_at ASC + LIMIT $2 OFFSET $3"# + )) + .bind(entity_id) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await + .map_err(db_err)?; + let total: i64 = crate::db::query_scalar::(&format!("SELECT COUNT(*) {WHERE}")) + .bind(entity_id) + .fetch_one(pool) + .await + .map_err(db_err)?; + let items = rows + .into_iter() + .map(|row| { + Ok(LegacyCredentialIdentifierMismatchItem { + credential_id: row.try_get("credential_id").map_err(db_err)?, + entity_id: row.try_get("entity_id").map_err(db_err)?, + identifier: row.try_get("identifier").map_err(db_err)?, + canonical_email: row.try_get("canonical_email").map_err(db_err)?, + canonical_verified_at: row.try_get("canonical_verified_at").map_err(db_err)?, + credential_created_at: row.try_get("credential_created_at").map_err(db_err)?, + pending_tokens: pending_token_counts_from_row(&row)?, + }) + }) + .collect::, AppError>>()?; + Ok(LegacyCredentialIdentifierMismatchesResponse { items, total }) +} + +/// `oauth_identities` whose claimed email does not match an active, verified +/// canonical email for the linked entity (including no canonical email at +/// all, and a canonical email that exists but is still unverified). +pub async fn legacy_oauth_email_mismatches( + pool: &Database, + entity_id: Option, + params: AdminPageQuery, +) -> Result { + let limit = params.limit.clamp(1, 200); + let offset = params.offset.max(0); + const WHERE: &str = r#" + FROM oauth_identities oi + JOIN entities e ON e.id = oi.entity_id AND e.deleted_at IS NULL + LEFT JOIN entity_emails ee ON ee.entity_id = oi.entity_id AND ee.deleted_at IS NULL + WHERE (ee.email IS NULL + OR ee.verified_at IS NULL + OR lower(ee.email) IS DISTINCT FROM lower(oi.email)) + AND ($1::uuid IS NULL OR oi.entity_id = $1)"#; + let rows = crate::db::query(&format!( + r#"SELECT oi.entity_id, oi.provider, oi.subject, oi.email AS oauth_email, + oi.email_verified AS oauth_email_verified, oi.updated_at AS linked_at, + ee.email AS canonical_email, ee.verified_at AS canonical_verified_at, + (SELECT COUNT(*) FROM email_verification_tokens t + WHERE t.entity_id = oi.entity_id AND t.consumed_at IS NULL AND t.expires_at > now()) AS pending_verification, + (SELECT COUNT(*) FROM password_reset_tokens t + WHERE t.entity_id = oi.entity_id AND t.consumed_at IS NULL AND t.expires_at > now()) AS pending_reset, + (SELECT COUNT(*) FROM email_change_tokens t + WHERE t.entity_id = oi.entity_id AND t.consumed_at IS NULL AND t.expires_at > now()) AS pending_email_change, + (SELECT COUNT(*) FROM tenant_invitations ti + WHERE lower(ti.invitee_email) = lower(oi.email) + AND ti.accepted_at IS NULL AND ti.rejected_at IS NULL AND ti.revoked_at IS NULL + AND (ti.expires_at IS NULL OR ti.expires_at >= now())) AS pending_invitation + {WHERE} + ORDER BY oi.updated_at ASC + LIMIT $2 OFFSET $3"# + )) + .bind(entity_id) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await + .map_err(db_err)?; + let total: i64 = crate::db::query_scalar::(&format!("SELECT COUNT(*) {WHERE}")) + .bind(entity_id) + .fetch_one(pool) + .await + .map_err(db_err)?; + let items = rows + .into_iter() + .map(|row| { + Ok(LegacyOauthEmailMismatchItem { + entity_id: row.try_get("entity_id").map_err(db_err)?, + provider: row.try_get("provider").map_err(db_err)?, + subject: row.try_get("subject").map_err(db_err)?, + oauth_email: row.try_get("oauth_email").map_err(db_err)?, + oauth_email_verified: row.try_get("oauth_email_verified").map_err(db_err)?, + canonical_email: row.try_get("canonical_email").map_err(db_err)?, + canonical_verified_at: row.try_get("canonical_verified_at").map_err(db_err)?, + linked_at: row.try_get("linked_at").map_err(db_err)?, + pending_tokens: pending_token_counts_from_row(&row)?, + }) + }) + .collect::, AppError>>()?; + Ok(LegacyOauthEmailMismatchesResponse { items, total }) +} + +/// Live human entities whose legacy `attributes.email` differs from the +/// canonical `entity_emails` row (including no canonical row at all). This +/// is the compatibility-mirror surface `sync_entity_email_from_attrs_in_tx` +/// and `confirm_email_change` both keep in sync going forward; a mismatch +/// here predates one of those paths running, or was written directly. +pub async fn legacy_attributes_email_mismatches( + pool: &Database, + entity_id: Option, + params: AdminPageQuery, +) -> Result { + let limit = params.limit.clamp(1, 200); + let offset = params.offset.max(0); + const WHERE: &str = r#" + FROM entities e + LEFT JOIN entity_emails ee ON ee.entity_id = e.id AND ee.deleted_at IS NULL + WHERE e.kind = 'human' AND e.deleted_at IS NULL + -- `->>'email'` is NULL both when the key is missing and when it + -- is present as JSON null, so this alone catches every row that + -- carries no reconcilable legacy email address. + AND e.attributes->>'email' IS NOT NULL + AND (ee.email IS NULL + OR lower(e.attributes->>'email') IS DISTINCT FROM lower(ee.email)) + AND ($1::uuid IS NULL OR e.id = $1)"#; + let rows = crate::db::query(&format!( + r#"SELECT e.id AS entity_id, e.attributes->>'email' AS attributes_email, e.updated_at AS entity_updated_at, + ee.email AS canonical_email, ee.verified_at AS canonical_verified_at, + (SELECT COUNT(*) FROM email_verification_tokens t + WHERE t.entity_id = e.id AND t.consumed_at IS NULL AND t.expires_at > now()) AS pending_verification, + (SELECT COUNT(*) FROM password_reset_tokens t + WHERE t.entity_id = e.id AND t.consumed_at IS NULL AND t.expires_at > now()) AS pending_reset, + (SELECT COUNT(*) FROM email_change_tokens t + WHERE t.entity_id = e.id AND t.consumed_at IS NULL AND t.expires_at > now()) AS pending_email_change, + (SELECT COUNT(*) FROM tenant_invitations ti + WHERE lower(ti.invitee_email) = lower(e.attributes->>'email') + AND ti.accepted_at IS NULL AND ti.rejected_at IS NULL AND ti.revoked_at IS NULL + AND (ti.expires_at IS NULL OR ti.expires_at >= now())) AS pending_invitation + {WHERE} + ORDER BY e.updated_at ASC NULLS LAST + LIMIT $2 OFFSET $3"# + )) + .bind(entity_id) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await + .map_err(db_err)?; + let total: i64 = crate::db::query_scalar::(&format!("SELECT COUNT(*) {WHERE}")) + .bind(entity_id) + .fetch_one(pool) + .await + .map_err(db_err)?; + let items = rows + .into_iter() + .map(|row| { + Ok(LegacyAttributesEmailMismatchItem { + entity_id: row.try_get("entity_id").map_err(db_err)?, + attributes_email: row.try_get("attributes_email").map_err(db_err)?, + canonical_email: row.try_get("canonical_email").map_err(db_err)?, + canonical_verified_at: row.try_get("canonical_verified_at").map_err(db_err)?, + entity_updated_at: row.try_get("entity_updated_at").map_err(db_err)?, + pending_tokens: pending_token_counts_from_row(&row)?, + }) + }) + .collect::, AppError>>()?; + Ok(LegacyAttributesEmailMismatchesResponse { items, total }) +} diff --git a/src/models/access.rs b/src/models/access.rs index 462a43ff..e081d1af 100644 --- a/src/models/access.rs +++ b/src/models/access.rs @@ -246,6 +246,95 @@ pub struct ExpiringCredentialsResponse { pub total: i64, } +// ─── Legacy identity audit (issue #110, workstream B) ────────────────────── +// +// Read-only findings only — no row here has been mutated. Recovery/ +// remediation is a deliberately separate, approval-gated tool (not part of +// this report) per the issue's delivery order. + +/// Outstanding (unexpired, unconsumed) tokens that could independently prove +/// or resolve an identity's email state, attached to each finding so an +/// operator can see whether a self-service path is already in flight before +/// reaching for manual recovery. Never carries a token value or hash. +#[derive(Debug, Clone, Copy, Serialize)] +pub struct PendingTokenCounts { + pub verification: i64, + pub password_reset: i64, + pub email_change: i64, + pub invitation: i64, +} + +#[derive(Debug, Serialize)] +pub struct LegacyUnverifiedEmailItem { + pub entity_id: Uuid, + pub entity_kind: EntityKind, + pub entity_status: EntityStatus, + pub email: String, + pub email_created_at: DateTime, + pub pending_tokens: PendingTokenCounts, +} + +#[derive(Debug, Serialize)] +pub struct LegacyUnverifiedEmailsResponse { + pub items: Vec, + pub total: i64, +} + +#[derive(Debug, Serialize)] +pub struct LegacyCredentialIdentifierMismatchItem { + pub credential_id: Uuid, + pub entity_id: Uuid, + /// `None` only if `identifier` was never set (an argon2-only legacy row + /// predating identifier tracking) — a mismatch either way against a + /// canonical email that does exist. + pub identifier: Option, + pub canonical_email: Option, + pub canonical_verified_at: Option>, + pub credential_created_at: DateTime, + pub pending_tokens: PendingTokenCounts, +} + +#[derive(Debug, Serialize)] +pub struct LegacyCredentialIdentifierMismatchesResponse { + pub items: Vec, + pub total: i64, +} + +#[derive(Debug, Serialize)] +pub struct LegacyOauthEmailMismatchItem { + pub entity_id: Uuid, + pub provider: String, + pub subject: String, + pub oauth_email: String, + pub oauth_email_verified: bool, + pub canonical_email: Option, + pub canonical_verified_at: Option>, + pub linked_at: DateTime, + pub pending_tokens: PendingTokenCounts, +} + +#[derive(Debug, Serialize)] +pub struct LegacyOauthEmailMismatchesResponse { + pub items: Vec, + pub total: i64, +} + +#[derive(Debug, Serialize)] +pub struct LegacyAttributesEmailMismatchItem { + pub entity_id: Uuid, + pub attributes_email: String, + pub canonical_email: Option, + pub canonical_verified_at: Option>, + pub entity_updated_at: Option>, + pub pending_tokens: PendingTokenCounts, +} + +#[derive(Debug, Serialize)] +pub struct LegacyAttributesEmailMismatchesResponse { + pub items: Vec, + pub total: i64, +} + fn default_limit() -> i64 { 20 } diff --git a/tests/m53_legacy_identity_audit.rs b/tests/m53_legacy_identity_audit.rs new file mode 100644 index 00000000..43dea2b7 --- /dev/null +++ b/tests/m53_legacy_identity_audit.rs @@ -0,0 +1,426 @@ +//! Issue #110, workstream B: the dry-run legacy identity audit report. +//! +//! Every query here is read-only — these tests assert findings appear (or +//! are correctly excluded) and that nothing in the database changes as a +//! side effect of running the report. +//! +//! Run with: +//! ```bash +//! DATABASE_URL=postgres://... cargo test --test m53_legacy_identity_audit -- --ignored +//! ``` + +mod common; + +use async_graphql::Request; +use atom::db::{query, query_as, query_scalar, Database}; +use atom::{ + auth::AuthContext, + config::Config, + graphql::build_schema, + identity::service, + keys::{ActiveKeys, LoadedKey}, + state::AppState, +}; +use serde_json::json; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] +struct EntityEmailAttrs { + email: String, + verified_at: Option>, + attributes: serde_json::Value, +} + +fn state(pool: Database) -> AppState { + let primary = LoadedKey { + kid: "test".into(), + public_key_pem: String::new(), + private_key_pem: String::new(), + x_b64: String::new(), + y_b64: String::new(), + }; + AppState::new( + pool, + Config::for_tests(), + ActiveKeys { + primary, + standby: None, + }, + None, + ) +} + +fn authed(query: impl Into) -> Request { + Request::new(query).data(AuthContext { + entity_id: common::admin_id(), + tenant_id: None, + session_id: None, + ..Default::default() + }) +} + +fn authed_as(entity_id: Uuid, query: impl Into) -> Request { + Request::new(query).data(AuthContext { + entity_id, + tenant_id: None, + session_id: None, + ..Default::default() + }) +} + +async fn human(pool: &Database, attributes: serde_json::Value) -> Uuid { + let id = Uuid::new_v4(); + query( + "INSERT INTO entities (id, kind, name, tenant_id, status, attributes) \ + VALUES ($1, 'human', $2, NULL, 'active', $3)", + ) + .bind(id) + .bind(format!("legacy-audit-{id}")) + .bind(attributes) + .execute(pool) + .await + .expect("insert human"); + id +} + +async fn entity_email(pool: &Database, entity_id: Uuid, email: &str, verified: bool) { + query( + "INSERT INTO entity_emails (id, entity_id, email, verified_at) \ + VALUES ($1, $2, $3, CASE WHEN $4 THEN now() ELSE NULL END)", + ) + .bind(Uuid::new_v4()) + .bind(entity_id) + .bind(email) + .bind(verified) + .execute(pool) + .await + .expect("insert entity_emails"); +} + +async fn password_credential(pool: &Database, entity_id: Uuid, identifier: &str) { + let hash = service::hash_secret(b"irrelevant-test-secret").expect("hash"); + query( + "INSERT INTO credentials (id, entity_id, kind, identifier, secret_hash) \ + VALUES ($1, $2, 'password', $3, $4)", + ) + .bind(Uuid::new_v4()) + .bind(entity_id) + .bind(identifier) + .bind(hash) + .execute(pool) + .await + .expect("insert password credential"); +} + +async fn oauth_identity(pool: &Database, entity_id: Uuid, email: &str, verified: bool) { + query( + "INSERT INTO oauth_identities (id, entity_id, provider, subject, email, email_verified) \ + VALUES ($1, $2, 'test-provider', $3, $4, $5)", + ) + .bind(Uuid::new_v4()) + .bind(entity_id) + .bind(format!("subject-{entity_id}")) + .bind(email) + .bind(verified) + .execute(pool) + .await + .expect("insert oauth identity"); +} + +fn find<'a>(items: &'a serde_json::Value, entity_id: Uuid) -> Option<&'a serde_json::Value> { + items + .as_array() + .expect("array") + .iter() + .find(|item| item["entityId"] == entity_id.to_string()) +} + +#[tokio::test] +#[ignore] +async fn legacy_unverified_emails_lists_only_unverified_live_rows() { + let pool = common::pool().await; + let unverified = human(&pool, json!({})).await; + entity_email( + &pool, + unverified, + &format!("unverified-{unverified}@example.test"), + false, + ) + .await; + let verified = human(&pool, json!({})).await; + entity_email( + &pool, + verified, + &format!("verified-{verified}@example.test"), + true, + ) + .await; + let schema = build_schema(state(pool)); + + let response = schema + .execute(authed( + "{ legacyUnverifiedEmails(limit: 200) { entityId email entityKind entityStatus \ + pendingTokens { verification passwordReset emailChange invitation } } }", + )) + .await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + let data = response.data.into_json().expect("json"); + let items = &data["legacyUnverifiedEmails"]; + + let row = find(items, unverified).expect("unverified email must be reported"); + assert_eq!(row["entityKind"], "human"); + assert_eq!(row["entityStatus"], "active"); + assert_eq!(row["pendingTokens"]["verification"], 0); + assert_eq!(row["pendingTokens"]["passwordReset"], 0); + assert_eq!(row["pendingTokens"]["emailChange"], 0); + assert_eq!(row["pendingTokens"]["invitation"], 0); + + assert!( + find(items, verified).is_none(), + "a verified email must not be reported" + ); +} + +#[tokio::test] +#[ignore] +async fn legacy_unverified_emails_counts_pending_tokens() { + let pool = common::pool().await; + let entity_id = human(&pool, json!({})).await; + let email = format!("pending-{entity_id}@example.test"); + entity_email(&pool, entity_id, &email, false).await; + let email_id: Uuid = query_scalar::("SELECT id FROM entity_emails WHERE entity_id = $1") + .bind(entity_id) + .fetch_one(&pool) + .await + .expect("email id"); + query( + r#"INSERT INTO email_verification_tokens (id, entity_id, email_id, secret_hash, expires_at) + VALUES ($1, $2, $3, $4, now() + interval '1 day')"#, + ) + .bind(Uuid::new_v4()) + .bind(entity_id) + .bind(email_id) + .bind(service::hash_secret(b"irrelevant").expect("hash")) + .execute(&pool) + .await + .expect("insert verification token"); + + let schema = build_schema(state(pool)); + let response = schema + .execute(authed(format!( + "{{ legacyUnverifiedEmails(entityId: \"{entity_id}\") {{ entityId \ + pendingTokens {{ verification }} }} }}" + ))) + .await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + let data = response.data.into_json().expect("json"); + let row = find(&data["legacyUnverifiedEmails"], entity_id).expect("row present"); + assert_eq!(row["pendingTokens"]["verification"], 1); +} + +#[tokio::test] +#[ignore] +async fn legacy_credential_identifier_mismatches_excludes_matching_and_includes_missing_canonical() +{ + let pool = common::pool().await; + let matching = human(&pool, json!({})).await; + let matching_email = format!("matches-{matching}@example.test"); + entity_email(&pool, matching, &matching_email, true).await; + password_credential(&pool, matching, &matching_email).await; + + let drifted = human(&pool, json!({})).await; + entity_email( + &pool, + drifted, + &format!("canonical-{drifted}@example.test"), + true, + ) + .await; + password_credential(&pool, drifted, &format!("stale-{drifted}@example.test")).await; + + let no_canonical = human(&pool, json!({})).await; + password_credential( + &pool, + no_canonical, + &format!("orphan-{no_canonical}@example.test"), + ) + .await; + + let schema = build_schema(state(pool)); + let response = schema + .execute(authed( + "{ legacyCredentialIdentifierMismatches(limit: 200) { entityId identifier \ + canonicalEmail } }", + )) + .await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + let data = response.data.into_json().expect("json"); + let items = &data["legacyCredentialIdentifierMismatches"]; + + assert!( + find(items, matching).is_none(), + "a credential matching the canonical email must not be reported" + ); + let drifted_row = find(items, drifted).expect("drifted identifier must be reported"); + assert!(drifted_row["canonicalEmail"].is_string()); + let orphan_row = + find(items, no_canonical).expect("credential with no canonical email must be reported"); + assert!(orphan_row["canonicalEmail"].is_null()); +} + +#[tokio::test] +#[ignore] +async fn legacy_oauth_email_mismatches_flags_stale_and_unverified_links() { + let pool = common::pool().await; + let matching = human(&pool, json!({})).await; + let matching_email = format!("oauth-match-{matching}@example.test"); + entity_email(&pool, matching, &matching_email, true).await; + oauth_identity(&pool, matching, &matching_email, true).await; + + let stale = human(&pool, json!({})).await; + entity_email( + &pool, + stale, + &format!("canonical-{stale}@example.test"), + true, + ) + .await; + oauth_identity( + &pool, + stale, + &format!("stale-oauth-{stale}@example.test"), + true, + ) + .await; + + let unverified_canonical = human(&pool, json!({})).await; + let shared_email = format!("shared-{unverified_canonical}@example.test"); + entity_email(&pool, unverified_canonical, &shared_email, false).await; + oauth_identity(&pool, unverified_canonical, &shared_email, true).await; + + let schema = build_schema(state(pool)); + let response = schema + .execute(authed( + "{ legacyOauthEmailMismatches(limit: 200) { entityId oauthEmail canonicalEmail \ + canonicalVerifiedAt } }", + )) + .await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + let data = response.data.into_json().expect("json"); + let items = &data["legacyOauthEmailMismatches"]; + + assert!( + find(items, matching).is_none(), + "an oauth link matching a verified canonical email must not be reported" + ); + assert!( + find(items, stale).is_some(), + "a stale oauth link must be reported" + ); + let unverified_row = + find(items, unverified_canonical).expect("an unverified canonical email must be reported"); + assert!(unverified_row["canonicalVerifiedAt"].is_null()); +} + +#[tokio::test] +#[ignore] +async fn legacy_attributes_email_mismatches_ignores_entities_without_the_legacy_key() { + let pool = common::pool().await; + let drifted = human( + &pool, + json!({ "email": format!("legacy-{}@example.test", Uuid::new_v4()) }), + ) + .await; + let canonical_email = format!("canonical-{drifted}@example.test"); + entity_email(&pool, drifted, &canonical_email, true).await; + + let never_had_one = human(&pool, json!({})).await; + entity_email( + &pool, + never_had_one, + &format!("only-canonical-{never_had_one}@example.test"), + true, + ) + .await; + + let schema = build_schema(state(pool)); + let response = schema + .execute(authed( + "{ legacyAttributesEmailMismatches(limit: 200) { entityId attributesEmail \ + canonicalEmail } }", + )) + .await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + let data = response.data.into_json().expect("json"); + let items = &data["legacyAttributesEmailMismatches"]; + + let row = find(items, drifted).expect("drifted attributes.email must be reported"); + assert_eq!(row["canonicalEmail"], canonical_email); + assert!( + find(items, never_had_one).is_none(), + "an entity that never had attributes.email must not be reported" + ); +} + +#[tokio::test] +#[ignore] +async fn legacy_reports_require_platform_manage() { + let pool = common::pool().await; + let caller = human(&pool, json!({})).await; + let schema = build_schema(state(pool)); + + for query in [ + "{ legacyUnverifiedEmails(limit: 1) { entityId } }", + "{ legacyCredentialIdentifierMismatches(limit: 1) { entityId } }", + "{ legacyOauthEmailMismatches(limit: 1) { entityId } }", + "{ legacyAttributesEmailMismatches(limit: 1) { entityId } }", + ] { + let response = schema.execute(authed_as(caller, query)).await; + assert_eq!(response.errors.len(), 1, "{query}: {:?}", response.errors); + assert_eq!(response.errors[0].message, "forbidden", "{query}"); + } +} + +#[tokio::test] +#[ignore] +async fn legacy_reports_mutate_nothing() { + let pool = common::pool().await; + let entity_id = human(&pool, json!({ "email": "unused" })).await; + let email = format!("read-only-{entity_id}@example.test"); + entity_email(&pool, entity_id, &email, false).await; + password_credential( + &pool, + entity_id, + &format!("mismatched-{entity_id}@example.test"), + ) + .await; + let before: EntityEmailAttrs = query_as::( + "SELECT ee.email, ee.verified_at, e.attributes \ + FROM entity_emails ee JOIN entities e ON e.id = ee.entity_id \ + WHERE ee.entity_id = $1", + ) + .bind(entity_id) + .fetch_one(&pool) + .await + .expect("before state"); + + let schema = build_schema(state(pool.clone())); + let response = schema + .execute(authed(format!( + "{{ legacyUnverifiedEmails(entityId: \"{entity_id}\") {{ entityId }} \ + legacyCredentialIdentifierMismatches(entityId: \"{entity_id}\") {{ entityId }} }}" + ))) + .await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + + let after: EntityEmailAttrs = query_as::( + "SELECT ee.email, ee.verified_at, e.attributes \ + FROM entity_emails ee JOIN entities e ON e.id = ee.entity_id \ + WHERE ee.entity_id = $1", + ) + .bind(entity_id) + .fetch_one(&pool) + .await + .expect("after state"); + assert_eq!(before, after, "running the report must not mutate any row"); +}