Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
597dc5d
fix(iam): persist recovery challenges before delivery
BeforeLights Aug 4, 2026
eb2d3c9
fix(iam): compose service accounts from derived repository
BeforeLights Aug 4, 2026
bfdcd5d
fix(iam): enforce strong digest keys
BeforeLights Aug 4, 2026
8860135
fix(iam): reject recovery for inactive users
BeforeLights Aug 4, 2026
ea70d9f
fix(iam): guard service-account replacement scope
BeforeLights Aug 4, 2026
2946e92
fix(iam): persist invitations before delivery
BeforeLights Aug 4, 2026
75af992
fix(iam): map invitation invariant conflicts
BeforeLights Aug 4, 2026
7910db8
fix(iam): enforce one active invitation per membership
BeforeLights Aug 4, 2026
a723292
fix(iam): validate service-account permissions before issuance
BeforeLights Aug 4, 2026
b54f0b4
fix(bua): use server time for lease verification
BeforeLights Aug 4, 2026
716229a
fix(iam): make MFA reenrollment state explicit
BeforeLights Aug 4, 2026
03a2101
fix(security): fail closed on signer errors and MFA clear
BeforeLights Aug 4, 2026
c6c5cee
fix(api): preserve integer and UTC timestamp contracts
BeforeLights Aug 4, 2026
c4a08a2
fix(bua): compare immutable leases by fields
BeforeLights Aug 4, 2026
418b56a
fix(aud): use targeted scoped seal lookups
BeforeLights Aug 4, 2026
3f1c4e9
fix(aud): return typed attestation replays
BeforeLights Aug 4, 2026
05149f6
fix(iam): make registration outcomes indistinguishable
BeforeLights Aug 4, 2026
c61bb1c
fix(iam): throttle registration before password hashing
BeforeLights Aug 4, 2026
fa15e4b
fix(iam): honor service-account create idempotency
BeforeLights Aug 4, 2026
40af06b
test(iam): exercise registration transaction rollback
BeforeLights Aug 4, 2026
397087b
test(prisma): fix migration assertion escaping
BeforeLights Aug 4, 2026
5f1bc30
docs(iam): refresh invitation slice checkpoint
BeforeLights Aug 4, 2026
8b48661
test(iam): verify registration admission composition
BeforeLights Aug 4, 2026
108cc0a
test(iam): prove registration rollback after staged write
BeforeLights Aug 4, 2026
000554c
fix(iam): distinguish revoked invitation tokens
BeforeLights Aug 4, 2026
417885e
docs(iam): refresh registration slice evidence
BeforeLights Aug 4, 2026
6231dfd
test(iam): fail closed on unreadable account replay
BeforeLights Aug 4, 2026
e1ff67f
test(iam): verify registration admission digest boundaries
BeforeLights Aug 4, 2026
277e699
test(iam): cover replay envelope bounds
BeforeLights Aug 4, 2026
0bdd655
test(iam): correct registration timestamp assertions
BeforeLights Aug 4, 2026
ba41a8b
test(iam): preserve service-account create replay
BeforeLights Aug 4, 2026
e957e43
test(iam): keep admission fixtures lint-clean
BeforeLights Aug 4, 2026
2a49f79
Merge pull request #47 from DatabreezeService/fix/coderabbit-iam-crit…
BeforeLights Aug 4, 2026
67d1982
merge: reconcile promotion with main
BeforeLights Aug 4, 2026
bb276d8
fix(iam): align replay and entitlement contracts
BeforeLights Aug 4, 2026
5acded3
fix(iam): require durable service-account envelope keys
BeforeLights Aug 4, 2026
a2e79b6
fix(iam): require MFA reenrollment state at auth boundaries
BeforeLights Aug 4, 2026
1a90d5b
fix(iam): bound and snapshot service-account replays
BeforeLights Aug 4, 2026
9c50840
fix(iam): key registration admission and require durable counters
BeforeLights Aug 4, 2026
df8bfc8
fix(iam): durably block recovery compensation failures
BeforeLights Aug 4, 2026
888aa7d
test(iam): prove replay invalidation across lifecycle
BeforeLights Aug 4, 2026
5e2307b
fix(iam): revoke and block failed invitation delivery
BeforeLights Aug 4, 2026
70f2b27
test(iam): tighten replay framing and migration evidence
BeforeLights Aug 4, 2026
9c9af98
style(iam): normalize promotion formatting
BeforeLights Aug 4, 2026
63af721
fix(iam): satisfy durable adapter lint boundaries
BeforeLights Aug 4, 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
2 changes: 1 addition & 1 deletion docs/operations/coderabbit-pr-37-disposition.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CodeRabbit PR #37 disposition

Review run: `d034f311-c043-4139-8372-ed69b774a83f`
Review run: `d034f311-c043-4139-8372-ed69b774a83f`
Review policy: one automatic review run for this promotion PR; no rerun.

After the fixes were pushed, the CodeRabbit app automatically queued a
Expand Down
10 changes: 6 additions & 4 deletions docs/operations/iam-010-invitation-token-2026-08-03.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ complete.
- `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 versioned invitation controller and IAM module composition now expose the acceptance flow
through the same replaceable repository and delivery ports; the HTTP boundary never returns the
raw token.
- The Prisma foundation test proves the schema diff and migration inventory include the new table.

## Verification
Expand All @@ -25,7 +28,6 @@ complete.

## 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.
Transactional AUD append, registration for unknown recipients, resend/revocation administration,
an SMTP/SES delivery adapter, and production PostgreSQL/backup/security evidence remain future
work. IAM-010 therefore remains `partial` and `not-verified` in the requirement manifest.
5 changes: 3 additions & 2 deletions docs/operations/iam-registration-2026-08-03.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ It does not claim that IAM-001 or the IAM plan is complete.
- 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.
- Return a generic `202 Accepted` body (`{"accepted":true}`) from `POST /v1/auth/register`; the endpoint never returns hierarchy identifiers, bearer material, or an account-existence signal and never automatically creates a session.
- Apply bounded, domain-separated admission before Argon2id hashing: the control plane hashes the normalized client IP and normalized email into separate rate-limit namespaces, and the durable Redis adapter fails closed on counter errors.
- Select the Prisma registration adapter only when durable registration storage and the password boundary are configured; otherwise the endpoint fails closed.

## Evidence
Expand All @@ -23,4 +24,4 @@ It does not claim that IAM-001 or the IAM plan is complete.

## 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.
The scoped API TypeScript build, registration tests, i18n tests, OpenAPI generation/check, and Redocly validation passed on 2026-08-04. The rollback test stages the row in the transaction before injecting a persistence failure, proving no partial hierarchy remains. Admission tests prove IP/email throttling occurs before password hashing and persistence. The requirement remains `partial` and `not-verified` until the complete IAM release gates, audit integration, recovery, MFA, and restoration evidence are delivered.
7 changes: 7 additions & 0 deletions docs/operations/secret-rotation.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ job envelopes, or client storage. Rotation is performed by an authorized
operator through the provider adapter and is audited without recording the
plaintext value.

Service-account create-replay envelopes require a stable, managed 32-byte
envelope key in every durable environment. The process-local random fallback
is limited to tests and private alpha instances with no durable service-account
repository; it must not be used by replicas or after a restart. Rotate the
managed key through an overlap plan and invalidate old replay envelopes before
retiring the previous key.

## General procedure

1. Identify the secret reference, owning organization/workspace, provider,
Expand Down
7 changes: 6 additions & 1 deletion packages/domain/src/entitlements/v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,12 @@ export function createEntitlementLeaseV1(
expiresAt,
};
const payload = canonicalLease(unsigned);
const signature = text(signer.sign(payload), 2048);
let signature: string | undefined;
try {
signature = text(signer.sign(payload), 2048);
} catch {
return rejected('LEASE_INVALID');
}
if (!signature) return rejected('LEASE_INVALID');
return Object.freeze({
accepted: true,
Expand Down
21 changes: 20 additions & 1 deletion packages/domain/src/invitation/v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ export function consumeInvitationTokenV1(
): InvitationTokenResultV1<InvitationTokenV1> {
const timestampValue = timestamp(at);
if (!timestampValue) return rejected('INVALID_TIMESTAMP');
if (token.status !== 'ACTIVE') return rejected('ALREADY_CONSUMED');
if (token.status === 'REDEEMED') return rejected('ALREADY_CONSUMED');
if (token.status !== 'ACTIVE') return rejected('INVALID_STATE');
const nowMs = Date.parse(timestampValue);
const issuedMs = Date.parse(token.issuedAt);
const expiresMs = Date.parse(token.expiresAt);
Expand All @@ -151,3 +152,21 @@ export function consumeInvitationTokenV1(
}),
);
}

/** Revoke a token after a delivery acknowledgement failure; no bearer is reusable. */
export function revokeInvitationTokenV1(
token: InvitationTokenV1,
at: unknown,
): InvitationTokenResultV1<InvitationTokenV1> {
const timestampValue = timestamp(at);
if (!timestampValue) return rejected('INVALID_TIMESTAMP');
if (token.status !== 'ACTIVE') return rejected('INVALID_STATE');
if (Date.parse(timestampValue) < Date.parse(token.issuedAt)) return rejected('INVALID_TIMESTAMP');
return accepted(
Object.freeze({
...token,
status: 'REVOKED' as const,
revision: token.revision + 1,
}),
);
}
19 changes: 19 additions & 0 deletions packages/domain/test/entitlement-lease-issuance-v1.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,25 @@ void test('[BUA-017, BUA-018] suspended snapshots and overlong leases fail close
);
});

void test('[BUA-018] a signing-provider exception fails closed as an invalid lease', () => {
assert.deepEqual(
createEntitlementLeaseV1(
snapshot(),
{
leaseId: '00000000-0000-4000-8000-000000000759',
issuedAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2026-01-01T01:00:00.000Z',
},
{
sign: () => {
throw new Error('provider unavailable');
},
},
),
{ accepted: false, code: 'LEASE_INVALID' },
);
});

void test('[BUA-018] acceptance rejects payloads that do not canonically bind lease fields', () => {
const lease = createEntitlementLeaseV1(
snapshot(),
Expand Down
11 changes: 11 additions & 0 deletions packages/domain/test/invitation-v1.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,14 @@ void test('[IAM-010] consuming after expiry or before issue fails closed', () =>
{ accepted: false, code: 'INVALID_TIMESTAMP' },
);
});

void test('[IAM-010] consuming a revoked token returns an invalid-state result', () => {
const created = createInvitationTokenV1(input());
assert.equal(created.accepted, true);
if (!created.accepted) return;
const revoked = { ...created.value, status: 'REVOKED' };
assert.deepEqual(consumeInvitationTokenV1(revoked, issuedAt), {
accepted: false,
code: 'INVALID_STATE',
});
});
55 changes: 23 additions & 32 deletions services/api/openapi/v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -3083,7 +3083,7 @@
}
},
"responses": {
"201": {
"202": {
"description": "",
"content": {
"application/json": {
Expand Down Expand Up @@ -9799,20 +9799,13 @@
"name": "snapshotRevision",
"required": true,
"in": "query",
"schema": { "minimum": 1, "type": "number" }
"schema": { "minimum": 1, "maximum": 9007199254740991, "type": "integer" }
},
{
"name": "securityEpoch",
"required": true,
"in": "query",
"schema": { "minimum": 1, "type": "number" }
},
{
"name": "now",
"required": false,
"in": "query",
"description": "Verification time; server clock is used when omitted",
"schema": { "format": "date-time", "type": "string" }
"schema": { "minimum": 1, "maximum": 9007199254740991, "type": "integer" }
},
{
"name": "X-Correlation-Id",
Expand Down Expand Up @@ -10164,7 +10157,13 @@
"mfaRequired": { "type": "boolean" },
"mfaReenrollmentRequired": { "type": "boolean" }
},
"required": ["userId", "organizationId", "authorizationEpoch", "mfaRequired"]
"required": [
"userId",
"organizationId",
"authorizationEpoch",
"mfaRequired",
"mfaReenrollmentRequired"
]
},
"SignInDto": {
"type": "object",
Expand Down Expand Up @@ -10197,7 +10196,8 @@
"accessToken",
"accessExpiresAt",
"securityEpoch",
"mfaRequired"
"mfaRequired",
"mfaReenrollmentRequired"
]
},
"SessionRefreshDto": {
Expand Down Expand Up @@ -10411,7 +10411,12 @@
"RegistrationDto": {
"type": "object",
"properties": {
"email": { "type": "string", "example": "ngu***@example.com", "maxLength": 254 },
"email": {
"type": "string",
"format": "email",
"example": "nguyen@example.com",
"maxLength": 254
},
"displayName": { "type": "string", "minLength": 1, "maxLength": 200 },
"password": { "type": "string", "minLength": 12, "maxLength": 128, "writeOnly": true },
"locale": { "type": "string", "enum": ["vi-VN", "en"], "default": "vi-VN" }
Expand All @@ -10420,22 +10425,8 @@
},
"RegistrationResponseDto": {
"type": "object",
"properties": {
"userId": { "type": "string", "format": "uuid" },
"organizationId": { "type": "string", "format": "uuid" },
"workspaceId": { "type": "string", "format": "uuid" },
"projectId": { "type": "string", "format": "uuid" },
"membershipId": { "type": "string", "format": "uuid" },
"locale": { "type": "string", "enum": ["vi-VN", "en"] }
},
"required": [
"userId",
"organizationId",
"workspaceId",
"projectId",
"membershipId",
"locale"
]
"properties": { "accepted": { "type": "boolean", "enum": [true], "example": true } },
"required": ["accepted"]
},
"RecoveryRequestDto": {
"type": "object",
Expand Down Expand Up @@ -10614,7 +10605,7 @@
{ "type": "null" }
]
},
"expectedRevision": { "type": "number", "minimum": 1 }
"expectedRevision": { "type": "integer", "minimum": 1 }
}
},
"CreateEvidenceGrantDto": {
Expand Down Expand Up @@ -11563,8 +11554,8 @@
"description": "Server-generated when omitted"
},
"signerKeyId": { "type": "string", "minLength": 1, "maxLength": 200 },
"firstSequence": { "type": "number", "minimum": 1 },
"lastSequence": { "type": "number", "minimum": 1 },
"firstSequence": { "type": "integer", "minimum": 1 },
"lastSequence": { "type": "integer", "minimum": 1 },
"rootDigest": { "type": "string", "minLength": 1, "maxLength": 512 }
},
"required": ["signerKeyId", "firstSequence", "lastSequence", "rootDigest"]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- IAM-010: enforce one active invitation per membership at the database boundary.
CREATE UNIQUE INDEX "invitation_tokens_active_membership_key"
ON "iam"."invitation_tokens"("membership_id")
WHERE "status" = 'ACTIVE';
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- IAM-013/INT-004: bind service-account create retries to one actor, target scope, and request hash.
-- The replayable one-time secret is stored only as an application-encrypted envelope.
ALTER TABLE "iam"."service_accounts"
ADD COLUMN "created_by_actor_id" UUID,
ADD COLUMN "create_idempotency_key" VARCHAR(200),
ADD COLUMN "create_request_hash" CHAR(64),
ADD COLUMN "create_secret_envelope" TEXT;

CREATE UNIQUE INDEX "service_accounts_create_idempotency_key"
ON "iam"."service_accounts"(
"organization_id",
"workspace_id",
"created_by_actor_id",
"create_idempotency_key"
);

-- PostgreSQL treats NULL values as distinct in a composite unique index. These partial
-- constraints close the organization-scope and workspace-scope retry races explicitly.
CREATE UNIQUE INDEX "service_accounts_create_idempotency_org_key"
ON "iam"."service_accounts"("organization_id", "created_by_actor_id", "create_idempotency_key")
WHERE "workspace_id" IS NULL
AND "created_by_actor_id" IS NOT NULL
AND "create_idempotency_key" IS NOT NULL;

CREATE UNIQUE INDEX "service_accounts_create_idempotency_workspace_key"
ON "iam"."service_accounts"(
"organization_id",
"workspace_id",
"created_by_actor_id",
"create_idempotency_key"
)
WHERE "workspace_id" IS NOT NULL
AND "created_by_actor_id" IS NOT NULL
AND "create_idempotency_key" IS NOT NULL;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- IAM-013/INT-004: bound replayable create envelopes and preserve create-time state.
ALTER TABLE "iam"."service_accounts"
ADD COLUMN "create_idempotency_expires_at" TIMESTAMPTZ(6),
ADD COLUMN "create_account_snapshot" TEXT;

-- The composite unique index already covers non-null workspace identities. Keep the
-- organization partial index for NULL workspace rows, but remove the redundant copy.
DROP INDEX IF EXISTS "service_accounts_create_idempotency_workspace_key";
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- IAM-015: a delivery failure whose compensating revoke cannot be persisted is
-- durably blocked from token completion until an operator resolves the marker.
CREATE TABLE "iam"."recovery_compensation_failures" (
"token_digest" CHAR(64) NOT NULL,
"recorded_at" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "recovery_compensation_failures_pkey" PRIMARY KEY ("token_digest")
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- IAM-010: retain a durable block when an invitation delivery acknowledgement fails.
CREATE TABLE "iam"."invitation_delivery_failures" (
"token_digest" CHAR(64) NOT NULL,
"recorded_at" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "invitation_delivery_failures_pkey" PRIMARY KEY ("token_digest")
);
25 changes: 25 additions & 0 deletions services/api/prisma/schema/iam.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ model InvitationTokenRecord {
@@schema("iam")
}

/// IAM-010: delivery failures block bearer acceptance across restarts.
model InvitationDeliveryFailureRecord {
tokenDigest String @id @map("token_digest") @db.Char(64)
recordedAt DateTime @map("recorded_at") @db.Timestamptz(6)

@@map("invitation_delivery_failures")
@@schema("iam")
}

/// IAM-015: recovery bearers are short-lived, email-bound, and hashed at rest.
model RecoveryChallengeRecord {
id String @id @db.Uuid
Expand All @@ -139,6 +148,15 @@ model RecoveryChallengeRecord {
@@schema("iam")
}

/// IAM-015: delivery-compensation failures remain blocked across restarts until reviewed.
model RecoveryCompensationFailureRecord {
tokenDigest String @id @map("token_digest") @db.Char(64)
recordedAt DateTime @map("recorded_at") @db.Timestamptz(6)

@@map("recovery_compensation_failures")
@@schema("iam")
}

model SessionRecord {
id String @id @db.Uuid
userId String @map("user_id") @db.Uuid
Expand Down Expand Up @@ -300,9 +318,16 @@ model ServiceAccountRecord {
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(6)
revision Int @default(1)
createdByActorId String? @map("created_by_actor_id") @db.Uuid
createIdempotencyKey String? @map("create_idempotency_key") @db.VarChar(200)
createRequestHash String? @map("create_request_hash") @db.Char(64)
createSecretEnvelope String? @map("create_secret_envelope") @db.Text
createIdempotencyExpiresAt DateTime? @map("create_idempotency_expires_at") @db.Timestamptz(6)
createAccountSnapshot String? @map("create_account_snapshot") @db.Text

@@index([organizationId, workspaceId, status], map: "service_accounts_scope_status_idx")
@@index([secretExpiresAt, status], map: "service_accounts_expiry_status_idx")
@@unique([organizationId, workspaceId, createdByActorId, createIdempotencyKey], map: "service_accounts_create_idempotency_key")
@@map("service_accounts")
@@schema("iam")
}
Loading
Loading