Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c35f62d
feat(iam): define hashed invitation token invariants
BeforeLights Aug 3, 2026
92b18ed
feat(iam): publish invitation invariants in domain aggregate
BeforeLights Aug 3, 2026
46b6939
feat(iam): add invitation issue and redemption service
BeforeLights Aug 3, 2026
9f14db2
feat(iam): add scoped invitation repository adapter
BeforeLights Aug 3, 2026
2a65756
feat(iam): persist invitation token records
BeforeLights Aug 3, 2026
5ce9fe3
docs(iam): record invitation token evidence
BeforeLights Aug 3, 2026
bf311ec
feat(iam): add keyed invitation crypto adapters
BeforeLights Aug 3, 2026
ab4d22c
feat(iam): expose invitation HTTP composition
BeforeLights Aug 3, 2026
003f7c9
feat(iam): add durable principal email lookup
BeforeLights Aug 3, 2026
b7701a7
feat(iam): add atomic account registration service
BeforeLights Aug 3, 2026
a6a6168
feat(iam): persist account registration atomically
BeforeLights Aug 3, 2026
4fd6236
fix(repo): normalize invitation formatting
BeforeLights Aug 3, 2026
a8ec43e
feat(iam): expose account registration endpoint
BeforeLights Aug 3, 2026
26dd0ee
feat(iam): add bounded recovery challenge invariants
BeforeLights Aug 3, 2026
371ccdf
feat(iam): add recovery service and transactional state effects
BeforeLights Aug 3, 2026
857aa05
feat(iam): persist recovery security state atomically
BeforeLights Aug 3, 2026
66abc06
feat(iam): expose account recovery endpoints
BeforeLights Aug 3, 2026
20888ec
feat(iam): clear recovery gate after MFA verification
BeforeLights Aug 3, 2026
c7faf68
feat(iam): propagate MFA recovery re-enrollment state
BeforeLights Aug 3, 2026
42bb322
fix(iam): compare and set recovery challenge revisions
BeforeLights Aug 3, 2026
1269428
feat(iam): add bounded recovery admission
BeforeLights Aug 3, 2026
33882d9
fix(iam): avoid password hashing for invalid recovery tokens
BeforeLights Aug 3, 2026
c6b7228
feat(iam): enforce recovery mfa gate on approvals
BeforeLights Aug 3, 2026
94b8766
feat(iam): guard high-risk step-up during mfa reenrollment
BeforeLights Aug 3, 2026
e60170f
feat(iam): add shared recovery admission adapter
BeforeLights Aug 3, 2026
8f0a7c2
feat(iam): compose shared recovery admission
BeforeLights Aug 3, 2026
32d8687
feat(iam): bind recovery admission to redis eval
BeforeLights Aug 3, 2026
20b592e
feat(iam): provide context-aware mfa step-up
BeforeLights Aug 3, 2026
1302f88
feat(iam): bound recovery completion attempts
BeforeLights Aug 3, 2026
fe8380d
feat(iam): separate recovery completion admission
BeforeLights Aug 3, 2026
fee9579
fix(repo): align iam mocks with async lint contract
BeforeLights Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/operations/iam-010-invitation-token-2026-08-03.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# IAM-010 durable invitation token slice — 2026-08-03

## Scope

This evidence record covers the durable persistence boundary for the IAM-010 invitation-token
slice. It is a partial implementation record, not a release approval or a claim that Plan 020 is
complete.

## Delivered

- `2a65756` adds the `iam.invitation_tokens` Prisma model and centrally ordered migration.
- Only the token digest and recipient-email digest are persisted; the raw bearer value is accepted
only by the delivery port and is never returned by the application result or stored in a row.
- `PrismaIamInvitationRepositoryAdapter` maps persisted rows through domain validation, enforces
tenant scope visibility, rejects sibling-token reads, prevents multiple active invitations per
membership, and uses compare-and-set revisions for redemption and membership activation.
- The Prisma foundation test proves the schema diff and migration inventory include the new table.

## Verification

- `corepack pnpm --filter @databreeze/api exec prisma validate --config prisma.config.ts`
- `corepack pnpm --filter @databreeze/api test` — 352 tests passed.
- `corepack pnpm --filter @databreeze/domain test` — 134 tests passed.
- `git diff --check` passed before commit.

## Explicitly not complete

The invitation HTTP/controller and production composition wiring, transactional AUD append,
registration for unknown recipients, resend/revocation administration, email-provider adapter,
and production PostgreSQL/backup/security evidence remain future work. IAM-010 therefore remains
`partial` and `not-verified` in the requirement manifest.
33 changes: 33 additions & 0 deletions docs/operations/iam-recovery-2026-08-03.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# IAM recovery slice — 2026-08-03

This evidence records the partial account-recovery boundary delivered on `feat/iam-recovery`.
It does not claim that IAM-015 or the IAM plan is complete.

## Scope

- Validate a bounded recovery request and return the same accepted response for known and unknown email addresses.
- Generate a short-lived, single-use bearer, deliver the raw value only through the delivery port, and persist only keyed HMAC digests.
- Consume the bearer exactly once and atomically rotate the Argon2id credential, advance the user security epoch, revoke active sessions and MFA factors, and mark MFA re-enrollment required.
- Clear the re-enrollment gate in the same MFA transaction when a newly enrolled factor is successfully verified; failed proofs do not clear it.
- Carry the live gate through credential lookup, session lookup, protected request context, and sign-in/current-session projections without trusting client-supplied state.
- Apply a bounded, replaceable recovery-admission port before account lookup; unknown and throttled addresses receive the same generic response.
- The `RedisRecoveryAdmissionAdapter` implements that port for horizontally scaled deployments. It accepts only keyed digests, namespaces counter keys, requires an injected atomic `INCR`/`PEXPIRE` implementation, and fails closed on malformed input or counter failure. The in-memory adapter remains the alpha default until a Redis client is provisioned.
- Completion attempts use a separate admission port and, when Redis is configured, a distinct `databreeze:iam:recovery:completion:v1:` namespace so email-request and token-brute-force limits cannot collide.
- Keep the public completion response free of bearer material; no session is automatically created.
- Select the Prisma recovery adapter only when persistence is configured, and fail closed when the delivery, digest, or password boundary is missing.

## Evidence

- Recovery state-machine tests: `packages/domain/test/recovery-v1.test.mjs`.
- Abuse-control tests: `services/api/test/features/iam/recovery-admission.test.ts` and `redis-recovery-admission.adapter.test.ts`.
- In-memory transaction/service tests: `services/api/test/features/iam/recovery.service.test.ts`.
- Durable schema adapter and atomic side-effect tests: `services/api/test/features/iam/prisma-recovery-repository.test.ts`.
- MFA re-enrollment transaction tests: `services/api/test/features/iam/mfa.service.test.ts` and `services/api/test/features/iam/prisma-mfa-repository.test.ts`.
- Live principal/context propagation tests: `services/api/test/features/iam/prisma-credential-lookup.test.ts`, `services/api/test/features/iam/prisma-session-lifecycle.test.ts`, and `services/api/test/platform/http/session-tenant-context.test.ts`.
- Composition/controller/HTTP tests: `services/api/test/features/iam/recovery-composition.test.ts`, `recovery-controller.test.ts`, and `recovery-http.test.ts`.
- Public routes: `services/api/openapi/v1.json` (`POST /v1/auth/recovery` and `POST /v1/auth/recovery/complete`).
- Bilingual problem copy: `packages/i18n/src/catalogs-v1.ts` and `packages/i18n/test/catalogs-v1.test.mjs`.

## Verification

The scoped API TypeScript build, recovery tests, i18n tests, OpenAPI generation/check, and Prisma validation passed on 2026-08-03. The requirement remains `partial` and `not-verified` until authenticated MFA re-enrollment enforcement, audit events, rate limits, abuse monitoring, restoration drills, and the complete IAM release gates are delivered.
26 changes: 26 additions & 0 deletions docs/operations/iam-registration-2026-08-03.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# IAM registration slice — 2026-08-03

This evidence records the partial account-registration boundary delivered on `feat/iam-registration`.
It does not claim that IAM-001 or the IAM plan is complete.

## Scope

- Normalize and validate the email, display name, locale, and password at the application boundary.
- Hash the password through the existing Argon2id password port; raw passwords never enter persistence.
- Create the user, credential, personal organization, workspace, internal project, and owner membership in one transaction.
- Keep duplicate-email responses generic and map persistence races to a safe rejection.
- Return only hierarchy identifiers and locale from `POST /v1/auth/register`; the endpoint never returns bearer material or automatically creates a session.
- Select the Prisma registration adapter only when durable registration storage and the password boundary are configured; otherwise the endpoint fails closed.

## Evidence

- Service and in-memory transaction tests: `services/api/test/features/iam/registration.service.test.ts`.
- Durable adapter and rollback tests: `services/api/test/features/iam/prisma-registration-repository.test.ts`.
- Composition and controller tests: `services/api/test/features/iam/registration-composition.test.ts` and `services/api/test/features/iam/registration-controller.test.ts`.
- HTTP and problem-details tests: `services/api/test/features/iam/registration-http.test.ts`.
- OpenAPI route: `services/api/openapi/v1.json` (`POST /v1/auth/register`).
- Bilingual error catalog coverage: `packages/i18n/src/catalogs-v1.ts` and `packages/i18n/test/catalogs-v1.test.mjs`.

## Verification

The scoped API TypeScript build, registration tests, i18n tests, OpenAPI generation/check, and Redocly validation passed on 2026-08-03. The requirement remains `partial` and `not-verified` until the complete IAM release gates, audit integration, recovery, MFA, and restoration evidence are delivered.
58 changes: 46 additions & 12 deletions docs/plans/requirement-traceability.json
Original file line number Diff line number Diff line change
Expand Up @@ -8985,25 +8985,37 @@
"priority": "P0",
"primaryPlan": "020-identity-audit-entitlements.md",
"primaryTask": "Task 1: IAM identity and permissions",
"supportingTasks": [],
"supportingTasks": [
"IAM-010 durable invitation token persistence slice",
"IAM registration and personal-tenant persistence slice"
],
"codePaths": [
"packages/domain/src/identity/v1.ts",
"packages/domain/src/permissions/v1.ts",
"packages/domain/src/authorization/v1.ts",
"packages/domain/src/mfa/v1.ts",
"packages/domain/src/csrf/v1.ts",
"services/api/src/features/iam/",
"services/api/prisma/schema/iam.prisma"
"services/api/src/features/iam/adapter/prisma-iam-invitation-repository.adapter.ts",
"services/api/src/features/iam/adapter/prisma-registration-repository.adapter.ts",
"services/api/prisma/schema/iam.prisma",
"services/api/prisma/migrations/20260803040000_iam_invitation_tokens/migration.sql"
],
"testPaths": [
"packages/domain/test/identity-v1.test.mjs",
"packages/domain/test/permissions-v1.test.mjs",
"services/api/test/features/iam/",
"services/api/test/features/iam/prisma-iam-invitation-repository.test.ts",
"services/api/test/features/iam/prisma-registration-repository.test.ts",
"services/api/test/features/iam/registration.service.test.ts",
"services/api/test/features/iam/registration-http.test.ts",
"services/api/test/prisma-foundation.test.mjs",
"services/api/test/platform/http/session-tenant-context.test.ts",
"services/api/test/platform/http/csrf-protection.test.ts"
],
"releaseEvidence": [
"docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md"
"docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md",
"docs/operations/iam-registration-2026-08-03.md"
],
"status": "partial",
"coverage": "partial",
Expand Down Expand Up @@ -9283,11 +9295,12 @@
],
"releaseEvidence": [
"requirement-linked-tests",
"docs/operations/iam-010-invitation-token-2026-08-03.md",
"security-and-tenant-gate",
"release-manager-approval"
],
"status": "planned",
"coverage": "planned",
"status": "partial",
"coverage": "partial",
"verificationStatus": "not-verified",
"verifiedPaths": [],
"releaseStatus": "p0-release-gate"
Expand Down Expand Up @@ -9430,23 +9443,44 @@
"packages/domain/src/authorization/v1.ts",
"packages/domain/src/mfa/v1.ts",
"packages/domain/src/csrf/v1.ts",
"packages/domain/src/recovery/v1.ts",
"services/api/src/features/iam/",
"services/api/prisma/schema/iam.prisma"
"services/api/prisma/schema/iam.prisma",
"services/api/prisma/migrations/20260803050000_iam_recovery_challenges/migration.sql",
"services/api/src/features/iam/application/recovery.service.ts",
"services/api/src/features/iam/application/mfa.service.ts",
"services/api/src/features/iam/application/mfa-repository.port.ts",
"services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts",
"services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts",
"services/api/src/features/iam/adapter/iam-recovery-crypto.adapter.ts",
"services/api/src/features/iam/adapter/in-memory-recovery-admission.adapter.ts",
"services/api/src/features/iam/adapter/redis-recovery-admission.adapter.ts",
"services/api/src/features/iam/application/recovery-repository.port.ts",
"services/api/src/features/iam/iam.module.ts",
"services/api/src/features/iam/api/recovery.controller.ts"
],
"testPaths": [
"packages/domain/test/identity-v1.test.mjs",
"packages/domain/test/permissions-v1.test.mjs",
"services/api/test/features/iam/",
"packages/domain/test/recovery-v1.test.mjs",
"services/api/test/features/iam/recovery.service.test.ts",
"services/api/test/features/iam/prisma-recovery-repository.test.ts",
"services/api/test/features/iam/recovery-crypto.test.ts",
"services/api/test/features/iam/recovery-admission.test.ts",
"services/api/test/features/iam/redis-recovery-admission.adapter.test.ts",
"services/api/test/features/iam/mfa.service.test.ts",
"services/api/test/features/iam/prisma-mfa-repository.test.ts",
"services/api/test/features/iam/recovery-composition.test.ts",
"services/api/test/features/iam/recovery-controller.test.ts",
"services/api/test/features/iam/recovery-http.test.ts",
"services/api/test/platform/http/session-tenant-context.test.ts",
"services/api/test/platform/http/csrf-protection.test.ts"
],
"releaseEvidence": [
"requirement-linked-tests",
"security-and-tenant-gate",
"release-manager-approval"
"docs/operations/iam-recovery-2026-08-03.md"
],
"status": "planned",
"coverage": "planned",
"status": "partial",
"coverage": "partial",
"verificationStatus": "not-verified",
"verifiedPaths": [],
"releaseStatus": "ga-completion"
Expand Down
8 changes: 8 additions & 0 deletions packages/domain/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@
"types": "./src/mfa/v1.ts",
"import": "./dist/mfa/v1.js"
},
"./invitation/v1": {
"types": "./src/invitation/v1.ts",
"import": "./dist/invitation/v1.js"
},
"./recovery/v1": {
"types": "./src/recovery/v1.ts",
"import": "./dist/recovery/v1.js"
},
"./device-authorization/v1": {
"types": "./src/device-authorization/v1.ts",
"import": "./dist/device-authorization/v1.js"
Expand Down
1 change: 1 addition & 0 deletions packages/domain/src/approval/v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export type ApprovalErrorCodeV1 =
| 'SUBJECT_HASH_MISMATCH'
| 'SELF_APPROVAL_FORBIDDEN'
| 'MFA_REQUIRED'
| 'MFA_REENROLLMENT_REQUIRED'
| 'REQUEST_NOT_OPEN';

export type ApprovalResultV1<TValue> =
Expand Down
153 changes: 153 additions & 0 deletions packages/domain/src/invitation/v1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { INVITATION_MAX_SECONDS_V1, type InitialRoleIdForIdentityV1 } from '../identity/v1.js';
import {
parseStableIdentifierV1,
parseStrictUtcTimestampV1,
parseTenantScopeV1,
type StableIdentifierV1,
type StrictUtcTimestampV1,
type TenantScopeV1,
} from '../tenant-scope/v1.js';

/** IAM-010: persisted invitation tokens never contain the raw bearer value. */
export const INVITATION_TOKEN_SCHEMA_VERSION_V1 = 1 as const;
export { INVITATION_MAX_SECONDS_V1 } from '../identity/v1.js';

export type InvitationTokenStatusV1 = 'ACTIVE' | 'REDEEMED' | 'REVOKED';

export interface InvitationTokenV1 {
readonly schemaVersion: typeof INVITATION_TOKEN_SCHEMA_VERSION_V1;
readonly id: StableIdentifierV1;
readonly membershipId: StableIdentifierV1;
readonly principalId: StableIdentifierV1;
readonly scope: TenantScopeV1;
readonly roleId: InitialRoleIdForIdentityV1;
readonly tokenDigest: string;
readonly emailDigest: string;
readonly issuedAt: StrictUtcTimestampV1;
readonly expiresAt: StrictUtcTimestampV1;
readonly status: InvitationTokenStatusV1;
readonly consumedAt?: StrictUtcTimestampV1;
readonly revision: number;
}

export type InvitationTokenErrorCodeV1 =
| 'INVALID_IDENTIFIER'
| 'INVALID_SCOPE'
| 'INVALID_ROLE'
| 'INVALID_DIGEST'
| 'INVALID_TIMESTAMP'
| 'INVALID_LIFETIME'
| 'INVALID_STATE'
| 'ALREADY_CONSUMED'
| 'EXPIRED';

export type InvitationTokenResultV1<TValue> =
| { readonly accepted: true; readonly value: TValue }
| { readonly accepted: false; readonly code: InvitationTokenErrorCodeV1 };

function accepted<TValue>(value: TValue): InvitationTokenResultV1<TValue> {
return Object.freeze({ accepted: true, value });
}

function rejected(code: InvitationTokenErrorCodeV1): InvitationTokenResultV1<never> {
return Object.freeze({ accepted: false, code });
}

function stable(input: unknown): StableIdentifierV1 | undefined {
const parsed = parseStableIdentifierV1(input);
return parsed.accepted ? parsed.value : undefined;
}

function timestamp(input: unknown): StrictUtcTimestampV1 | undefined {
const parsed = parseStrictUtcTimestampV1(input);
return parsed.accepted ? parsed.value : undefined;
}

function digest(input: unknown): string | undefined {
return typeof input === 'string' && /^[a-f0-9]{64}$/u.test(input) ? input : undefined;
}

function role(input: unknown): input is InitialRoleIdForIdentityV1 {
return (
input === 'owner' ||
input === 'admin' ||
input === 'analyst' ||
input === 'operator' ||
input === 'approver' ||
input === 'viewer'
);
}

function positiveRevision(input: unknown): number | undefined {
return typeof input === 'number' && Number.isSafeInteger(input) && input >= 1 ? input : undefined;
}

export function createInvitationTokenV1(input: {
readonly id: unknown;
readonly membershipId: unknown;
readonly principalId: unknown;
readonly scope: unknown;
readonly roleId: unknown;
readonly tokenDigest: unknown;
readonly emailDigest: unknown;
readonly issuedAt: unknown;
readonly expiresAt: unknown;
readonly revision?: unknown;
}): InvitationTokenResultV1<InvitationTokenV1> {
const id = stable(input.id);
const membershipId = stable(input.membershipId);
const principalId = stable(input.principalId);
const scope = parseTenantScopeV1(input.scope);
const tokenDigest = digest(input.tokenDigest);
const emailDigest = digest(input.emailDigest);
const issuedAt = timestamp(input.issuedAt);
const expiresAt = timestamp(input.expiresAt);
const revision = input.revision === undefined ? 1 : positiveRevision(input.revision);
if (!id || !membershipId || !principalId) return rejected('INVALID_IDENTIFIER');
if (!scope.accepted) return rejected('INVALID_SCOPE');
if (!role(input.roleId)) return rejected('INVALID_ROLE');
if (!tokenDigest || !emailDigest) return rejected('INVALID_DIGEST');
if (!issuedAt || !expiresAt) return rejected('INVALID_TIMESTAMP');
if (!revision) return rejected('INVALID_STATE');
const duration = Date.parse(expiresAt) - Date.parse(issuedAt);
if (!Number.isFinite(duration) || duration <= 0 || duration > INVITATION_MAX_SECONDS_V1 * 1_000)
return rejected('INVALID_LIFETIME');
return accepted(
Object.freeze({
schemaVersion: INVITATION_TOKEN_SCHEMA_VERSION_V1,
id,
membershipId,
principalId,
scope: scope.value,
roleId: input.roleId,
tokenDigest,
emailDigest,
issuedAt,
expiresAt,
status: 'ACTIVE' as const,
revision,
}),
);
}

export function consumeInvitationTokenV1(
token: InvitationTokenV1,
at: unknown,
): InvitationTokenResultV1<InvitationTokenV1> {
const timestampValue = timestamp(at);
if (!timestampValue) return rejected('INVALID_TIMESTAMP');
if (token.status !== 'ACTIVE') return rejected('ALREADY_CONSUMED');
const nowMs = Date.parse(timestampValue);
const issuedMs = Date.parse(token.issuedAt);
const expiresMs = Date.parse(token.expiresAt);
if (!Number.isFinite(nowMs) || nowMs < issuedMs) return rejected('INVALID_TIMESTAMP');
if (nowMs >= expiresMs) return rejected('EXPIRED');
return accepted(
Object.freeze({
...token,
status: 'REDEEMED' as const,
consumedAt: timestampValue,
revision: token.revision + 1,
}),
);
}
4 changes: 4 additions & 0 deletions packages/domain/src/mfa/v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export type MfaErrorCodeV1 =
| 'FACTOR_PROOF_INVALID'
| 'RECOVERY_CODE_INVALID'
| 'RECOVERY_CODE_USED'
| 'MFA_REENROLLMENT_REQUIRED'
| 'STEP_UP_REQUIRED';

export type MfaResultV1<TValue> =
Expand Down Expand Up @@ -216,8 +217,11 @@ export function requiresStepUpV1(
assertion: StepUpAssertionV1 | undefined,
principalId: StableIdentifierV1,
now: unknown,
mfaReenrollmentRequired = false,
): MfaResultV1<true> {
if (risk !== 'NORMAL' && risk !== 'HIGH' && risk !== 'CRITICAL') return rejected('INVALID_STATE');
if (risk !== 'NORMAL' && mfaReenrollmentRequired === true)
return rejected('MFA_REENROLLMENT_REQUIRED');
if (risk === 'NORMAL') return Object.freeze({ accepted: true, value: true });
if (assertion && isFreshStepUpV1(assertion, principalId, now))
return Object.freeze({ accepted: true, value: true });
Expand Down
Loading
Loading