Skip to content

promote: reviewed IAM recovery slice - #44

Merged
BeforeLights merged 102 commits into
mainfrom
dev
Aug 4, 2026
Merged

promote: reviewed IAM recovery slice#44
BeforeLights merged 102 commits into
mainfrom
dev

Conversation

@BeforeLights

@BeforeLights BeforeLights commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Promotion

Promote the reviewed dev integration branch to main after PR #43.

Included slice

  • IAM recovery request/completion security flow
  • bounded in-memory and Redis admission adapter boundaries
  • recovery MFA re-enrollment enforcement for privileged approvals
  • challenge CAS, generic account outcomes, session-context propagation
  • OpenAPI, traceability, tests, and repository lint-contract cleanup

Verification

The source PR passed repository, Android, Python, security, and affected-runtime checks. The full repository gate passed locally with corepack pnpm repo:check.

Required review

Please perform one full CodeRabbit review on this promotion PR. Do not rerun CodeRabbit after the initial review; valid findings will be fixed in focused commits and rejected findings documented.

Summary by CodeRabbit

  • New Features

    • Added invitation issuance and acceptance with single-use links.
    • Added public account registration with personal workspace setup.
    • Added account recovery with one-time links, password reset, session invalidation, and MFA reenrollment.
    • Added recovery-attempt protection and privacy-preserving responses.
    • Added service-account lifecycle management, signed audit attestations, and signed offline entitlement leases.
    • Added MFA reenrollment status to session and authentication responses.
    • Added English and Vietnamese messages for invitation, registration, and recovery flows.
  • Documentation

    • Added operational evidence and requirement traceability for the new security capabilities.

BeforeLights and others added 27 commits August 4, 2026 11:18
…ical-20260804

fix: harden IAM registration and service-account replay

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
services/api/src/features/bua/adapter/in-memory-entitlement-lease-repository.adapter.ts (1)

50-66: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Reject nested transactions before queueing.

Line 55 waits for the outer transaction tail. If work calls withTransaction on this adapter, the inner call waits for release(). The outer call waits for work, so both calls remain pending.

Add an explicit non-reentrancy guard and a regression test. Preserve normal serialization for independent calls.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@services/api/src/features/bua/adapter/in-memory-entitlement-lease-repository.adapter.ts`
around lines 50 - 66, Update the transaction method containing transactionTail
to detect and reject reentrant withTransaction calls before awaiting or queueing
on transactionTail; ensure the guard is scoped to the active transaction and
always cleared in finally, while preserving rollback behavior and serialization
for independent calls. Add a regression test that invokes withTransaction from
within work and asserts prompt rejection without deadlock.
services/api/openapi/v1.json (1)

3495-3507: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Declare success response schemas for service-account creation and rotation. Both endpoints return { account, secret }, but their OpenAPI success responses define headers only. Generated clients will omit the response body.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/api/openapi/v1.json` around lines 3495 - 3507, Add the `{ account,
secret }` success response schema to the 201 responses for both service-account
creation and rotation in services/api/openapi/v1.json:3495-3507 and
services/api/openapi/v1.json:3577-3589. Preserve the existing headers and
reference the appropriate account and secret schema definitions so generated
clients include the response body.
🧹 Nitpick comments (4)
services/api/test/prisma-foundation.test.mjs (1)

573-593: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert index predicates and indexed fields.

The assertions accept an index with the correct name but an incorrect WHERE predicate or key columns. Match the complete index definitions. This protects active-only invitation uniqueness and scope-bound service-account idempotency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/api/test/prisma-foundation.test.mjs` around lines 573 - 593, Update
the migration assertions in the prisma-foundation test to match complete CREATE
UNIQUE INDEX definitions, including indexed columns and WHERE predicates. Cover
the active-only predicate for invitation_tokens_active_membership_key and the
organization/workspace key columns plus their scope predicates for the
service-account idempotency indexes, rather than asserting names alone.
services/api/prisma/migrations/20260804000000_iam_invitation_active_membership_unique/migration.sql (1)

2-4: 🩺 Stability & Availability | 🔵 Trivial

Plan online creation for production unique indexes. Normal PostgreSQL index builds block writes on their target tables. Confirm that the migration runner can execute non-transactional concurrent index creation, or schedule a maintenance window.

  • services/api/prisma/migrations/20260804000000_iam_invitation_active_membership_unique/migration.sql#L2-L4: deploy the active-invitation index without blocking invitation writes.
  • services/api/prisma/migrations/20260804010000_iam_service_account_create_idempotency/migration.sql#L9-L34: deploy the idempotency indexes without blocking service-account writes.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@services/api/prisma/migrations/20260804000000_iam_invitation_active_membership_unique/migration.sql`
around lines 2 - 4, Update both migration files to create their unique indexes
concurrently so invitation and service-account writes remain available: use
non-transactional concurrent index creation for
services/api/prisma/migrations/20260804000000_iam_invitation_active_membership_unique/migration.sql
lines 2-4 and
services/api/prisma/migrations/20260804010000_iam_service_account_create_idempotency/migration.sql
lines 9-34, and configure the migration runner to execute these statements
outside a transaction if required.

Source: Linters/SAST tools

services/api/src/features/iam/adapter/service-account-secret-envelope.adapter.ts (2)

40-43: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reject envelopes that carry extra framing parts.

envelope.split('.') returns every part, and the destructuring at Line 42 discards anything after the fourth. An envelope with a trailing part such as v1.<iv>.<tag>.<ciphertext>.junk still decrypts, because GCM authenticates only the ciphertext.

The existing test at services/api/test/features/iam/service-account-secret-envelope.adapter.test.ts Line 37 passes only because that specific input also fails the IV length check. Validate the part count so the framing check is exact.

♻️ Proposed strict framing check
   public open(envelope: string): string | undefined {
     if (typeof envelope !== 'string') return undefined;
-    const [version, ivEncoded, tagEncoded, ciphertextEncoded] = envelope.split('.');
-    if (version !== 'v1' || !ivEncoded || !tagEncoded || !ciphertextEncoded) return undefined;
+    const parts = envelope.split('.');
+    if (parts.length !== 4) return undefined;
+    const [version, ivEncoded, tagEncoded, ciphertextEncoded] = parts;
+    if (version !== 'v1' || !ivEncoded || !tagEncoded || !ciphertextEncoded) return undefined;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@services/api/src/features/iam/adapter/service-account-secret-envelope.adapter.ts`
around lines 40 - 43, Update the open method’s envelope parsing to require
exactly four dot-separated parts before decryption, rejecting any envelope with
trailing or missing framing segments. Preserve the existing v1, IV, tag, and
ciphertext validation for otherwise valid envelopes.

64-67: 🔒 Security & Privacy | 🔵 Trivial

Stability And Availability

Reachability: Internal · Exploitability: Theoretical

Reachability path
● Entry
  services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts:397
  findSeal
│
▼
● Sink
  services/api/src/features/iam/adapter/service-account-secret-envelope.adapter.ts

Configure a durable envelope key before multi-instance deployment.

When serviceAccountSecretEnvelopeKey is unset, each process uses a different random AES-GCM key. Retries handled by another instance or after a restart cannot open the persisted secretEnvelope, so replayCreate returns UNAVAILABLE. Set the same managed 32-byte base64url key for every instance. During rotation, retain the previous key until existing replays are no longer needed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@services/api/src/features/iam/adapter/service-account-secret-envelope.adapter.ts`
around lines 64 - 67, Update randomServiceAccountSecretEnvelopeAdapter and its
configuration path to require a shared managed 32-byte base64url
serviceAccountSecretEnvelopeKey for multi-instance or durable deployments
instead of silently relying on a process-local random key. Ensure every instance
uses the same configured key, and preserve the previous key during rotation
until persisted secretEnvelope replays are no longer required.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@services/api/src/features/iam/application/service-account.service.ts`:
- Around line 208-231: Update createRequestHash to canonicalize
input.permissions before JSON.stringify by sorting a copied permissions array,
while preserving the original input and validation behavior. Hash the sorted
permission set so equivalent requests with different permission ordering produce
the same request hash used by replayCreate.

---

Outside diff comments:
In `@services/api/openapi/v1.json`:
- Around line 3495-3507: Add the `{ account, secret }` success response schema
to the 201 responses for both service-account creation and rotation in
services/api/openapi/v1.json:3495-3507 and
services/api/openapi/v1.json:3577-3589. Preserve the existing headers and
reference the appropriate account and secret schema definitions so generated
clients include the response body.

In
`@services/api/src/features/bua/adapter/in-memory-entitlement-lease-repository.adapter.ts`:
- Around line 50-66: Update the transaction method containing transactionTail to
detect and reject reentrant withTransaction calls before awaiting or queueing on
transactionTail; ensure the guard is scoped to the active transaction and always
cleared in finally, while preserving rollback behavior and serialization for
independent calls. Add a regression test that invokes withTransaction from
within work and asserts prompt rejection without deadlock.

---

Nitpick comments:
In
`@services/api/prisma/migrations/20260804000000_iam_invitation_active_membership_unique/migration.sql`:
- Around line 2-4: Update both migration files to create their unique indexes
concurrently so invitation and service-account writes remain available: use
non-transactional concurrent index creation for
services/api/prisma/migrations/20260804000000_iam_invitation_active_membership_unique/migration.sql
lines 2-4 and
services/api/prisma/migrations/20260804010000_iam_service_account_create_idempotency/migration.sql
lines 9-34, and configure the migration runner to execute these statements
outside a transaction if required.

In
`@services/api/src/features/iam/adapter/service-account-secret-envelope.adapter.ts`:
- Around line 40-43: Update the open method’s envelope parsing to require
exactly four dot-separated parts before decryption, rejecting any envelope with
trailing or missing framing segments. Preserve the existing v1, IV, tag, and
ciphertext validation for otherwise valid envelopes.
- Around line 64-67: Update randomServiceAccountSecretEnvelopeAdapter and its
configuration path to require a shared managed 32-byte base64url
serviceAccountSecretEnvelopeKey for multi-instance or durable deployments
instead of silently relying on a process-local random key. Ensure every instance
uses the same configured key, and preserve the previous key during rotation
until persisted secretEnvelope replays are no longer required.

In `@services/api/test/prisma-foundation.test.mjs`:
- Around line 573-593: Update the migration assertions in the prisma-foundation
test to match complete CREATE UNIQUE INDEX definitions, including indexed
columns and WHERE predicates. Cover the active-only predicate for
invitation_tokens_active_membership_key and the organization/workspace key
columns plus their scope predicates for the service-account idempotency indexes,
rather than asserting names alone.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1e2447d4-2e7c-427f-ac79-6e84ac301aa8

📥 Commits

Reviewing files that changed from the base of the PR and between b32584d and 2a49f79.

📒 Files selected for processing (75)
  • docs/operations/iam-010-invitation-token-2026-08-03.md
  • docs/operations/iam-registration-2026-08-03.md
  • packages/domain/src/entitlements/v1.ts
  • packages/domain/src/invitation/v1.ts
  • packages/domain/test/entitlement-lease-issuance-v1.test.mjs
  • packages/domain/test/invitation-v1.test.mjs
  • services/api/openapi/v1.json
  • services/api/prisma/migrations/20260804000000_iam_invitation_active_membership_unique/migration.sql
  • services/api/prisma/migrations/20260804010000_iam_service_account_create_idempotency/migration.sql
  • services/api/prisma/schema/iam.prisma
  • services/api/src/features/aud/adapter/in-memory-audit-attestation-repository.adapter.ts
  • services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts
  • services/api/src/features/aud/adapter/prisma-audit-attestation-repository.adapter.ts
  • services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts
  • services/api/src/features/aud/api/audit-attestation.dto.ts
  • services/api/src/features/aud/application/audit-attestation-repository.port.ts
  • services/api/src/features/aud/application/audit-attestation.service.ts
  • services/api/src/features/aud/application/audit-repository.port.ts
  • services/api/src/features/bua/adapter/in-memory-entitlement-lease-repository.adapter.ts
  • services/api/src/features/bua/adapter/prisma-entitlement-lease-repository.adapter.ts
  • services/api/src/features/bua/api/entitlement-lease.dto.ts
  • services/api/src/features/bua/api/entitlement.controller.ts
  • services/api/src/features/bua/application/entitlement-equality.ts
  • services/api/src/features/bua/application/entitlement-lease.service.ts
  • services/api/src/features/iae/api/artifact-admission.dto.ts
  • services/api/src/features/iae/api/artifact-retention.dto.ts
  • services/api/src/features/iae/api/inbox-item.dto.ts
  • services/api/src/features/iam/adapter/iam-invitation-crypto.adapter.ts
  • services/api/src/features/iam/adapter/iam-recovery-crypto.adapter.ts
  • services/api/src/features/iam/adapter/in-memory-service-account-repository.adapter.ts
  • services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts
  • services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts
  • services/api/src/features/iam/adapter/prisma-service-account-repository.adapter.ts
  • services/api/src/features/iam/adapter/service-account-secret-envelope.adapter.ts
  • services/api/src/features/iam/api/auth-session.dto.ts
  • services/api/src/features/iam/api/authentication.controller.ts
  • services/api/src/features/iam/api/current-session.dto.ts
  • services/api/src/features/iam/api/registration.controller.ts
  • services/api/src/features/iam/api/registration.dto.ts
  • services/api/src/features/iam/api/service-account.controller.ts
  • services/api/src/features/iam/application/invitation.service.ts
  • services/api/src/features/iam/application/mfa-repository.port.ts
  • services/api/src/features/iam/application/recovery.service.ts
  • services/api/src/features/iam/application/registration-repository.port.ts
  • services/api/src/features/iam/application/registration.service.ts
  • services/api/src/features/iam/application/service-account-repository.port.ts
  • services/api/src/features/iam/application/service-account.service.ts
  • services/api/src/features/iam/iam.module.ts
  • services/api/src/features/sa/api/spreadsheet-audit.dto.ts
  • services/api/test/features/aud/audit-attestation-repository.test.ts
  • services/api/test/features/aud/audit-attestation.service.test.ts
  • services/api/test/features/aud/prisma-audit-attestation-repository.test.ts
  • services/api/test/features/aud/prisma-audit-repository.test.ts
  • services/api/test/features/bua/entitlement-equality.test.ts
  • services/api/test/features/bua/entitlement-lease.service.test.ts
  • services/api/test/features/bua/entitlement.controller.test.ts
  • services/api/test/features/iam/iam-invitation-crypto.adapter.test.ts
  • services/api/test/features/iam/invitation-service.test.ts
  • services/api/test/features/iam/prisma-recovery-repository.test.ts
  • services/api/test/features/iam/prisma-service-account-repository.test.ts
  • services/api/test/features/iam/recovery-composition.test.ts
  • services/api/test/features/iam/recovery-crypto.test.ts
  • services/api/test/features/iam/recovery-http.test.ts
  • services/api/test/features/iam/recovery.service.test.ts
  • services/api/test/features/iam/registration-composition.test.ts
  • services/api/test/features/iam/registration-controller.test.ts
  • services/api/test/features/iam/registration-http.test.ts
  • services/api/test/features/iam/registration.service.test.ts
  • services/api/test/features/iam/service-account-composition.test.ts
  • services/api/test/features/iam/service-account-repository.test.ts
  • services/api/test/features/iam/service-account-secret-envelope.adapter.test.ts
  • services/api/test/features/iam/service-account.controller.test.ts
  • services/api/test/features/iam/service-account.service.test.ts
  • services/api/test/http-contract.test.ts
  • services/api/test/prisma-foundation.test.mjs
💤 Files with no reviewable changes (1)
  • services/api/src/features/bua/api/entitlement.controller.ts
🚧 Files skipped from review as they are similar to previous changes (29)
  • services/api/test/features/iam/service-account-composition.test.ts
  • packages/domain/test/invitation-v1.test.mjs
  • services/api/test/features/iam/recovery-http.test.ts
  • services/api/test/features/iam/recovery-composition.test.ts
  • services/api/test/features/iam/registration-http.test.ts
  • services/api/test/features/iam/recovery-crypto.test.ts
  • services/api/test/features/aud/audit-attestation-repository.test.ts
  • services/api/src/features/aud/api/audit-attestation.dto.ts
  • services/api/test/features/bua/entitlement.controller.test.ts
  • services/api/src/features/bua/api/entitlement-lease.dto.ts
  • services/api/test/features/iam/iam-invitation-crypto.adapter.test.ts
  • services/api/test/features/bua/entitlement-lease.service.test.ts
  • services/api/src/features/bua/application/entitlement-lease.service.ts
  • services/api/src/features/iam/application/invitation.service.ts
  • docs/operations/iam-registration-2026-08-03.md
  • services/api/test/features/iam/service-account.controller.test.ts
  • services/api/src/features/iam/application/registration.service.ts
  • services/api/src/features/iam/api/service-account.controller.ts
  • services/api/src/features/aud/application/audit-attestation.service.ts
  • packages/domain/src/invitation/v1.ts
  • services/api/src/features/bua/adapter/prisma-entitlement-lease-repository.adapter.ts
  • services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts
  • services/api/src/features/iam/adapter/iam-invitation-crypto.adapter.ts
  • services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts
  • services/api/prisma/schema/iam.prisma
  • services/api/src/features/iam/adapter/iam-recovery-crypto.adapter.ts
  • services/api/src/features/aud/adapter/prisma-audit-attestation-repository.adapter.ts
  • packages/domain/src/entitlements/v1.ts
  • services/api/src/features/iam/iam.module.ts

Comment on lines +208 to +231
function createRequestHash(
input: CreateServiceAccountInputV1,
workspaceId: StableIdentifierV1 | undefined,
): string | undefined {
const name = normalizedName(input.name);
const expiry = normalizedExpiry(input.secretExpiresAt);
if (!name || !serviceAccountPermissions(input.permissions)) return undefined;
if (input.secretExpiresAt !== undefined && expiry === undefined) return undefined;
try {
return createHash('sha256')
.update(
JSON.stringify({
name,
workspaceId: workspaceId ?? null,
permissions: input.permissions,
secretExpiresAt: expiry ?? null,
}),
'utf8',
)
.digest('hex');
} catch {
return undefined;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Canonicalize permissions before hashing the create request.

createRequestHash serializes input.permissions in the order the client sent them. serviceAccountPermissions accepts any order, so ['artifact.record.read', 'job.execution.create'] and the reversed array are both valid but produce different hashes.

A client that retries the same logical create with a reordered array then hits replayCreate at Line 238, which returns CONFLICT. The caller receives 409 and cannot recover the original one-time secret.

Sort the permissions before hashing so the hash depends on the permission set, not the array order.

🐛 Proposed fix to make the request hash order-independent
 function createRequestHash(
   input: CreateServiceAccountInputV1,
   workspaceId: StableIdentifierV1 | undefined,
 ): string | undefined {
   const name = normalizedName(input.name);
   const expiry = normalizedExpiry(input.secretExpiresAt);
   if (!name || !serviceAccountPermissions(input.permissions)) return undefined;
   if (input.secretExpiresAt !== undefined && expiry === undefined) return undefined;
   try {
     return createHash('sha256')
       .update(
         JSON.stringify({
           name,
           workspaceId: workspaceId ?? null,
-          permissions: input.permissions,
+          permissions: [...input.permissions].sort(),
           secretExpiresAt: expiry ?? null,
         }),
         'utf8',
       )
       .digest('hex');
   } catch {
     return undefined;
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function createRequestHash(
input: CreateServiceAccountInputV1,
workspaceId: StableIdentifierV1 | undefined,
): string | undefined {
const name = normalizedName(input.name);
const expiry = normalizedExpiry(input.secretExpiresAt);
if (!name || !serviceAccountPermissions(input.permissions)) return undefined;
if (input.secretExpiresAt !== undefined && expiry === undefined) return undefined;
try {
return createHash('sha256')
.update(
JSON.stringify({
name,
workspaceId: workspaceId ?? null,
permissions: input.permissions,
secretExpiresAt: expiry ?? null,
}),
'utf8',
)
.digest('hex');
} catch {
return undefined;
}
}
function createRequestHash(
input: CreateServiceAccountInputV1,
workspaceId: StableIdentifierV1 | undefined,
): string | undefined {
const name = normalizedName(input.name);
const expiry = normalizedExpiry(input.secretExpiresAt);
if (!name || !serviceAccountPermissions(input.permissions)) return undefined;
if (input.secretExpiresAt !== undefined && expiry === undefined) return undefined;
try {
return createHash('sha256')
.update(
JSON.stringify({
name,
workspaceId: workspaceId ?? null,
permissions: [...input.permissions].sort(),
secretExpiresAt: expiry ?? null,
}),
'utf8',
)
.digest('hex');
} catch {
return undefined;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/api/src/features/iam/application/service-account.service.ts` around
lines 208 - 231, Update createRequestHash to canonicalize input.permissions
before JSON.stringify by sorting a copied permissions array, while preserving
the original input and validation behavior. Hash the sorted permission set so
equivalent requests with different permission ordering produce the same request
hash used by replayCreate.

@BeforeLights
BeforeLights merged commit 67d1982 into main Aug 4, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant