Skip to content
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,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).
Expand Down
4 changes: 2 additions & 2 deletions api/v1/contracts-v1.0.0.sha384
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ bd31f2c034f2a08eea2eb74c6e614fc04f073950e063f0994365aea4156df1ed524952909a1b2c4e
a44bb49d7a56221f836484db74f14ee927e650d5330e3002b2128a948929e21f54480dc53a9102e297184685fa564754 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
df8b123221dbdd868acb5decdb2aa7aeae86d2d85e5d703dc5545ace9cb5192563ae7d0ebf9a9c73cf8ecde7a97cf20a apidocs/openapi.yaml
76922de3659ca7fb769086aac81edd379f61a7dd2c41460c50cef8474fc7a9a5ac70d6c92f4b9d0a729766b30bd13635 apidocs/graphql-schema.graphql
54086362af94dcf287fc143b8be174239cb76a7e95b8701ddc5280964f6567382d05235d010b24598462e4b052fb8e80 apidocs/graphql-schema.graphql
76309eaef4ce4ec758173331c12b38ba2e187fdd09ebd6be475924bb865cca05cd182cda2d34f1e540e57f2efb22a4e5 proto/atom/v1/atom.proto
5af9ce98f8ce5061c961ea0e60f0eb66132fc8d0032367052c608d4c0d5e5de8ad38d763dc40b4f9efd6f9ca06e69aba proto/atom/v1/callout.proto
7e9342b673b00f1aa288468cf6c852c78d83991a54eb989ecc376252739da0102e656e3984e305636d7e7dfcbea213f3 proto/broker/v1/auth.proto
Expand Down
4 changes: 2 additions & 2 deletions api/v1/graphql-auth-matrix.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"]
},
Expand Down
51 changes: 51 additions & 0 deletions apidocs/graphql-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,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!
Expand Down Expand Up @@ -1033,6 +1073,13 @@ type Ownership {
createdAt: String!
}

type PendingTokenCounts {
verification: Int!
passwordReset: Int!
emailChange: Int!
invitation: Int!
}

type PermissionBlock {
id: ID!
tenantId: ID
Expand Down Expand Up @@ -1167,6 +1214,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!]!
}
Expand Down
Loading