@open-mercato/core 0.10.1-develop.7767.2.440f7c2b4c → 0.10.1-develop.7768.1.90fa052c11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/api_keys/data/entities.ts"],
4
- "sourcesContent": ["import { Entity, Index, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'\n\n@Entity({ tableName: 'api_keys' })\n// The unique keyPrefix bounds the bcrypt candidate loop in findApiKeyBySecret to at\n// most one live row. Do not drop this constraint or widen the prefix space without\n// re-evaluating that loop's per-request cost (see #3812).\n@Unique({ properties: ['keyPrefix'] })\n@Index({\n name: 'api_keys_opencode_session_id_uq',\n expression:\n 'create unique index \"api_keys_opencode_session_id_uq\" on \"api_keys\" (\"opencode_session_id\") where \"opencode_session_id\" is not null and \"deleted_at\" is null',\n})\n@Index({\n name: 'api_keys_session_token_hash_uq',\n expression:\n 'create unique index \"api_keys_session_token_hash_uq\" on \"api_keys\" (\"session_token_hash\") where \"session_token_hash\" is not null and \"deleted_at\" is null',\n})\nexport class ApiKey {\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n @Property({ type: 'text' })\n name!: string\n\n @Property({ name: 'description', type: 'text', nullable: true })\n description?: string | null\n\n @Property({ name: 'tenant_id', type: 'uuid', nullable: true })\n tenantId?: string | null\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n @Property({ name: 'key_hash', type: 'text' })\n keyHash!: string\n\n @Property({ name: 'key_prefix', type: 'text' })\n keyPrefix!: string\n\n @Property({ name: 'roles_json', type: 'json', nullable: true })\n rolesJson?: string[] | null\n\n @Property({ name: 'created_by', type: 'uuid', nullable: true })\n createdBy?: string | null\n\n /**\n * SHA-256 hash of the session token for ephemeral session-scoped keys (used by AI chat).\n * The raw token is a bearer credential \u2014 the same one that unlocks the decrypted API key\n * secret and the user's ACL on the MCP server \u2014 so, like `customer_accounts` session\n * tokens, only its hash is persisted. It is returned to the caller once, at issuance, and\n * never stored or logged in the clear (see #2254).\n */\n @Property({ name: 'session_token_hash', type: 'text', nullable: true })\n sessionTokenHash?: string | null\n\n /**\n * @deprecated Superseded by `sessionTokenHash` (#2254) \u2014 nothing reads or writes this\n * anymore and `Migration20261012202110_api_keys` nulls out every existing value as part of\n * scrubbing the plaintext token. Kept, not dropped, per the \"renamed/removed DB column\" rule in\n * `.ai/review-checklist.md` (keep old column, add new one, backfill, deprecate old) \u2014 chat\n * sessions in flight across the deploy carry a 2-hour TTL and only mint once per\n * conversation (`ai_assistant`'s chat route, `!sessionId` branch), so the column must survive\n * long enough for every session minted before this ships to expire naturally. Safe to drop in\n * a later migration once that window has passed.\n */\n @Property({ name: 'session_token', type: 'text', nullable: true })\n sessionToken?: string | null\n\n /** User ID who owns this session (for ephemeral keys) */\n @Property({ name: 'session_user_id', type: 'uuid', nullable: true })\n sessionUserId?: string | null\n\n /** Encrypted API key secret for session keys (recoverable for API calls) */\n @Property({ name: 'session_secret_encrypted', type: 'text', nullable: true })\n sessionSecretEncrypted?: string | null\n\n /**\n * OpenCode session id bound to this api_key row. Set the first time the\n * chat dispatcher receives a `done` event after minting a session token,\n * then asserted on every subsequent resume to prevent cross-user session\n * continuation (see security fix 2026-05-23).\n */\n @Property({ name: 'opencode_session_id', type: 'text', nullable: true })\n opencodeSessionId?: string | null\n\n @Property({ name: 'last_used_at', type: Date, nullable: true })\n lastUsedAt?: Date | null\n\n @Property({ name: 'expires_at', type: Date, nullable: true })\n expiresAt?: Date | null\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n\n @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date(), nullable: true })\n updatedAt?: Date\n\n @Property({ name: 'deleted_at', type: Date, nullable: true })\n deletedAt?: Date | null\n}\n"],
4
+ "sourcesContent": ["import { Entity, Index, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'\n\n@Entity({ tableName: 'api_keys' })\n// The unique keyPrefix bounds the bcrypt candidate loop in findApiKeyBySecret to at\n// most one live row. Do not drop this constraint or widen the prefix space without\n// re-evaluating that loop's per-request cost (see #3812).\n@Unique({ properties: ['keyPrefix'] })\n@Index({\n name: 'api_keys_opencode_session_id_uq',\n expression:\n 'create unique index \"api_keys_opencode_session_id_uq\" on \"api_keys\" (\"opencode_session_id\") where \"opencode_session_id\" is not null and \"deleted_at\" is null',\n})\n@Index({\n name: 'api_keys_session_token_hash_uq',\n expression:\n 'create unique index \"api_keys_session_token_hash_uq\" on \"api_keys\" (\"session_token_hash\") where \"session_token_hash\" is not null and \"deleted_at\" is null',\n})\nexport class ApiKey {\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n @Property({ type: 'text' })\n name!: string\n\n @Property({ name: 'description', type: 'text', nullable: true })\n description?: string | null\n\n @Property({ name: 'tenant_id', type: 'uuid', nullable: true })\n tenantId?: string | null\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n @Property({ name: 'key_hash', type: 'text' })\n keyHash!: string\n\n @Property({ name: 'key_prefix', type: 'text' })\n keyPrefix!: string\n\n @Property({ name: 'roles_json', type: 'json', nullable: true })\n rolesJson?: string[] | null\n\n @Property({ name: 'created_by', type: 'uuid', nullable: true })\n createdBy?: string | null\n\n /**\n * SHA-256 hash of the session token for ephemeral session-scoped keys (used by AI chat).\n * The raw token is a bearer credential \u2014 the same one that unlocks the decrypted API key\n * secret and the user's ACL on the MCP server \u2014 so, like `customer_accounts` session\n * tokens, only its hash is persisted. It is returned to the caller once, at issuance, and\n * never stored or logged in the clear (see #2254).\n */\n @Property({ name: 'session_token_hash', type: 'text', nullable: true })\n sessionTokenHash?: string | null\n\n /**\n * @deprecated Superseded by `sessionTokenHash` (#2254) \u2014 nothing reads or writes this\n * anymore and `Migration20261012202110_api_keys` nulls out every existing value as part of\n * scrubbing the plaintext token. Kept, not dropped, per the \"renamed/removed DB column\" rule in\n * `.ai/review-checklist.md` (keep old column, add new one, backfill, deprecate old) \u2014 chat\n * sessions in flight across the deploy carry a 2-hour TTL and only mint once per\n * conversation (`ai_assistant`'s chat route, `!sessionId` branch), so the column must survive\n * long enough for every session minted before this ships to expire naturally. Safe to drop in\n * a later migration once that window has passed.\n */\n @Property({ name: 'session_token', type: 'text', nullable: true })\n sessionToken?: string | null\n\n /** Owner whose effective ACL bounds a delegated MCP or session key. */\n @Property({ name: 'session_user_id', type: 'uuid', nullable: true })\n sessionUserId?: string | null\n\n /** Encrypted API key secret for session keys (recoverable for API calls) */\n @Property({ name: 'session_secret_encrypted', type: 'text', nullable: true })\n sessionSecretEncrypted?: string | null\n\n /**\n * OpenCode session id bound to this api_key row. Set the first time the\n * chat dispatcher receives a `done` event after minting a session token,\n * then asserted on every subsequent resume to prevent cross-user session\n * continuation (see security fix 2026-05-23).\n */\n @Property({ name: 'opencode_session_id', type: 'text', nullable: true })\n opencodeSessionId?: string | null\n\n @Property({ name: 'last_used_at', type: Date, nullable: true })\n lastUsedAt?: Date | null\n\n @Property({ name: 'expires_at', type: Date, nullable: true })\n expiresAt?: Date | null\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n\n @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date(), nullable: true })\n updatedAt?: Date\n\n @Property({ name: 'deleted_at', type: Date, nullable: true })\n deletedAt?: Date | null\n}\n"],
5
5
  "mappings": ";;;;;;;;;;AAAA,SAAS,QAAQ,OAAO,YAAY,UAAU,cAAc;AAiBrD,IAAM,SAAN,MAAa;AAAA,EAAb;AA2EL,qBAAkB,oBAAI,KAAK;AAAA;AAO7B;AAhFE;AAAA,EADC,WAAW,EAAE,MAAM,QAAQ,YAAY,oBAAoB,CAAC;AAAA,GADlD,OAEX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,OAAO,CAAC;AAAA,GAJf,OAKX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,eAAe,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAPpD,OAQX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,aAAa,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAVlD,OAWX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,mBAAmB,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAbxD,OAcX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,YAAY,MAAM,OAAO,CAAC;AAAA,GAhBjC,OAiBX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,OAAO,CAAC;AAAA,GAnBnC,OAoBX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAtBnD,OAuBX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAzBnD,OA0BX;AAUA;AAAA,EADC,SAAS,EAAE,MAAM,sBAAsB,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAnC3D,OAoCX;AAaA;AAAA,EADC,SAAS,EAAE,MAAM,iBAAiB,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAhDtD,OAiDX;AAIA;AAAA,EADC,SAAS,EAAE,MAAM,mBAAmB,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GApDxD,OAqDX;AAIA;AAAA,EADC,SAAS,EAAE,MAAM,4BAA4B,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAxDjE,OAyDX;AASA;AAAA,EADC,SAAS,EAAE,MAAM,uBAAuB,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAjE5D,OAkEX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,gBAAgB,MAAM,MAAM,UAAU,KAAK,CAAC;AAAA,GApEnD,OAqEX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,MAAM,UAAU,KAAK,CAAC;AAAA,GAvEjD,OAwEX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,MAAM,UAAU,MAAM,oBAAI,KAAK,EAAE,CAAC;AAAA,GA1E7D,OA2EX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,MAAM,UAAU,MAAM,oBAAI,KAAK,GAAG,UAAU,KAAK,CAAC;AAAA,GA7E7E,OA8EX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,MAAM,UAAU,KAAK,CAAC;AAAA,GAhFjD,OAiFX;AAjFW,SAAN;AAAA,EAfN,OAAO,EAAE,WAAW,WAAW,CAAC;AAAA,EAIhC,OAAO,EAAE,YAAY,CAAC,WAAW,EAAE,CAAC;AAAA,EACpC,MAAM;AAAA,IACL,MAAM;AAAA,IACN,YACE;AAAA,EACJ,CAAC;AAAA,EACA,MAAM;AAAA,IACL,MAAM;AAAA,IACN,YACE;AAAA,EACJ,CAAC;AAAA,GACY;",
6
6
  "names": []
7
7
  }
@@ -81,6 +81,7 @@ async function createApiKey(em, input, opts = {}) {
81
81
  keyPrefix: prefix,
82
82
  rolesJson: roleIds,
83
83
  createdBy: input.createdBy ?? null,
84
+ sessionUserId: input.sessionUserId ?? null,
84
85
  expiresAt: input.expiresAt ?? null,
85
86
  createdAt: /* @__PURE__ */ new Date()
86
87
  });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/api_keys/services/apiKeyService.ts"],
4
- "sourcesContent": ["import { randomBytes, createHash } from 'node:crypto'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { hash, compare } from 'bcryptjs'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { ApiKey } from '../data/entities'\nimport { createKmsService, resolveEncryptionMode } from '@open-mercato/shared/lib/encryption/kms'\nimport { encryptWithAesGcm, decryptWithAesGcm, looksLikeEncryptedPayload } from '@open-mercato/shared/lib/encryption/aes'\nimport { getSharedApiKeyAuthCache } from '@open-mercato/shared/lib/auth/apiKeyAuthCache'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'\nimport {\n lockAuthorizationApiKeyRows,\n lockRoleWriterAuthorizationState,\n} from '@open-mercato/core/modules/auth/lib/authorizationStateLocks'\n\nconst logger = createLogger('api_keys').child({ component: 'api-key-service' })\n\nconst BCRYPT_COST = 10\n\n// =============================================================================\n// Session Secret Encryption Helpers\n// =============================================================================\n\n/**\n * Seal an ephemeral session API key secret for storage in `session_secret_encrypted`.\n *\n * Returns null when the secret cannot be stored at all, which costs the caller MCP session-token\n * auth (the secret is unrecoverable and `findSessionApiKeyWithSecret` gives up).\n *\n * Under `TENANT_DATA_ENCRYPTION=no` the secret is stored as-is. That is the same bargain the rest\n * of the system already strikes in that mode -- emails, integration credentials and the search\n * index all sit in plaintext -- and it is what keeps the AI chat working when an operator opts\n * out. A DEK that is merely unreachable is a different situation and still yields null: writing a\n * secret in the clear because Vault happens to be down is not a downgrade anyone asked for.\n */\nasync function encryptSessionSecret(\n secret: string,\n tenantId: string | null\n): Promise<string | null> {\n if (!tenantId) return null\n\n const kms = createKmsService()\n const mode = resolveEncryptionMode(kms)\n if (mode === 'disabled') return secret\n if (mode === 'unavailable') {\n logger.warn(\n 'Tenant data encryption is enabled but no DEK is reachable; session secret not stored. '\n + 'MCP session-token auth will fail until the KMS recovers.',\n { tenantId },\n )\n return null\n }\n\n const dek = await kms.getTenantDek(tenantId)\n if (!dek) {\n // Try to create a DEK if one doesn't exist\n const created = await kms.createTenantDek(tenantId)\n if (!created) return null\n const encrypted = encryptWithAesGcm(secret, created.key)\n return encrypted.value\n }\n\n const encrypted = encryptWithAesGcm(secret, dek.key)\n return encrypted.value\n}\n\n/**\n * Recover a session API key secret written by {@link encryptSessionSecret}.\n * Returns null if it cannot be recovered.\n */\nasync function decryptSessionSecret(\n stored: string,\n tenantId: string | null\n): Promise<string | null> {\n if (!tenantId || !stored) return null\n\n const kms = createKmsService()\n const mode = resolveEncryptionMode(kms)\n if (mode === 'disabled') {\n // Written in the clear by the branch above -- unless it predates the toggle being flipped, in\n // which case it is a sealed envelope no key can open and null is the honest answer.\n return looksLikeEncryptedPayload(stored) ? null : stored\n }\n if (mode === 'unavailable') {\n logger.warn('Tenant data encryption is enabled but no DEK is reachable; cannot recover session secret', { tenantId })\n return null\n }\n\n // Mirror of the `disabled` branch: a secret written in the clear while the toggle was off is\n // still recoverable after it is switched back on. Without this `decryptWithAesGcm` reads the\n // plaintext as a malformed envelope and returns null, so the flip would silently break every\n // live session rather than only the ones sealed under the old setting.\n if (!looksLikeEncryptedPayload(stored)) return stored\n\n const dek = await kms.getTenantDek(tenantId)\n if (!dek) return null\n\n return decryptWithAesGcm(stored, dek.key)\n}\n\nexport type CreateApiKeyInput = {\n name: string\n description?: string | null\n tenantId?: string | null\n organizationId?: string | null\n roles?: string[]\n expiresAt?: Date | null\n createdBy?: string | null\n}\n\nexport type ApiKeyWithSecret = {\n record: ApiKey\n secret: string\n}\n\nexport function generateApiKeySecret(): { secret: string; prefix: string } {\n const short = randomBytes(4).toString('hex')\n const body = randomBytes(24).toString('hex')\n const secret = `omk_${short}.${body}`\n const prefix = secret.slice(0, 12)\n return { secret, prefix }\n}\n\nexport async function hashApiKey(secret: string): Promise<string> {\n return hash(secret, BCRYPT_COST)\n}\n\nexport async function verifyApiKey(secret: string, keyHash: string): Promise<boolean> {\n return compare(secret, keyHash)\n}\n\nexport async function createApiKey(\n em: EntityManager,\n input: CreateApiKeyInput,\n opts: {\n rbac?: RbacService\n authorizeRoles?: (roleIds: readonly string[]) => Promise<void> | void\n } = {},\n): Promise<ApiKeyWithSecret> {\n const { secret, prefix } = generateApiKeySecret()\n const keyHash = await hashApiKey(secret)\n const roleIds = Array.from(new Set((input.roles ?? []).filter(Boolean))).sort((left, right) => left.localeCompare(right))\n let record!: ApiKey\n await withAtomicFlush(em, [async () => {\n await lockRoleWriterAuthorizationState(em, roleIds)\n await opts.authorizeRoles?.(roleIds)\n record = em.create(ApiKey, {\n name: input.name,\n description: input.description ?? null,\n tenantId: input.tenantId ?? null,\n organizationId: input.organizationId ?? null,\n keyHash,\n keyPrefix: prefix,\n rolesJson: roleIds,\n createdBy: input.createdBy ?? null,\n expiresAt: input.expiresAt ?? null,\n createdAt: new Date(),\n })\n em.persist(record)\n }], { transaction: true, label: 'api_keys.create' })\n if (opts.rbac) {\n await opts.rbac.invalidateUserCache(`api_key:${record.id}`)\n }\n return { record, secret }\n}\n\nexport async function deleteApiKey(\n em: EntityManager,\n id: string,\n opts: { rbac?: RbacService; authorize?: (record: ApiKey) => Promise<void> | void } = {},\n): Promise<boolean> {\n let deletedId: string | null = null\n await withAtomicFlush(em, [async () => {\n const records = await lockAuthorizationApiKeyRows(em, [id])\n const record = records.find((candidate) => String(candidate.id) === id)\n if (!record || record.deletedAt) return\n await opts.authorize?.(record)\n record.deletedAt = new Date()\n await em.persist(record).flush()\n deletedId = String(record.id)\n }], { transaction: true, label: 'api_keys.delete' })\n if (!deletedId) return false\n getSharedApiKeyAuthCache().invalidateByKeyId(deletedId)\n if (opts.rbac) {\n await opts.rbac.invalidateUserCache(`api_key:${deletedId}`)\n }\n return true\n}\n\nexport async function findApiKeyBySecret(em: EntityManager, secret: string): Promise<ApiKey | null> {\n if (!secret) return null\n // Extract prefix from the secret for fast candidate lookup\n const prefix = secret.slice(0, 12)\n // Find candidates by prefix (fast index lookup). Invariant: the unique keyPrefix\n // constraint plus the deletedAt: null filter keep this to at most one live row, so\n // the bcrypt loop below stays bounded. Do not widen the prefix space or relax either\n // filter without re-evaluating that cost (see #3812).\n const candidates = await em.find(ApiKey, { keyPrefix: prefix, deletedAt: null })\n // Verify each candidate with bcrypt until we find a match\n for (const candidate of candidates) {\n if (candidate.expiresAt && candidate.expiresAt.getTime() < Date.now()) continue\n const isValid = await verifyApiKey(secret, candidate.keyHash)\n if (isValid) return candidate\n }\n return null\n}\n\n// =============================================================================\n// Session-scoped API Keys (for AI Chat ephemeral authorization)\n// =============================================================================\n\nexport type CreateSessionApiKeyInput = {\n sessionToken: string\n userId: string\n userRoles: string[]\n tenantId?: string | null\n organizationId?: string | null\n ttlMinutes?: number\n}\n\n/**\n * Generate a unique session token for ephemeral API keys.\n * Format: sess_{32 hex chars}\n */\nexport function generateSessionToken(): string {\n return `sess_${randomBytes(16).toString('hex')}`\n}\n\n/**\n * Hash a session token for storage/lookup (#2254). The session token is a bearer credential \u2014\n * it unlocks the decrypted API key secret and the user's ACL on the MCP server \u2014 so, mirroring\n * `customer_accounts`' `hashToken`, only its hash is ever persisted. 128 bits of entropy from\n * `generateSessionToken` is sufficient for a plain SHA-256 (no bcrypt cost needed, and a slow\n * hash would just add latency to every MCP tool call that resolves a session).\n */\nexport function hashSessionToken(sessionToken: string): string {\n return createHash('sha256').update(sessionToken).digest('hex')\n}\n\n/**\n * Create an ephemeral API key scoped to a chat session.\n * The key inherits the user's roles and expires after ttlMinutes (default 30).\n * The API key secret is encrypted and stored so it can be recovered for API calls.\n */\nexport async function createSessionApiKey(\n em: EntityManager,\n input: CreateSessionApiKeyInput\n): Promise<{ keyId: string; secret: string; sessionToken: string }> {\n const { secret, prefix } = generateApiKeySecret()\n const ttl = input.ttlMinutes ?? 30\n const expiresAt = new Date(Date.now() + ttl * 60 * 1000)\n const keyHash = await hashApiKey(secret)\n\n // Encrypt the secret for later retrieval (used by MCP server for API calls)\n const encryptedSecret = await encryptSessionSecret(secret, input.tenantId ?? null)\n\n // Hash before storage (#2254) \u2014 the row (and its `name` column, previously embedding the raw\n // token for operator visibility) must never carry the live bearer credential in the clear.\n const sessionTokenHash = hashSessionToken(input.sessionToken)\n\n const roleIds = Array.from(new Set(input.userRoles.filter(Boolean))).sort((left, right) => left.localeCompare(right))\n let record!: ApiKey\n await withAtomicFlush(em, [async () => {\n await lockRoleWriterAuthorizationState(em, roleIds)\n record = em.create(ApiKey, {\n name: `__session_${sessionTokenHash.slice(0, 12)}__`,\n description: 'Ephemeral session API key for AI chat',\n tenantId: input.tenantId ?? null,\n organizationId: input.organizationId ?? null,\n keyHash,\n keyPrefix: prefix,\n rolesJson: roleIds,\n createdBy: input.userId,\n sessionTokenHash,\n sessionUserId: input.userId,\n sessionSecretEncrypted: encryptedSecret,\n expiresAt,\n createdAt: new Date(),\n })\n em.persist(record)\n }], { transaction: true, label: 'api_keys.session.create' })\n\n return {\n keyId: record.id,\n secret,\n sessionToken: input.sessionToken,\n }\n}\n\n/**\n * Find an API key by its session token.\n * Returns null if not found, expired, or deleted.\n */\nexport async function findApiKeyBySessionToken(\n em: EntityManager,\n sessionToken: string\n): Promise<ApiKey | null> {\n if (!sessionToken) return null\n\n // Looked up by hash, never by the raw token (#2254).\n const sessionTokenHash = hashSessionToken(sessionToken)\n let record = await em.findOne(ApiKey, { sessionTokenHash, deletedAt: null })\n\n if (!record) {\n // Version-skew fallback (#7304): during a rolling deploy/rollback window the app and the\n // separate MCP process can briefly run different code versions. A row minted by\n // pre-#2254 code in that window carries only the plaintext token (sessionTokenHash is\n // still null) \u2014 fall back to it so the session still resolves instead of answering a\n // false SESSION_EXPIRED, then self-heal the row onto the hash immediately so this is the\n // only lookup that ever sees the plaintext, and the window closes as soon as each\n // outstanding legacy row is touched once.\n const legacyRecord = await em.findOne(ApiKey, { sessionToken, deletedAt: null })\n if (legacyRecord) {\n legacyRecord.sessionTokenHash = sessionTokenHash\n legacyRecord.sessionToken = null\n await em.persist(legacyRecord).flush()\n record = legacyRecord\n }\n }\n\n if (!record) return null\n if (record.expiresAt && record.expiresAt.getTime() < Date.now()) return null\n\n return record\n}\n\n/**\n * Bind an OpenCode session id to the api_key row that owns this chat session.\n *\n * Called by the chat dispatcher the first time we see the `done` event for a\n * freshly minted session token. From that point on,\n * `findApiKeyByOpencodeSessionId(em, opencodeSessionId)` returns the same row,\n * which the ai-assistant runtime uses to assert ownership on every resume.\n *\n * Throws when the session token has been deleted/expired, and when the api_key\n * row is already bound to a DIFFERENT OpenCode session (defensive: this should\n * never happen in practice because each chat mints a new session token, but we\n * fail closed instead of silently overwriting).\n *\n * Idempotent when the row is already bound to the same OpenCode session id.\n */\nexport async function bindOpencodeSessionToApiKey(\n em: EntityManager,\n sessionToken: string,\n opencodeSessionId: string\n): Promise<void> {\n if (!sessionToken) throw new Error('Session token not found or expired')\n if (!opencodeSessionId) throw new Error('OpenCode session id is required')\n\n const row = await findApiKeyBySessionToken(em, sessionToken)\n if (!row) throw new Error('Session token not found or expired')\n\n if (row.opencodeSessionId === opencodeSessionId) return\n if (row.opencodeSessionId && row.opencodeSessionId !== opencodeSessionId) {\n throw new Error('Session token already bound to a different OpenCode session')\n }\n\n row.opencodeSessionId = opencodeSessionId\n await em.persist(row).flush()\n}\n\n/**\n * Find an api_key row by its bound OpenCode session id.\n *\n * Returns null if no active row matches, or if the matched row is expired\n * (same contract as `findApiKeyBySessionToken`). Uses\n * `findOneWithDecryption` so encrypted-at-rest fields on the row are decrypted\n * before the ai-assistant runtime inspects `sessionUserId` / `tenantId` /\n * `organizationId` for the ownership check.\n */\nexport async function findApiKeyByOpencodeSessionId(\n em: EntityManager,\n opencodeSessionId: string\n): Promise<ApiKey | null> {\n if (!opencodeSessionId) return null\n\n const record = await findOneWithDecryption(\n em,\n ApiKey,\n { opencodeSessionId, deletedAt: null } as any,\n )\n\n if (!record) return null\n if (record.expiresAt && record.expiresAt.getTime() < Date.now()) return null\n\n return record\n}\n\n/**\n * Find a session API key with its decrypted secret.\n * Returns null if not found, expired, deleted, or decryption fails.\n * This is used by the MCP server to recover the API key secret for making\n * authenticated API calls on behalf of the user.\n */\nexport async function findSessionApiKeyWithSecret(\n em: EntityManager,\n sessionToken: string\n): Promise<{ key: ApiKey; secret: string } | null> {\n const record = await findApiKeyBySessionToken(em, sessionToken)\n if (!record) return null\n\n // If no encrypted secret stored, cannot recover\n if (!record.sessionSecretEncrypted) {\n logger.warn('Session key has no encrypted secret', { apiKeyId: record.id })\n return null\n }\n\n // Decrypt the secret\n const secret = await decryptSessionSecret(record.sessionSecretEncrypted, record.tenantId ?? null)\n if (!secret) {\n logger.warn('Failed to decrypt session secret', { apiKeyId: record.id })\n return null\n }\n\n return { key: record, secret }\n}\n\n/**\n * Delete an ephemeral API key by its session token.\n */\nexport async function deleteSessionApiKey(\n em: EntityManager,\n sessionToken: string\n): Promise<void> {\n // Looked up and re-checked by hash, never by the raw token (#2254). Deliberately does not\n // call findApiKeyBySessionToken: that helper also drops already-expired rows, but deleting\n // an expired-but-not-yet-cleaned-up row must still work here.\n const sessionTokenHash = hashSessionToken(sessionToken)\n let candidate = await em.findOne(ApiKey, { sessionTokenHash, deletedAt: null })\n if (!candidate) {\n // Version-skew fallback (#7304): see findApiKeyBySessionToken for the full rationale \u2014 a\n // row minted by pre-#2254 code during a deploy window is plaintext-only until touched.\n const legacyCandidate = await em.findOne(ApiKey, { sessionToken, deletedAt: null })\n if (legacyCandidate) {\n legacyCandidate.sessionTokenHash = sessionTokenHash\n legacyCandidate.sessionToken = null\n await em.persist(legacyCandidate).flush()\n candidate = legacyCandidate\n }\n }\n if (!candidate) return\n let deletedId: string | null = null\n await withAtomicFlush(em, [async () => {\n const records = await lockAuthorizationApiKeyRows(em, [String(candidate.id)])\n const record = records.find((entry) => (\n String(entry.id) === String(candidate.id)\n && entry.sessionTokenHash === sessionTokenHash\n && !entry.deletedAt\n ))\n if (!record) return\n record.deletedAt = new Date()\n await em.persist(record).flush()\n deletedId = String(record.id)\n }], { transaction: true, label: 'api_keys.session.delete' })\n if (deletedId) getSharedApiKeyAuthCache().invalidateByKeyId(deletedId)\n}\n\n/**\n * Execute a function with a one-time API key\n *\n * Creates a temporary API key, executes the function, and deletes the key.\n * Perfect for workflow activities that need authenticated access without\n * storing long-lived credentials.\n *\n * @param em - Entity manager\n * @param input - API key configuration\n * @param fn - Function to execute with the API key secret\n * @returns Result of the function\n */\nconst ONETIME_KEY_MAX_TTL_MS = 5 * 60 * 1000\n\nexport async function withOnetimeApiKey<T>(\n em: EntityManager,\n input: CreateApiKeyInput,\n fn: (secret: string) => Promise<T>\n): Promise<T> {\n const maxExpiresAt = new Date(Date.now() + ONETIME_KEY_MAX_TTL_MS)\n const safeExpiresAt = input.expiresAt && input.expiresAt < maxExpiresAt\n ? input.expiresAt\n : maxExpiresAt\n\n const { record, secret } = await createApiKey(em, {\n ...input,\n name: input.name || '__onetime__',\n description: input.description || 'One-time API key',\n expiresAt: safeExpiresAt,\n })\n\n try {\n const result = await fn(secret)\n return result\n } finally {\n try {\n await deleteApiKey(em, String(record.id))\n } catch (error) {\n logger.error('Failed to soft-delete one-time API key', { err: error })\n }\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { randomBytes, createHash } from 'node:crypto'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { hash, compare } from 'bcryptjs'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { ApiKey } from '../data/entities'\nimport { createKmsService, resolveEncryptionMode } from '@open-mercato/shared/lib/encryption/kms'\nimport { encryptWithAesGcm, decryptWithAesGcm, looksLikeEncryptedPayload } from '@open-mercato/shared/lib/encryption/aes'\nimport { getSharedApiKeyAuthCache } from '@open-mercato/shared/lib/auth/apiKeyAuthCache'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'\nimport {\n lockAuthorizationApiKeyRows,\n lockRoleWriterAuthorizationState,\n} from '@open-mercato/core/modules/auth/lib/authorizationStateLocks'\n\nconst logger = createLogger('api_keys').child({ component: 'api-key-service' })\n\nconst BCRYPT_COST = 10\n\n// =============================================================================\n// Session Secret Encryption Helpers\n// =============================================================================\n\n/**\n * Seal an ephemeral session API key secret for storage in `session_secret_encrypted`.\n *\n * Returns null when the secret cannot be stored at all, which costs the caller MCP session-token\n * auth (the secret is unrecoverable and `findSessionApiKeyWithSecret` gives up).\n *\n * Under `TENANT_DATA_ENCRYPTION=no` the secret is stored as-is. That is the same bargain the rest\n * of the system already strikes in that mode -- emails, integration credentials and the search\n * index all sit in plaintext -- and it is what keeps the AI chat working when an operator opts\n * out. A DEK that is merely unreachable is a different situation and still yields null: writing a\n * secret in the clear because Vault happens to be down is not a downgrade anyone asked for.\n */\nasync function encryptSessionSecret(\n secret: string,\n tenantId: string | null\n): Promise<string | null> {\n if (!tenantId) return null\n\n const kms = createKmsService()\n const mode = resolveEncryptionMode(kms)\n if (mode === 'disabled') return secret\n if (mode === 'unavailable') {\n logger.warn(\n 'Tenant data encryption is enabled but no DEK is reachable; session secret not stored. '\n + 'MCP session-token auth will fail until the KMS recovers.',\n { tenantId },\n )\n return null\n }\n\n const dek = await kms.getTenantDek(tenantId)\n if (!dek) {\n // Try to create a DEK if one doesn't exist\n const created = await kms.createTenantDek(tenantId)\n if (!created) return null\n const encrypted = encryptWithAesGcm(secret, created.key)\n return encrypted.value\n }\n\n const encrypted = encryptWithAesGcm(secret, dek.key)\n return encrypted.value\n}\n\n/**\n * Recover a session API key secret written by {@link encryptSessionSecret}.\n * Returns null if it cannot be recovered.\n */\nasync function decryptSessionSecret(\n stored: string,\n tenantId: string | null\n): Promise<string | null> {\n if (!tenantId || !stored) return null\n\n const kms = createKmsService()\n const mode = resolveEncryptionMode(kms)\n if (mode === 'disabled') {\n // Written in the clear by the branch above -- unless it predates the toggle being flipped, in\n // which case it is a sealed envelope no key can open and null is the honest answer.\n return looksLikeEncryptedPayload(stored) ? null : stored\n }\n if (mode === 'unavailable') {\n logger.warn('Tenant data encryption is enabled but no DEK is reachable; cannot recover session secret', { tenantId })\n return null\n }\n\n // Mirror of the `disabled` branch: a secret written in the clear while the toggle was off is\n // still recoverable after it is switched back on. Without this `decryptWithAesGcm` reads the\n // plaintext as a malformed envelope and returns null, so the flip would silently break every\n // live session rather than only the ones sealed under the old setting.\n if (!looksLikeEncryptedPayload(stored)) return stored\n\n const dek = await kms.getTenantDek(tenantId)\n if (!dek) return null\n\n return decryptWithAesGcm(stored, dek.key)\n}\n\nexport type CreateApiKeyInput = {\n name: string\n description?: string | null\n tenantId?: string | null\n organizationId?: string | null\n roles?: string[]\n expiresAt?: Date | null\n createdBy?: string | null\n sessionUserId?: string | null\n}\n\nexport type ApiKeyWithSecret = {\n record: ApiKey\n secret: string\n}\n\nexport function generateApiKeySecret(): { secret: string; prefix: string } {\n const short = randomBytes(4).toString('hex')\n const body = randomBytes(24).toString('hex')\n const secret = `omk_${short}.${body}`\n const prefix = secret.slice(0, 12)\n return { secret, prefix }\n}\n\nexport async function hashApiKey(secret: string): Promise<string> {\n return hash(secret, BCRYPT_COST)\n}\n\nexport async function verifyApiKey(secret: string, keyHash: string): Promise<boolean> {\n return compare(secret, keyHash)\n}\n\nexport async function createApiKey(\n em: EntityManager,\n input: CreateApiKeyInput,\n opts: {\n rbac?: RbacService\n authorizeRoles?: (roleIds: readonly string[]) => Promise<void> | void\n } = {},\n): Promise<ApiKeyWithSecret> {\n const { secret, prefix } = generateApiKeySecret()\n const keyHash = await hashApiKey(secret)\n const roleIds = Array.from(new Set((input.roles ?? []).filter(Boolean))).sort((left, right) => left.localeCompare(right))\n let record!: ApiKey\n await withAtomicFlush(em, [async () => {\n await lockRoleWriterAuthorizationState(em, roleIds)\n await opts.authorizeRoles?.(roleIds)\n record = em.create(ApiKey, {\n name: input.name,\n description: input.description ?? null,\n tenantId: input.tenantId ?? null,\n organizationId: input.organizationId ?? null,\n keyHash,\n keyPrefix: prefix,\n rolesJson: roleIds,\n createdBy: input.createdBy ?? null,\n sessionUserId: input.sessionUserId ?? null,\n expiresAt: input.expiresAt ?? null,\n createdAt: new Date(),\n })\n em.persist(record)\n }], { transaction: true, label: 'api_keys.create' })\n if (opts.rbac) {\n await opts.rbac.invalidateUserCache(`api_key:${record.id}`)\n }\n return { record, secret }\n}\n\nexport async function deleteApiKey(\n em: EntityManager,\n id: string,\n opts: { rbac?: RbacService; authorize?: (record: ApiKey) => Promise<void> | void } = {},\n): Promise<boolean> {\n let deletedId: string | null = null\n await withAtomicFlush(em, [async () => {\n const records = await lockAuthorizationApiKeyRows(em, [id])\n const record = records.find((candidate) => String(candidate.id) === id)\n if (!record || record.deletedAt) return\n await opts.authorize?.(record)\n record.deletedAt = new Date()\n await em.persist(record).flush()\n deletedId = String(record.id)\n }], { transaction: true, label: 'api_keys.delete' })\n if (!deletedId) return false\n getSharedApiKeyAuthCache().invalidateByKeyId(deletedId)\n if (opts.rbac) {\n await opts.rbac.invalidateUserCache(`api_key:${deletedId}`)\n }\n return true\n}\n\nexport async function findApiKeyBySecret(em: EntityManager, secret: string): Promise<ApiKey | null> {\n if (!secret) return null\n // Extract prefix from the secret for fast candidate lookup\n const prefix = secret.slice(0, 12)\n // Find candidates by prefix (fast index lookup). Invariant: the unique keyPrefix\n // constraint plus the deletedAt: null filter keep this to at most one live row, so\n // the bcrypt loop below stays bounded. Do not widen the prefix space or relax either\n // filter without re-evaluating that cost (see #3812).\n const candidates = await em.find(ApiKey, { keyPrefix: prefix, deletedAt: null })\n // Verify each candidate with bcrypt until we find a match\n for (const candidate of candidates) {\n if (candidate.expiresAt && candidate.expiresAt.getTime() < Date.now()) continue\n const isValid = await verifyApiKey(secret, candidate.keyHash)\n if (isValid) return candidate\n }\n return null\n}\n\n// =============================================================================\n// Session-scoped API Keys (for AI Chat ephemeral authorization)\n// =============================================================================\n\nexport type CreateSessionApiKeyInput = {\n sessionToken: string\n userId: string\n userRoles: string[]\n tenantId?: string | null\n organizationId?: string | null\n ttlMinutes?: number\n}\n\n/**\n * Generate a unique session token for ephemeral API keys.\n * Format: sess_{32 hex chars}\n */\nexport function generateSessionToken(): string {\n return `sess_${randomBytes(16).toString('hex')}`\n}\n\n/**\n * Hash a session token for storage/lookup (#2254). The session token is a bearer credential \u2014\n * it unlocks the decrypted API key secret and the user's ACL on the MCP server \u2014 so, mirroring\n * `customer_accounts`' `hashToken`, only its hash is ever persisted. 128 bits of entropy from\n * `generateSessionToken` is sufficient for a plain SHA-256 (no bcrypt cost needed, and a slow\n * hash would just add latency to every MCP tool call that resolves a session).\n */\nexport function hashSessionToken(sessionToken: string): string {\n return createHash('sha256').update(sessionToken).digest('hex')\n}\n\n/**\n * Create an ephemeral API key scoped to a chat session.\n * The key inherits the user's roles and expires after ttlMinutes (default 30).\n * The API key secret is encrypted and stored so it can be recovered for API calls.\n */\nexport async function createSessionApiKey(\n em: EntityManager,\n input: CreateSessionApiKeyInput\n): Promise<{ keyId: string; secret: string; sessionToken: string }> {\n const { secret, prefix } = generateApiKeySecret()\n const ttl = input.ttlMinutes ?? 30\n const expiresAt = new Date(Date.now() + ttl * 60 * 1000)\n const keyHash = await hashApiKey(secret)\n\n // Encrypt the secret for later retrieval (used by MCP server for API calls)\n const encryptedSecret = await encryptSessionSecret(secret, input.tenantId ?? null)\n\n // Hash before storage (#2254) \u2014 the row (and its `name` column, previously embedding the raw\n // token for operator visibility) must never carry the live bearer credential in the clear.\n const sessionTokenHash = hashSessionToken(input.sessionToken)\n\n const roleIds = Array.from(new Set(input.userRoles.filter(Boolean))).sort((left, right) => left.localeCompare(right))\n let record!: ApiKey\n await withAtomicFlush(em, [async () => {\n await lockRoleWriterAuthorizationState(em, roleIds)\n record = em.create(ApiKey, {\n name: `__session_${sessionTokenHash.slice(0, 12)}__`,\n description: 'Ephemeral session API key for AI chat',\n tenantId: input.tenantId ?? null,\n organizationId: input.organizationId ?? null,\n keyHash,\n keyPrefix: prefix,\n rolesJson: roleIds,\n createdBy: input.userId,\n sessionTokenHash,\n sessionUserId: input.userId,\n sessionSecretEncrypted: encryptedSecret,\n expiresAt,\n createdAt: new Date(),\n })\n em.persist(record)\n }], { transaction: true, label: 'api_keys.session.create' })\n\n return {\n keyId: record.id,\n secret,\n sessionToken: input.sessionToken,\n }\n}\n\n/**\n * Find an API key by its session token.\n * Returns null if not found, expired, or deleted.\n */\nexport async function findApiKeyBySessionToken(\n em: EntityManager,\n sessionToken: string\n): Promise<ApiKey | null> {\n if (!sessionToken) return null\n\n // Looked up by hash, never by the raw token (#2254).\n const sessionTokenHash = hashSessionToken(sessionToken)\n let record = await em.findOne(ApiKey, { sessionTokenHash, deletedAt: null })\n\n if (!record) {\n // Version-skew fallback (#7304): during a rolling deploy/rollback window the app and the\n // separate MCP process can briefly run different code versions. A row minted by\n // pre-#2254 code in that window carries only the plaintext token (sessionTokenHash is\n // still null) \u2014 fall back to it so the session still resolves instead of answering a\n // false SESSION_EXPIRED, then self-heal the row onto the hash immediately so this is the\n // only lookup that ever sees the plaintext, and the window closes as soon as each\n // outstanding legacy row is touched once.\n const legacyRecord = await em.findOne(ApiKey, { sessionToken, deletedAt: null })\n if (legacyRecord) {\n legacyRecord.sessionTokenHash = sessionTokenHash\n legacyRecord.sessionToken = null\n await em.persist(legacyRecord).flush()\n record = legacyRecord\n }\n }\n\n if (!record) return null\n if (record.expiresAt && record.expiresAt.getTime() < Date.now()) return null\n\n return record\n}\n\n/**\n * Bind an OpenCode session id to the api_key row that owns this chat session.\n *\n * Called by the chat dispatcher the first time we see the `done` event for a\n * freshly minted session token. From that point on,\n * `findApiKeyByOpencodeSessionId(em, opencodeSessionId)` returns the same row,\n * which the ai-assistant runtime uses to assert ownership on every resume.\n *\n * Throws when the session token has been deleted/expired, and when the api_key\n * row is already bound to a DIFFERENT OpenCode session (defensive: this should\n * never happen in practice because each chat mints a new session token, but we\n * fail closed instead of silently overwriting).\n *\n * Idempotent when the row is already bound to the same OpenCode session id.\n */\nexport async function bindOpencodeSessionToApiKey(\n em: EntityManager,\n sessionToken: string,\n opencodeSessionId: string\n): Promise<void> {\n if (!sessionToken) throw new Error('Session token not found or expired')\n if (!opencodeSessionId) throw new Error('OpenCode session id is required')\n\n const row = await findApiKeyBySessionToken(em, sessionToken)\n if (!row) throw new Error('Session token not found or expired')\n\n if (row.opencodeSessionId === opencodeSessionId) return\n if (row.opencodeSessionId && row.opencodeSessionId !== opencodeSessionId) {\n throw new Error('Session token already bound to a different OpenCode session')\n }\n\n row.opencodeSessionId = opencodeSessionId\n await em.persist(row).flush()\n}\n\n/**\n * Find an api_key row by its bound OpenCode session id.\n *\n * Returns null if no active row matches, or if the matched row is expired\n * (same contract as `findApiKeyBySessionToken`). Uses\n * `findOneWithDecryption` so encrypted-at-rest fields on the row are decrypted\n * before the ai-assistant runtime inspects `sessionUserId` / `tenantId` /\n * `organizationId` for the ownership check.\n */\nexport async function findApiKeyByOpencodeSessionId(\n em: EntityManager,\n opencodeSessionId: string\n): Promise<ApiKey | null> {\n if (!opencodeSessionId) return null\n\n const record = await findOneWithDecryption(\n em,\n ApiKey,\n { opencodeSessionId, deletedAt: null } as any,\n )\n\n if (!record) return null\n if (record.expiresAt && record.expiresAt.getTime() < Date.now()) return null\n\n return record\n}\n\n/**\n * Find a session API key with its decrypted secret.\n * Returns null if not found, expired, deleted, or decryption fails.\n * This is used by the MCP server to recover the API key secret for making\n * authenticated API calls on behalf of the user.\n */\nexport async function findSessionApiKeyWithSecret(\n em: EntityManager,\n sessionToken: string\n): Promise<{ key: ApiKey; secret: string } | null> {\n const record = await findApiKeyBySessionToken(em, sessionToken)\n if (!record) return null\n\n // If no encrypted secret stored, cannot recover\n if (!record.sessionSecretEncrypted) {\n logger.warn('Session key has no encrypted secret', { apiKeyId: record.id })\n return null\n }\n\n // Decrypt the secret\n const secret = await decryptSessionSecret(record.sessionSecretEncrypted, record.tenantId ?? null)\n if (!secret) {\n logger.warn('Failed to decrypt session secret', { apiKeyId: record.id })\n return null\n }\n\n return { key: record, secret }\n}\n\n/**\n * Delete an ephemeral API key by its session token.\n */\nexport async function deleteSessionApiKey(\n em: EntityManager,\n sessionToken: string\n): Promise<void> {\n // Looked up and re-checked by hash, never by the raw token (#2254). Deliberately does not\n // call findApiKeyBySessionToken: that helper also drops already-expired rows, but deleting\n // an expired-but-not-yet-cleaned-up row must still work here.\n const sessionTokenHash = hashSessionToken(sessionToken)\n let candidate = await em.findOne(ApiKey, { sessionTokenHash, deletedAt: null })\n if (!candidate) {\n // Version-skew fallback (#7304): see findApiKeyBySessionToken for the full rationale \u2014 a\n // row minted by pre-#2254 code during a deploy window is plaintext-only until touched.\n const legacyCandidate = await em.findOne(ApiKey, { sessionToken, deletedAt: null })\n if (legacyCandidate) {\n legacyCandidate.sessionTokenHash = sessionTokenHash\n legacyCandidate.sessionToken = null\n await em.persist(legacyCandidate).flush()\n candidate = legacyCandidate\n }\n }\n if (!candidate) return\n let deletedId: string | null = null\n await withAtomicFlush(em, [async () => {\n const records = await lockAuthorizationApiKeyRows(em, [String(candidate.id)])\n const record = records.find((entry) => (\n String(entry.id) === String(candidate.id)\n && entry.sessionTokenHash === sessionTokenHash\n && !entry.deletedAt\n ))\n if (!record) return\n record.deletedAt = new Date()\n await em.persist(record).flush()\n deletedId = String(record.id)\n }], { transaction: true, label: 'api_keys.session.delete' })\n if (deletedId) getSharedApiKeyAuthCache().invalidateByKeyId(deletedId)\n}\n\n/**\n * Execute a function with a one-time API key\n *\n * Creates a temporary API key, executes the function, and deletes the key.\n * Perfect for workflow activities that need authenticated access without\n * storing long-lived credentials.\n *\n * @param em - Entity manager\n * @param input - API key configuration\n * @param fn - Function to execute with the API key secret\n * @returns Result of the function\n */\nconst ONETIME_KEY_MAX_TTL_MS = 5 * 60 * 1000\n\nexport async function withOnetimeApiKey<T>(\n em: EntityManager,\n input: CreateApiKeyInput,\n fn: (secret: string) => Promise<T>\n): Promise<T> {\n const maxExpiresAt = new Date(Date.now() + ONETIME_KEY_MAX_TTL_MS)\n const safeExpiresAt = input.expiresAt && input.expiresAt < maxExpiresAt\n ? input.expiresAt\n : maxExpiresAt\n\n const { record, secret } = await createApiKey(em, {\n ...input,\n name: input.name || '__onetime__',\n description: input.description || 'One-time API key',\n expiresAt: safeExpiresAt,\n })\n\n try {\n const result = await fn(secret)\n return result\n } finally {\n try {\n await deleteApiKey(em, String(record.id))\n } catch (error) {\n logger.error('Failed to soft-delete one-time API key', { err: error })\n }\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["encrypted"]
7
7
  }
@@ -117,7 +117,6 @@ async function lockAuthorizationState(em, targets, options = {}) {
117
117
  return;
118
118
  }
119
119
  const apiKeyIds = uniqueSortedIds(targets.apiKeyIds ?? []);
120
- const userIds = uniqueSortedIds(targets.userIds ?? []);
121
120
  const explicitRoleIds = uniqueSortedIds(targets.roleIds ?? []);
122
121
  const tenantIds = uniqueSortedIds(targets.tenantIds ?? []);
123
122
  const explicitApiKeys = apiKeyIds.length ? await findWithDecryption(
@@ -127,7 +126,16 @@ async function lockAuthorizationState(em, targets, options = {}) {
127
126
  { orderBy: { id: "ASC" } },
128
127
  { tenantId: null, organizationId: null }
129
128
  ) : [];
130
- const explicitApiKeyRoleIds = uniqueSortedIds(explicitApiKeys.flatMap((apiKey) => Array.isArray(apiKey.rolesJson) ? apiKey.rolesJson : []));
129
+ const explicitApiKeySnapshots = new Map(explicitApiKeys.map((apiKey) => [String(apiKey.id), {
130
+ sessionUserId: apiKey.sessionUserId ?? null,
131
+ roles: Array.isArray(apiKey.rolesJson) ? [...apiKey.rolesJson] : []
132
+ }]));
133
+ const apiKeySnapshots = [...explicitApiKeySnapshots.values()];
134
+ const userIds = uniqueSortedIds([
135
+ ...targets.userIds ?? [],
136
+ ...apiKeySnapshots.flatMap((apiKey) => apiKey.sessionUserId ? [apiKey.sessionUserId] : [])
137
+ ]);
138
+ const explicitApiKeyRoleIds = uniqueSortedIds(apiKeySnapshots.flatMap((apiKey) => apiKey.roles));
131
139
  const discoveredBeforeLocks = userIds.length ? await findWithDecryption(
132
140
  em,
133
141
  UserRole,
@@ -151,10 +159,10 @@ async function lockAuthorizationState(em, targets, options = {}) {
151
159
  for (const apiKey of lockedApiKeys) {
152
160
  const id = String(apiKey.id);
153
161
  if (!apiKeyIds.includes(id)) continue;
154
- const before = explicitApiKeys.find((candidate) => String(candidate.id) === id);
155
- const beforeRoles = Array.isArray(before?.rolesJson) ? before.rolesJson : [];
162
+ const before = explicitApiKeySnapshots.get(id);
163
+ const beforeRoles = before?.roles ?? [];
156
164
  const lockedRoles = Array.isArray(apiKey.rolesJson) ? apiKey.rolesJson : [];
157
- if (!sameIds(beforeRoles, lockedRoles)) {
165
+ if (!sameIds(beforeRoles, lockedRoles) || (before?.sessionUserId ?? null) !== (apiKey.sessionUserId ?? null)) {
158
166
  throw authorizationStateDrift();
159
167
  }
160
168
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/auth/lib/authorizationStateLocks.ts"],
4
- "sourcesContent": ["import { LockMode } from '@mikro-orm/core'\nimport type { EntityManager, EntityName, FilterQuery, FindOptions } from '@mikro-orm/postgresql'\nimport { ApiKey } from '@open-mercato/core/modules/api_keys/data/entities'\nimport { Role, RoleAcl, User, UserAcl, UserRole } from '@open-mercato/core/modules/auth/data/entities'\nimport { lockOrganizationHierarchyForTenant } from '@open-mercato/core/modules/directory/lib/hierarchy'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'\nimport {\n getTransactionLifetime,\n onTransactionLifetimeComplete,\n} from '@open-mercato/shared/lib/commands/transaction-lifetime'\n\n/**\n * `api_keys` is an optional module (see module-decoupling.test.ts): its entity\n * metadata is only registered with the ORM when the module is enabled, so this\n * lookup must be skipped rather than attempted when it is disabled. Mirrors the\n * same guard in `audit_logs/api/audit-logs/display.ts`.\n */\nfunction isApiKeysModuleEnabled(): boolean {\n return Boolean(getEntityIds(false).api_keys?.api_key)\n}\n\nfunction uniqueSortedIds(values: readonly string[]): string[] {\n return Array.from(new Set(values.filter((value) => value.length > 0))).sort((left, right) => left.localeCompare(right))\n}\n\nfunction relationId(value: unknown): string | null {\n if (typeof value === 'string' && value.length > 0) return value\n if (!value || typeof value !== 'object') return null\n const id = (value as { id?: unknown }).id\n return typeof id === 'string' && id.length > 0 ? id : null\n}\n\nexport type AuthorizationStateLockTargets = {\n apiKeyIds?: readonly string[]\n userIds?: readonly string[]\n roleIds?: readonly string[]\n tenantIds?: readonly string[]\n}\n\ntype AuthorizationStateLockLease = {\n apiKeyIds: ReadonlySet<string>\n userIds: ReadonlySet<string>\n roleIds: ReadonlySet<string>\n tenantIds: ReadonlySet<string>\n}\n\nconst replayLockLeases = new WeakMap<object, AuthorizationStateLockLease>()\n\nfunction assertLeaseContains(\n lease: AuthorizationStateLockLease,\n targets: AuthorizationStateLockTargets,\n): void {\n const requested = {\n apiKeyIds: uniqueSortedIds(targets.apiKeyIds ?? []),\n userIds: uniqueSortedIds(targets.userIds ?? []),\n roleIds: uniqueSortedIds(targets.roleIds ?? []),\n tenantIds: uniqueSortedIds(targets.tenantIds ?? []),\n }\n for (const [kind, ids] of Object.entries(requested) as Array<\n [keyof AuthorizationStateLockLease, string[]]\n >) {\n for (const id of ids) {\n if (!lease[kind].has(id)) {\n throw new Error(`[internal] Authorization-state lock footprint was extended after it was sealed (${kind}:${id})`)\n }\n }\n }\n}\n\nfunction userRoleIdentity(link: UserRole): string | null {\n const userId = relationId(link.user)\n const roleId = relationId(link.role)\n return userId && roleId ? `${userId}:${roleId}` : null\n}\n\nfunction sameIds(left: readonly string[], right: readonly string[]): boolean {\n const canonicalLeft = uniqueSortedIds(left)\n const canonicalRight = uniqueSortedIds(right)\n return canonicalLeft.length === canonicalRight.length\n && canonicalLeft.every((value, index) => value === canonicalRight[index])\n}\n\nfunction unexpectedIds(expected: readonly string[], observed: readonly string[]): string[] {\n const expectedIds = new Set(expected)\n return observed.filter((id) => !expectedIds.has(id))\n}\n\nfunction authorizationStateDrift(): CrudHttpError {\n return new CrudHttpError(409, {\n error: '[internal] Authorization state changed while acquiring its lock footprint',\n })\n}\n\nasync function lockRowsInIdOrder<T extends object>(\n em: EntityManager,\n entity: EntityName<T>,\n ids: readonly string[],\n lockMode: LockMode = LockMode.PESSIMISTIC_WRITE,\n): Promise<T[]> {\n const sortedIds = uniqueSortedIds(ids)\n if (!sortedIds.length) return []\n return findWithDecryption(\n em,\n entity,\n { id: { $in: sortedIds } as unknown } as FilterQuery<T>,\n { lockMode, orderBy: { id: 'ASC' }, refresh: true } as FindOptions<T>,\n { tenantId: null, organizationId: null },\n )\n}\n\nexport async function lockAuthorizationApiKeyRows(\n em: EntityManager,\n apiKeyIds: readonly string[],\n): Promise<ApiKey[]> {\n return lockRowsInIdOrder(em, ApiKey, apiKeyIds)\n}\n\nexport async function findApiKeyIdsReferencingRoles(\n em: EntityManager,\n roleIds: readonly string[],\n): Promise<string[]> {\n const ids = uniqueSortedIds(roleIds)\n if (!ids.length) return []\n if (!isApiKeysModuleEnabled()) return []\n const apiKeys = await em.find(\n ApiKey,\n {\n deletedAt: null,\n $and: [\n { $or: ids.map((roleId) => ({ rolesJson: { $contains: [roleId] } })) },\n { $or: [{ expiresAt: null }, { expiresAt: { $gt: new Date() } }] },\n ],\n } as FilterQuery<ApiKey>,\n { orderBy: { id: 'ASC' } },\n )\n return uniqueSortedIds(apiKeys.map((apiKey) => String(apiKey.id)))\n}\n\nexport async function lockApiKeysReferencingRoles(\n em: EntityManager,\n roleIds: readonly string[],\n): Promise<ApiKey[]> {\n const apiKeyIds = await findApiKeyIdsReferencingRoles(em, roleIds)\n return lockAuthorizationApiKeyRows(em, apiKeyIds)\n}\n\nexport async function lockAuthorizationRoleRows(\n em: EntityManager,\n roleIds: readonly string[],\n): Promise<void> {\n await lockRowsInIdOrder(em, Role, roleIds)\n}\n\nexport async function lockAuthorizationUserRows(\n em: EntityManager,\n userIds: readonly string[],\n): Promise<void> {\n await lockRowsInIdOrder(em, User, userIds)\n}\n\nasync function lockLateReferencingApiKeyRows(\n em: EntityManager,\n apiKeyIds: readonly string[],\n): Promise<void> {\n const requested = uniqueSortedIds(apiKeyIds)\n if (!requested.length) return\n const locked = await lockRowsInIdOrder(em, ApiKey, requested, LockMode.PESSIMISTIC_PARTIAL_WRITE)\n const lockedIds = new Set(locked.map((apiKey) => String(apiKey.id)))\n if (requested.some((id) => !lockedIds.has(id))) {\n throw authorizationStateDrift()\n }\n}\n\nexport async function lockRoleWriterAuthorizationState(\n em: EntityManager,\n roleIds: readonly string[],\n): Promise<void> {\n await lockRoleAclWriterAuthorizationState(em, roleIds)\n}\n\nexport async function lockAuthorizationState(\n em: EntityManager,\n targets: AuthorizationStateLockTargets,\n options: { sealReplayFootprint?: boolean } = {},\n): Promise<void> {\n const transactionLifetime = getTransactionLifetime(em)\n const existingLease = transactionLifetime\n ? replayLockLeases.get(transactionLifetime)\n : undefined\n if (existingLease) {\n assertLeaseContains(existingLease, targets)\n return\n }\n\n const apiKeyIds = uniqueSortedIds(targets.apiKeyIds ?? [])\n const userIds = uniqueSortedIds(targets.userIds ?? [])\n const explicitRoleIds = uniqueSortedIds(targets.roleIds ?? [])\n const tenantIds = uniqueSortedIds(targets.tenantIds ?? [])\n const explicitApiKeys = apiKeyIds.length\n ? await findWithDecryption(\n em,\n ApiKey,\n { id: { $in: apiKeyIds } as unknown } as FilterQuery<ApiKey>,\n { orderBy: { id: 'ASC' } },\n { tenantId: null, organizationId: null },\n )\n : []\n const explicitApiKeyRoleIds = uniqueSortedIds(explicitApiKeys.flatMap((apiKey) => (\n Array.isArray(apiKey.rolesJson) ? apiKey.rolesJson : []\n )))\n const discoveredBeforeLocks = userIds.length\n ? await findWithDecryption(\n em,\n UserRole,\n { user: { $in: userIds } as unknown } as FilterQuery<UserRole>,\n { orderBy: { id: 'ASC' } },\n { tenantId: null, organizationId: null },\n )\n : []\n const roleIdsBeforeLocks = uniqueSortedIds([\n ...explicitRoleIds,\n ...explicitApiKeyRoleIds,\n ...discoveredBeforeLocks.flatMap((link) => {\n const id = relationId(link.role)\n return id ? [id] : []\n }),\n ])\n const referencingApiKeyIds = await findApiKeyIdsReferencingRoles(em, roleIdsBeforeLocks)\n const lockedApiKeys = await lockAuthorizationApiKeyRows(em, [\n ...apiKeyIds,\n ...referencingApiKeyIds,\n ])\n\n for (const apiKey of lockedApiKeys) {\n const id = String(apiKey.id)\n if (!apiKeyIds.includes(id)) continue\n const before = explicitApiKeys.find((candidate) => String(candidate.id) === id)\n const beforeRoles = Array.isArray(before?.rolesJson) ? before.rolesJson : []\n const lockedRoles = Array.isArray(apiKey.rolesJson) ? apiKey.rolesJson : []\n if (!sameIds(beforeRoles, lockedRoles)) {\n throw authorizationStateDrift()\n }\n }\n\n await lockAuthorizationUserRows(em, userIds)\n\n const stableUserRoles = userIds.length\n ? await findWithDecryption(\n em,\n UserRole,\n { user: { $in: userIds } as unknown } as FilterQuery<UserRole>,\n { orderBy: { id: 'ASC' }, refresh: true },\n { tenantId: null, organizationId: null },\n )\n : []\n const discoveredIdentities = discoveredBeforeLocks\n .map(userRoleIdentity)\n .filter((value): value is string => value !== null)\n const stableIdentities = stableUserRoles\n .map(userRoleIdentity)\n .filter((value): value is string => value !== null)\n if (!sameIds(discoveredIdentities, stableIdentities)) {\n throw authorizationStateDrift()\n }\n const roleIds = uniqueSortedIds([\n ...roleIdsBeforeLocks,\n ...stableUserRoles.flatMap((link) => {\n const id = relationId(link.role)\n return id ? [id] : []\n }),\n ])\n\n await lockAuthorizationRoleRows(em, roleIds)\n\n const stableReferencingApiKeyIds = await findApiKeyIdsReferencingRoles(em, roleIds)\n const lateReferencingApiKeyIds = unexpectedIds([...apiKeyIds, ...referencingApiKeyIds], stableReferencingApiKeyIds)\n if (options.sealReplayFootprint && lateReferencingApiKeyIds.length) {\n throw authorizationStateDrift()\n }\n await lockLateReferencingApiKeyRows(em, lateReferencingApiKeyIds)\n\n for (const tenantId of tenantIds) {\n await lockOrganizationHierarchyForTenant(em, tenantId)\n }\n\n if (userIds.length) {\n await findWithDecryption(\n em,\n UserRole,\n { user: { $in: userIds } as unknown } as FilterQuery<UserRole>,\n { lockMode: LockMode.PESSIMISTIC_WRITE, orderBy: { id: 'ASC' }, refresh: true },\n { tenantId: null, organizationId: null },\n )\n }\n\n if (userIds.length) {\n await findWithDecryption(\n em,\n UserAcl,\n { user: { $in: userIds } as unknown } as FilterQuery<UserAcl>,\n { lockMode: LockMode.PESSIMISTIC_WRITE, orderBy: { id: 'ASC' }, refresh: true },\n { tenantId: null, organizationId: null },\n )\n }\n if (roleIds.length) {\n await findWithDecryption(\n em,\n RoleAcl,\n { role: { $in: roleIds } as unknown } as FilterQuery<RoleAcl>,\n { lockMode: LockMode.PESSIMISTIC_WRITE, orderBy: { id: 'ASC' }, refresh: true },\n { tenantId: null, organizationId: null },\n )\n }\n\n if (options.sealReplayFootprint) {\n if (!transactionLifetime) {\n throw new Error('[internal] Replay authorization locks require an explicit transaction lifetime')\n }\n replayLockLeases.set(transactionLifetime, {\n apiKeyIds: new Set(apiKeyIds),\n userIds: new Set(userIds),\n roleIds: new Set(roleIds),\n tenantIds: new Set(tenantIds),\n })\n onTransactionLifetimeComplete(em, () => {\n replayLockLeases.delete(transactionLifetime)\n })\n }\n}\n\nexport async function lockUserRoleWriterAuthorizationState(\n em: EntityManager,\n targets: { userIds: readonly string[]; roleIds: readonly string[] },\n): Promise<void> {\n await lockAuthorizationState(em, targets)\n}\n\nexport async function lockUserAclWriterAuthorizationState(\n em: EntityManager,\n userIds: readonly string[],\n): Promise<void> {\n await lockAuthorizationState(em, { userIds })\n}\n\nexport async function lockRoleAclWriterAuthorizationState(\n em: EntityManager,\n roleIds: readonly string[],\n): Promise<void> {\n await lockAuthorizationState(em, { roleIds })\n}\n"],
5
- "mappings": "AAAA,SAAS,gBAAgB;AAEzB,SAAS,cAAc;AACvB,SAAS,MAAM,SAAS,MAAM,SAAS,gBAAgB;AACvD,SAAS,0CAA0C;AACnD,SAAS,0BAA0B;AACnC,SAAS,qBAAqB;AAC9B,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAQP,SAAS,yBAAkC;AACzC,SAAO,QAAQ,aAAa,KAAK,EAAE,UAAU,OAAO;AACtD;AAEA,SAAS,gBAAgB,QAAqC;AAC5D,SAAO,MAAM,KAAK,IAAI,IAAI,OAAO,OAAO,CAAC,UAAU,MAAM,SAAS,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,MAAM,UAAU,KAAK,cAAc,KAAK,CAAC;AACxH;AAEA,SAAS,WAAW,OAA+B;AACjD,MAAI,OAAO,UAAU,YAAY,MAAM,SAAS,EAAG,QAAO;AAC1D,MAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO;AAChD,QAAM,KAAM,MAA2B;AACvC,SAAO,OAAO,OAAO,YAAY,GAAG,SAAS,IAAI,KAAK;AACxD;AAgBA,MAAM,mBAAmB,oBAAI,QAA6C;AAE1E,SAAS,oBACP,OACA,SACM;AACN,QAAM,YAAY;AAAA,IAChB,WAAW,gBAAgB,QAAQ,aAAa,CAAC,CAAC;AAAA,IAClD,SAAS,gBAAgB,QAAQ,WAAW,CAAC,CAAC;AAAA,IAC9C,SAAS,gBAAgB,QAAQ,WAAW,CAAC,CAAC;AAAA,IAC9C,WAAW,gBAAgB,QAAQ,aAAa,CAAC,CAAC;AAAA,EACpD;AACA,aAAW,CAAC,MAAM,GAAG,KAAK,OAAO,QAAQ,SAAS,GAE/C;AACD,eAAW,MAAM,KAAK;AACpB,UAAI,CAAC,MAAM,IAAI,EAAE,IAAI,EAAE,GAAG;AACxB,cAAM,IAAI,MAAM,mFAAmF,IAAI,IAAI,EAAE,GAAG;AAAA,MAClH;AAAA,IACF;AAAA,EACF;AACF;AAEA,SAAS,iBAAiB,MAA+B;AACvD,QAAM,SAAS,WAAW,KAAK,IAAI;AACnC,QAAM,SAAS,WAAW,KAAK,IAAI;AACnC,SAAO,UAAU,SAAS,GAAG,MAAM,IAAI,MAAM,KAAK;AACpD;AAEA,SAAS,QAAQ,MAAyB,OAAmC;AAC3E,QAAM,gBAAgB,gBAAgB,IAAI;AAC1C,QAAM,iBAAiB,gBAAgB,KAAK;AAC5C,SAAO,cAAc,WAAW,eAAe,UAC1C,cAAc,MAAM,CAAC,OAAO,UAAU,UAAU,eAAe,KAAK,CAAC;AAC5E;AAEA,SAAS,cAAc,UAA6B,UAAuC;AACzF,QAAM,cAAc,IAAI,IAAI,QAAQ;AACpC,SAAO,SAAS,OAAO,CAAC,OAAO,CAAC,YAAY,IAAI,EAAE,CAAC;AACrD;AAEA,SAAS,0BAAyC;AAChD,SAAO,IAAI,cAAc,KAAK;AAAA,IAC5B,OAAO;AAAA,EACT,CAAC;AACH;AAEA,eAAe,kBACb,IACA,QACA,KACA,WAAqB,SAAS,mBAChB;AACd,QAAM,YAAY,gBAAgB,GAAG;AACrC,MAAI,CAAC,UAAU,OAAQ,QAAO,CAAC;AAC/B,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,EAAE,IAAI,EAAE,KAAK,UAAU,EAAa;AAAA,IACpC,EAAE,UAAU,SAAS,EAAE,IAAI,MAAM,GAAG,SAAS,KAAK;AAAA,IAClD,EAAE,UAAU,MAAM,gBAAgB,KAAK;AAAA,EACzC;AACF;AAEA,eAAsB,4BACpB,IACA,WACmB;AACnB,SAAO,kBAAkB,IAAI,QAAQ,SAAS;AAChD;AAEA,eAAsB,8BACpB,IACA,SACmB;AACnB,QAAM,MAAM,gBAAgB,OAAO;AACnC,MAAI,CAAC,IAAI,OAAQ,QAAO,CAAC;AACzB,MAAI,CAAC,uBAAuB,EAAG,QAAO,CAAC;AACvC,QAAM,UAAU,MAAM,GAAG;AAAA,IACvB;AAAA,IACA;AAAA,MACE,WAAW;AAAA,MACX,MAAM;AAAA,QACJ,EAAE,KAAK,IAAI,IAAI,CAAC,YAAY,EAAE,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,EAAE,EAAE,EAAE;AAAA,QACrE,EAAE,KAAK,CAAC,EAAE,WAAW,KAAK,GAAG,EAAE,WAAW,EAAE,KAAK,oBAAI,KAAK,EAAE,EAAE,CAAC,EAAE;AAAA,MACnE;AAAA,IACF;AAAA,IACA,EAAE,SAAS,EAAE,IAAI,MAAM,EAAE;AAAA,EAC3B;AACA,SAAO,gBAAgB,QAAQ,IAAI,CAAC,WAAW,OAAO,OAAO,EAAE,CAAC,CAAC;AACnE;AAEA,eAAsB,4BACpB,IACA,SACmB;AACnB,QAAM,YAAY,MAAM,8BAA8B,IAAI,OAAO;AACjE,SAAO,4BAA4B,IAAI,SAAS;AAClD;AAEA,eAAsB,0BACpB,IACA,SACe;AACf,QAAM,kBAAkB,IAAI,MAAM,OAAO;AAC3C;AAEA,eAAsB,0BACpB,IACA,SACe;AACf,QAAM,kBAAkB,IAAI,MAAM,OAAO;AAC3C;AAEA,eAAe,8BACb,IACA,WACe;AACf,QAAM,YAAY,gBAAgB,SAAS;AAC3C,MAAI,CAAC,UAAU,OAAQ;AACvB,QAAM,SAAS,MAAM,kBAAkB,IAAI,QAAQ,WAAW,SAAS,yBAAyB;AAChG,QAAM,YAAY,IAAI,IAAI,OAAO,IAAI,CAAC,WAAW,OAAO,OAAO,EAAE,CAAC,CAAC;AACnE,MAAI,UAAU,KAAK,CAAC,OAAO,CAAC,UAAU,IAAI,EAAE,CAAC,GAAG;AAC9C,UAAM,wBAAwB;AAAA,EAChC;AACF;AAEA,eAAsB,iCACpB,IACA,SACe;AACf,QAAM,oCAAoC,IAAI,OAAO;AACvD;AAEA,eAAsB,uBACpB,IACA,SACA,UAA6C,CAAC,GAC/B;AACf,QAAM,sBAAsB,uBAAuB,EAAE;AACrD,QAAM,gBAAgB,sBAClB,iBAAiB,IAAI,mBAAmB,IACxC;AACJ,MAAI,eAAe;AACjB,wBAAoB,eAAe,OAAO;AAC1C;AAAA,EACF;AAEA,QAAM,YAAY,gBAAgB,QAAQ,aAAa,CAAC,CAAC;AACzD,QAAM,UAAU,gBAAgB,QAAQ,WAAW,CAAC,CAAC;AACrD,QAAM,kBAAkB,gBAAgB,QAAQ,WAAW,CAAC,CAAC;AAC7D,QAAM,YAAY,gBAAgB,QAAQ,aAAa,CAAC,CAAC;AACzD,QAAM,kBAAkB,UAAU,SAC9B,MAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA,EAAE,IAAI,EAAE,KAAK,UAAU,EAAa;AAAA,IACpC,EAAE,SAAS,EAAE,IAAI,MAAM,EAAE;AAAA,IACzB,EAAE,UAAU,MAAM,gBAAgB,KAAK;AAAA,EACzC,IACA,CAAC;AACL,QAAM,wBAAwB,gBAAgB,gBAAgB,QAAQ,CAAC,WACrE,MAAM,QAAQ,OAAO,SAAS,IAAI,OAAO,YAAY,CAAC,CACvD,CAAC;AACF,QAAM,wBAAwB,QAAQ,SAClC,MAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA,EAAE,MAAM,EAAE,KAAK,QAAQ,EAAa;AAAA,IACpC,EAAE,SAAS,EAAE,IAAI,MAAM,EAAE;AAAA,IACzB,EAAE,UAAU,MAAM,gBAAgB,KAAK;AAAA,EACzC,IACA,CAAC;AACL,QAAM,qBAAqB,gBAAgB;AAAA,IACzC,GAAG;AAAA,IACH,GAAG;AAAA,IACH,GAAG,sBAAsB,QAAQ,CAAC,SAAS;AACzC,YAAM,KAAK,WAAW,KAAK,IAAI;AAC/B,aAAO,KAAK,CAAC,EAAE,IAAI,CAAC;AAAA,IACtB,CAAC;AAAA,EACH,CAAC;AACD,QAAM,uBAAuB,MAAM,8BAA8B,IAAI,kBAAkB;AACvF,QAAM,gBAAgB,MAAM,4BAA4B,IAAI;AAAA,IAC1D,GAAG;AAAA,IACH,GAAG;AAAA,EACL,CAAC;AAED,aAAW,UAAU,eAAe;AAClC,UAAM,KAAK,OAAO,OAAO,EAAE;AAC3B,QAAI,CAAC,UAAU,SAAS,EAAE,EAAG;AAC7B,UAAM,SAAS,gBAAgB,KAAK,CAAC,cAAc,OAAO,UAAU,EAAE,MAAM,EAAE;AAC9E,UAAM,cAAc,MAAM,QAAQ,QAAQ,SAAS,IAAI,OAAO,YAAY,CAAC;AAC3E,UAAM,cAAc,MAAM,QAAQ,OAAO,SAAS,IAAI,OAAO,YAAY,CAAC;AAC1E,QAAI,CAAC,QAAQ,aAAa,WAAW,GAAG;AACtC,YAAM,wBAAwB;AAAA,IAChC;AAAA,EACF;AAEA,QAAM,0BAA0B,IAAI,OAAO;AAE3C,QAAM,kBAAkB,QAAQ,SAC5B,MAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA,EAAE,MAAM,EAAE,KAAK,QAAQ,EAAa;AAAA,IACpC,EAAE,SAAS,EAAE,IAAI,MAAM,GAAG,SAAS,KAAK;AAAA,IACxC,EAAE,UAAU,MAAM,gBAAgB,KAAK;AAAA,EACzC,IACA,CAAC;AACL,QAAM,uBAAuB,sBAC1B,IAAI,gBAAgB,EACpB,OAAO,CAAC,UAA2B,UAAU,IAAI;AACpD,QAAM,mBAAmB,gBACtB,IAAI,gBAAgB,EACpB,OAAO,CAAC,UAA2B,UAAU,IAAI;AACpD,MAAI,CAAC,QAAQ,sBAAsB,gBAAgB,GAAG;AACpD,UAAM,wBAAwB;AAAA,EAChC;AACA,QAAM,UAAU,gBAAgB;AAAA,IAC9B,GAAG;AAAA,IACH,GAAG,gBAAgB,QAAQ,CAAC,SAAS;AACnC,YAAM,KAAK,WAAW,KAAK,IAAI;AAC/B,aAAO,KAAK,CAAC,EAAE,IAAI,CAAC;AAAA,IACtB,CAAC;AAAA,EACH,CAAC;AAED,QAAM,0BAA0B,IAAI,OAAO;AAE3C,QAAM,6BAA6B,MAAM,8BAA8B,IAAI,OAAO;AAClF,QAAM,2BAA2B,cAAc,CAAC,GAAG,WAAW,GAAG,oBAAoB,GAAG,0BAA0B;AAClH,MAAI,QAAQ,uBAAuB,yBAAyB,QAAQ;AAClE,UAAM,wBAAwB;AAAA,EAChC;AACA,QAAM,8BAA8B,IAAI,wBAAwB;AAEhE,aAAW,YAAY,WAAW;AAChC,UAAM,mCAAmC,IAAI,QAAQ;AAAA,EACvD;AAEA,MAAI,QAAQ,QAAQ;AAClB,UAAM;AAAA,MACJ;AAAA,MACA;AAAA,MACA,EAAE,MAAM,EAAE,KAAK,QAAQ,EAAa;AAAA,MACpC,EAAE,UAAU,SAAS,mBAAmB,SAAS,EAAE,IAAI,MAAM,GAAG,SAAS,KAAK;AAAA,MAC9E,EAAE,UAAU,MAAM,gBAAgB,KAAK;AAAA,IACzC;AAAA,EACF;AAEA,MAAI,QAAQ,QAAQ;AAClB,UAAM;AAAA,MACJ;AAAA,MACA;AAAA,MACA,EAAE,MAAM,EAAE,KAAK,QAAQ,EAAa;AAAA,MACpC,EAAE,UAAU,SAAS,mBAAmB,SAAS,EAAE,IAAI,MAAM,GAAG,SAAS,KAAK;AAAA,MAC9E,EAAE,UAAU,MAAM,gBAAgB,KAAK;AAAA,IACzC;AAAA,EACF;AACA,MAAI,QAAQ,QAAQ;AAClB,UAAM;AAAA,MACJ;AAAA,MACA;AAAA,MACA,EAAE,MAAM,EAAE,KAAK,QAAQ,EAAa;AAAA,MACpC,EAAE,UAAU,SAAS,mBAAmB,SAAS,EAAE,IAAI,MAAM,GAAG,SAAS,KAAK;AAAA,MAC9E,EAAE,UAAU,MAAM,gBAAgB,KAAK;AAAA,IACzC;AAAA,EACF;AAEA,MAAI,QAAQ,qBAAqB;AAC/B,QAAI,CAAC,qBAAqB;AACxB,YAAM,IAAI,MAAM,gFAAgF;AAAA,IAClG;AACA,qBAAiB,IAAI,qBAAqB;AAAA,MACxC,WAAW,IAAI,IAAI,SAAS;AAAA,MAC5B,SAAS,IAAI,IAAI,OAAO;AAAA,MACxB,SAAS,IAAI,IAAI,OAAO;AAAA,MACxB,WAAW,IAAI,IAAI,SAAS;AAAA,IAC9B,CAAC;AACD,kCAA8B,IAAI,MAAM;AACtC,uBAAiB,OAAO,mBAAmB;AAAA,IAC7C,CAAC;AAAA,EACH;AACF;AAEA,eAAsB,qCACpB,IACA,SACe;AACf,QAAM,uBAAuB,IAAI,OAAO;AAC1C;AAEA,eAAsB,oCACpB,IACA,SACe;AACf,QAAM,uBAAuB,IAAI,EAAE,QAAQ,CAAC;AAC9C;AAEA,eAAsB,oCACpB,IACA,SACe;AACf,QAAM,uBAAuB,IAAI,EAAE,QAAQ,CAAC;AAC9C;",
4
+ "sourcesContent": ["import { LockMode } from '@mikro-orm/core'\nimport type { EntityManager, EntityName, FilterQuery, FindOptions } from '@mikro-orm/postgresql'\nimport { ApiKey } from '@open-mercato/core/modules/api_keys/data/entities'\nimport { Role, RoleAcl, User, UserAcl, UserRole } from '@open-mercato/core/modules/auth/data/entities'\nimport { lockOrganizationHierarchyForTenant } from '@open-mercato/core/modules/directory/lib/hierarchy'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'\nimport {\n getTransactionLifetime,\n onTransactionLifetimeComplete,\n} from '@open-mercato/shared/lib/commands/transaction-lifetime'\n\n/**\n * `api_keys` is an optional module (see module-decoupling.test.ts): its entity\n * metadata is only registered with the ORM when the module is enabled, so this\n * lookup must be skipped rather than attempted when it is disabled. Mirrors the\n * same guard in `audit_logs/api/audit-logs/display.ts`.\n */\nfunction isApiKeysModuleEnabled(): boolean {\n return Boolean(getEntityIds(false).api_keys?.api_key)\n}\n\nfunction uniqueSortedIds(values: readonly string[]): string[] {\n return Array.from(new Set(values.filter((value) => value.length > 0))).sort((left, right) => left.localeCompare(right))\n}\n\nfunction relationId(value: unknown): string | null {\n if (typeof value === 'string' && value.length > 0) return value\n if (!value || typeof value !== 'object') return null\n const id = (value as { id?: unknown }).id\n return typeof id === 'string' && id.length > 0 ? id : null\n}\n\nexport type AuthorizationStateLockTargets = {\n apiKeyIds?: readonly string[]\n userIds?: readonly string[]\n roleIds?: readonly string[]\n tenantIds?: readonly string[]\n}\n\ntype AuthorizationStateLockLease = {\n apiKeyIds: ReadonlySet<string>\n userIds: ReadonlySet<string>\n roleIds: ReadonlySet<string>\n tenantIds: ReadonlySet<string>\n}\n\nconst replayLockLeases = new WeakMap<object, AuthorizationStateLockLease>()\n\nfunction assertLeaseContains(\n lease: AuthorizationStateLockLease,\n targets: AuthorizationStateLockTargets,\n): void {\n const requested = {\n apiKeyIds: uniqueSortedIds(targets.apiKeyIds ?? []),\n userIds: uniqueSortedIds(targets.userIds ?? []),\n roleIds: uniqueSortedIds(targets.roleIds ?? []),\n tenantIds: uniqueSortedIds(targets.tenantIds ?? []),\n }\n for (const [kind, ids] of Object.entries(requested) as Array<\n [keyof AuthorizationStateLockLease, string[]]\n >) {\n for (const id of ids) {\n if (!lease[kind].has(id)) {\n throw new Error(`[internal] Authorization-state lock footprint was extended after it was sealed (${kind}:${id})`)\n }\n }\n }\n}\n\nfunction userRoleIdentity(link: UserRole): string | null {\n const userId = relationId(link.user)\n const roleId = relationId(link.role)\n return userId && roleId ? `${userId}:${roleId}` : null\n}\n\nfunction sameIds(left: readonly string[], right: readonly string[]): boolean {\n const canonicalLeft = uniqueSortedIds(left)\n const canonicalRight = uniqueSortedIds(right)\n return canonicalLeft.length === canonicalRight.length\n && canonicalLeft.every((value, index) => value === canonicalRight[index])\n}\n\nfunction unexpectedIds(expected: readonly string[], observed: readonly string[]): string[] {\n const expectedIds = new Set(expected)\n return observed.filter((id) => !expectedIds.has(id))\n}\n\nfunction authorizationStateDrift(): CrudHttpError {\n return new CrudHttpError(409, {\n error: '[internal] Authorization state changed while acquiring its lock footprint',\n })\n}\n\nasync function lockRowsInIdOrder<T extends object>(\n em: EntityManager,\n entity: EntityName<T>,\n ids: readonly string[],\n lockMode: LockMode = LockMode.PESSIMISTIC_WRITE,\n): Promise<T[]> {\n const sortedIds = uniqueSortedIds(ids)\n if (!sortedIds.length) return []\n return findWithDecryption(\n em,\n entity,\n { id: { $in: sortedIds } as unknown } as FilterQuery<T>,\n { lockMode, orderBy: { id: 'ASC' }, refresh: true } as FindOptions<T>,\n { tenantId: null, organizationId: null },\n )\n}\n\nexport async function lockAuthorizationApiKeyRows(\n em: EntityManager,\n apiKeyIds: readonly string[],\n): Promise<ApiKey[]> {\n return lockRowsInIdOrder(em, ApiKey, apiKeyIds)\n}\n\nexport async function findApiKeyIdsReferencingRoles(\n em: EntityManager,\n roleIds: readonly string[],\n): Promise<string[]> {\n const ids = uniqueSortedIds(roleIds)\n if (!ids.length) return []\n if (!isApiKeysModuleEnabled()) return []\n const apiKeys = await em.find(\n ApiKey,\n {\n deletedAt: null,\n $and: [\n { $or: ids.map((roleId) => ({ rolesJson: { $contains: [roleId] } })) },\n { $or: [{ expiresAt: null }, { expiresAt: { $gt: new Date() } }] },\n ],\n } as FilterQuery<ApiKey>,\n { orderBy: { id: 'ASC' } },\n )\n return uniqueSortedIds(apiKeys.map((apiKey) => String(apiKey.id)))\n}\n\nexport async function lockApiKeysReferencingRoles(\n em: EntityManager,\n roleIds: readonly string[],\n): Promise<ApiKey[]> {\n const apiKeyIds = await findApiKeyIdsReferencingRoles(em, roleIds)\n return lockAuthorizationApiKeyRows(em, apiKeyIds)\n}\n\nexport async function lockAuthorizationRoleRows(\n em: EntityManager,\n roleIds: readonly string[],\n): Promise<void> {\n await lockRowsInIdOrder(em, Role, roleIds)\n}\n\nexport async function lockAuthorizationUserRows(\n em: EntityManager,\n userIds: readonly string[],\n): Promise<void> {\n await lockRowsInIdOrder(em, User, userIds)\n}\n\nasync function lockLateReferencingApiKeyRows(\n em: EntityManager,\n apiKeyIds: readonly string[],\n): Promise<void> {\n const requested = uniqueSortedIds(apiKeyIds)\n if (!requested.length) return\n const locked = await lockRowsInIdOrder(em, ApiKey, requested, LockMode.PESSIMISTIC_PARTIAL_WRITE)\n const lockedIds = new Set(locked.map((apiKey) => String(apiKey.id)))\n if (requested.some((id) => !lockedIds.has(id))) {\n throw authorizationStateDrift()\n }\n}\n\nexport async function lockRoleWriterAuthorizationState(\n em: EntityManager,\n roleIds: readonly string[],\n): Promise<void> {\n await lockRoleAclWriterAuthorizationState(em, roleIds)\n}\n\nexport async function lockAuthorizationState(\n em: EntityManager,\n targets: AuthorizationStateLockTargets,\n options: { sealReplayFootprint?: boolean } = {},\n): Promise<void> {\n const transactionLifetime = getTransactionLifetime(em)\n const existingLease = transactionLifetime\n ? replayLockLeases.get(transactionLifetime)\n : undefined\n if (existingLease) {\n assertLeaseContains(existingLease, targets)\n return\n }\n\n const apiKeyIds = uniqueSortedIds(targets.apiKeyIds ?? [])\n const explicitRoleIds = uniqueSortedIds(targets.roleIds ?? [])\n const tenantIds = uniqueSortedIds(targets.tenantIds ?? [])\n const explicitApiKeys = apiKeyIds.length\n ? await findWithDecryption(\n em,\n ApiKey,\n { id: { $in: apiKeyIds } as unknown } as FilterQuery<ApiKey>,\n { orderBy: { id: 'ASC' } },\n { tenantId: null, organizationId: null },\n )\n : []\n const explicitApiKeySnapshots = new Map(explicitApiKeys.map((apiKey) => [String(apiKey.id), {\n sessionUserId: apiKey.sessionUserId ?? null,\n roles: Array.isArray(apiKey.rolesJson) ? [...apiKey.rolesJson] : [],\n }]))\n const apiKeySnapshots = [...explicitApiKeySnapshots.values()]\n const userIds = uniqueSortedIds([\n ...(targets.userIds ?? []),\n ...apiKeySnapshots.flatMap((apiKey) => apiKey.sessionUserId ? [apiKey.sessionUserId] : []),\n ])\n const explicitApiKeyRoleIds = uniqueSortedIds(apiKeySnapshots.flatMap((apiKey) => apiKey.roles))\n const discoveredBeforeLocks = userIds.length\n ? await findWithDecryption(\n em,\n UserRole,\n { user: { $in: userIds } as unknown } as FilterQuery<UserRole>,\n { orderBy: { id: 'ASC' } },\n { tenantId: null, organizationId: null },\n )\n : []\n const roleIdsBeforeLocks = uniqueSortedIds([\n ...explicitRoleIds,\n ...explicitApiKeyRoleIds,\n ...discoveredBeforeLocks.flatMap((link) => {\n const id = relationId(link.role)\n return id ? [id] : []\n }),\n ])\n const referencingApiKeyIds = await findApiKeyIdsReferencingRoles(em, roleIdsBeforeLocks)\n const lockedApiKeys = await lockAuthorizationApiKeyRows(em, [\n ...apiKeyIds,\n ...referencingApiKeyIds,\n ])\n\n for (const apiKey of lockedApiKeys) {\n const id = String(apiKey.id)\n if (!apiKeyIds.includes(id)) continue\n const before = explicitApiKeySnapshots.get(id)\n const beforeRoles = before?.roles ?? []\n const lockedRoles = Array.isArray(apiKey.rolesJson) ? apiKey.rolesJson : []\n if (!sameIds(beforeRoles, lockedRoles) || (before?.sessionUserId ?? null) !== (apiKey.sessionUserId ?? null)) {\n throw authorizationStateDrift()\n }\n }\n\n await lockAuthorizationUserRows(em, userIds)\n\n const stableUserRoles = userIds.length\n ? await findWithDecryption(\n em,\n UserRole,\n { user: { $in: userIds } as unknown } as FilterQuery<UserRole>,\n { orderBy: { id: 'ASC' }, refresh: true },\n { tenantId: null, organizationId: null },\n )\n : []\n const discoveredIdentities = discoveredBeforeLocks\n .map(userRoleIdentity)\n .filter((value): value is string => value !== null)\n const stableIdentities = stableUserRoles\n .map(userRoleIdentity)\n .filter((value): value is string => value !== null)\n if (!sameIds(discoveredIdentities, stableIdentities)) {\n throw authorizationStateDrift()\n }\n const roleIds = uniqueSortedIds([\n ...roleIdsBeforeLocks,\n ...stableUserRoles.flatMap((link) => {\n const id = relationId(link.role)\n return id ? [id] : []\n }),\n ])\n\n await lockAuthorizationRoleRows(em, roleIds)\n\n const stableReferencingApiKeyIds = await findApiKeyIdsReferencingRoles(em, roleIds)\n const lateReferencingApiKeyIds = unexpectedIds([...apiKeyIds, ...referencingApiKeyIds], stableReferencingApiKeyIds)\n if (options.sealReplayFootprint && lateReferencingApiKeyIds.length) {\n throw authorizationStateDrift()\n }\n await lockLateReferencingApiKeyRows(em, lateReferencingApiKeyIds)\n\n for (const tenantId of tenantIds) {\n await lockOrganizationHierarchyForTenant(em, tenantId)\n }\n\n if (userIds.length) {\n await findWithDecryption(\n em,\n UserRole,\n { user: { $in: userIds } as unknown } as FilterQuery<UserRole>,\n { lockMode: LockMode.PESSIMISTIC_WRITE, orderBy: { id: 'ASC' }, refresh: true },\n { tenantId: null, organizationId: null },\n )\n }\n\n if (userIds.length) {\n await findWithDecryption(\n em,\n UserAcl,\n { user: { $in: userIds } as unknown } as FilterQuery<UserAcl>,\n { lockMode: LockMode.PESSIMISTIC_WRITE, orderBy: { id: 'ASC' }, refresh: true },\n { tenantId: null, organizationId: null },\n )\n }\n if (roleIds.length) {\n await findWithDecryption(\n em,\n RoleAcl,\n { role: { $in: roleIds } as unknown } as FilterQuery<RoleAcl>,\n { lockMode: LockMode.PESSIMISTIC_WRITE, orderBy: { id: 'ASC' }, refresh: true },\n { tenantId: null, organizationId: null },\n )\n }\n\n if (options.sealReplayFootprint) {\n if (!transactionLifetime) {\n throw new Error('[internal] Replay authorization locks require an explicit transaction lifetime')\n }\n replayLockLeases.set(transactionLifetime, {\n apiKeyIds: new Set(apiKeyIds),\n userIds: new Set(userIds),\n roleIds: new Set(roleIds),\n tenantIds: new Set(tenantIds),\n })\n onTransactionLifetimeComplete(em, () => {\n replayLockLeases.delete(transactionLifetime)\n })\n }\n}\n\nexport async function lockUserRoleWriterAuthorizationState(\n em: EntityManager,\n targets: { userIds: readonly string[]; roleIds: readonly string[] },\n): Promise<void> {\n await lockAuthorizationState(em, targets)\n}\n\nexport async function lockUserAclWriterAuthorizationState(\n em: EntityManager,\n userIds: readonly string[],\n): Promise<void> {\n await lockAuthorizationState(em, { userIds })\n}\n\nexport async function lockRoleAclWriterAuthorizationState(\n em: EntityManager,\n roleIds: readonly string[],\n): Promise<void> {\n await lockAuthorizationState(em, { roleIds })\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -10,9 +10,26 @@ import {
10
10
  resolveEffectiveFeatures
11
11
  } from "@open-mercato/shared/security/featurePolicy";
12
12
  import { filterGrantsByEnabledModules } from "@open-mercato/shared/security/enabledModulesRegistry";
13
+ import { hasFeature } from "@open-mercato/shared/security/features";
13
14
  import { resolveRoleOrganizationScope, roleAclAllowsOrganization } from "./roleOrganizationScope.js";
14
15
  import { createLogger } from "@open-mercato/shared/lib/logger";
15
16
  const logger = createLogger("auth.rbac");
17
+ function intersectDelegatedAcl(keyAcl, ownerAcl) {
18
+ const keyFeatures = keyAcl.isSuperAdmin ? ["*"] : keyAcl.features;
19
+ const ownerFeatures = ownerAcl.isSuperAdmin ? ["*"] : ownerAcl.features;
20
+ const features = Array.from(/* @__PURE__ */ new Set([
21
+ ...keyFeatures.filter((feature) => hasFeature(ownerFeatures, feature)),
22
+ ...ownerFeatures.filter((feature) => hasFeature(keyFeatures, feature))
23
+ ]));
24
+ const keyOrganizations = keyAcl.isSuperAdmin || keyAcl.organizations?.includes("__all__") ? null : keyAcl.organizations;
25
+ const ownerOrganizations = ownerAcl.isSuperAdmin || ownerAcl.organizations?.includes("__all__") ? null : ownerAcl.organizations;
26
+ const organizations = keyOrganizations === null ? ownerOrganizations : ownerOrganizations === null ? keyOrganizations : keyOrganizations.filter((organizationId) => ownerOrganizations.includes(organizationId));
27
+ return {
28
+ isSuperAdmin: keyAcl.isSuperAdmin && ownerAcl.isSuperAdmin,
29
+ features: organizations?.length === 0 ? [] : features,
30
+ organizations
31
+ };
32
+ }
16
33
  async function resolveRoleOrganizationScopeWithEntityManager(em, tenantId, organizationId) {
17
34
  if (!organizationId) return null;
18
35
  if (!tenantId) return /* @__PURE__ */ new Set();
@@ -56,7 +73,7 @@ function isFeatureOrganizationGrants(value) {
56
73
  case "none":
57
74
  return true;
58
75
  case "api_key":
59
- return (record.organizationId === null || typeof record.organizationId === "string") && Array.isArray(record.roleGrants) && record.roleGrants.every(isFeatureOrganizationRoleGrant);
76
+ return (record.organizationId === null || typeof record.organizationId === "string") && (record.ownerId === null || typeof record.ownerId === "string") && Array.isArray(record.roleGrants) && record.roleGrants.every(isFeatureOrganizationRoleGrant);
60
77
  case "user_acl":
61
78
  return typeof record.isSuperAdmin === "boolean" && Array.isArray(record.features) && record.features.every((feature) => typeof feature === "string") && isStringListOrNull(record.organizations);
62
79
  case "roles":
@@ -163,7 +180,8 @@ class RbacService {
163
180
  return authorizeFeatures(required, { grantedFeatures: granted });
164
181
  }
165
182
  getCacheKey(userId, scope) {
166
- return `rbac:${userId}:${scope.tenantId || "null"}:${scope.organizationId || "null"}`;
183
+ const namespace = userId.startsWith("api_key:") ? "rbac:delegated-v1" : "rbac";
184
+ return `${namespace}:${userId}:${scope.tenantId || "null"}:${scope.organizationId || "null"}`;
167
185
  }
168
186
  getUserTag(userId) {
169
187
  return `rbac:user:${userId}`;
@@ -180,11 +198,11 @@ class RbacService {
180
198
  if (!cached) return null;
181
199
  return isAclData(cached) ? cached : null;
182
200
  }
183
- async setCache(cacheKey, data, userId, scope) {
201
+ async setCache(cacheKey, data, userId, scope, ownerId) {
184
202
  if (!this.cache) return;
185
203
  await this.cache.set(cacheKey, data, {
186
204
  ttl: this.cacheTtlMs,
187
- tags: this.buildCacheTags(userId, scope)
205
+ tags: [...this.buildCacheTags(userId, scope), ...ownerId ? [this.getUserTag(ownerId)] : []]
188
206
  });
189
207
  }
190
208
  buildCacheTags(userId, scope) {
@@ -431,8 +449,9 @@ class RbacService {
431
449
  organizations2 = roleOrganizations === null || roleOrganizations.some((organizationId) => keyOrganizationScope?.has(organizationId)) ? [keyOrganizationId] : [];
432
450
  }
433
451
  if (isSuper2 && organizations2 !== null && !features2.includes("*")) features2.push("*");
434
- const result2 = { isSuperAdmin: isSuper2 && organizations2 === null, features: features2, organizations: organizations2 };
435
- if (cacheable) await this.setCache(cacheKey, result2, userId, scope);
452
+ const keyAcl = { isSuperAdmin: isSuper2 && organizations2 === null, features: features2, organizations: organizations2 };
453
+ const result2 = await this.limitDelegatedKeyAcl(em2, key, { tenantId: tenantId2, organizationId: evaluatedOrganizationId }, keyAcl);
454
+ if (cacheable) await this.setCache(cacheKey, result2, userId, { tenantId: tenantId2, organizationId: evaluatedOrganizationId }, key.sessionUserId);
436
455
  return result2;
437
456
  }
438
457
  const em = this.em.fork();
@@ -580,7 +599,13 @@ class RbacService {
580
599
  organizations2 = organizations2 === null || organizations2.some((organizationId2) => keyScope?.has(organizationId2)) ? [keyOrganizationId] : [];
581
600
  }
582
601
  if (isSuperAdmin2 && organizations2 !== null && !features2.includes("*")) features2.push("*");
583
- return { isSuperAdmin: isSuperAdmin2 && organizations2 === null, features: features2, organizations: organizations2 };
602
+ return this.limitDelegatedKeyAcl(
603
+ em,
604
+ key,
605
+ { tenantId: tenantId2, organizationId: evaluatedOrganizationId },
606
+ { isSuperAdmin: isSuperAdmin2 && organizations2 === null, features: features2, organizations: organizations2 },
607
+ true
608
+ );
584
609
  }
585
610
  const user = await findOneWithDecryption(
586
611
  em,
@@ -738,6 +763,24 @@ class RbacService {
738
763
  scopeAllowed: organizationAllowed
739
764
  });
740
765
  }
766
+ async limitDelegatedKeyAcl(em, key, scope, keyAcl, uncached = false) {
767
+ if (!key.sessionUserId) return keyAcl;
768
+ if (!key.tenantId || key.tenantId !== scope.tenantId) {
769
+ return { isSuperAdmin: false, features: [], organizations: [] };
770
+ }
771
+ const owner = await findOneWithDecryption(em, User, {
772
+ id: key.sessionUserId,
773
+ tenantId: key.tenantId,
774
+ deletedAt: null
775
+ }, void 0, scope);
776
+ if (!owner) return { isSuperAdmin: false, features: [], organizations: [] };
777
+ const ownerAcl = uncached ? await this.loadAclWithEntityManager(em, key.sessionUserId, scope) : await this.loadAcl(key.sessionUserId, scope);
778
+ const result = intersectDelegatedAcl(keyAcl, ownerAcl);
779
+ if (scope.organizationId && result.organizations && !result.organizations.includes(scope.organizationId)) {
780
+ return { ...result, isSuperAdmin: false, features: [] };
781
+ }
782
+ return result;
783
+ }
741
784
  async resolveFeatureOrganizationAccess(userId, required, input) {
742
785
  const allowAll = (organizations) => normalizeFeatureOrganizationCandidates(organizations).map((organization) => organization.id);
743
786
  const denyAll = () => [];
@@ -764,11 +807,12 @@ class RbacService {
764
807
  if (grants.kind === "none") return { unrestricted: false, filterOrganizationIds: denyAll };
765
808
  if (grants.kind === "api_key") {
766
809
  const keyOrganizationId = grants.organizationId;
767
- const unrestricted = keyOrganizationId === null && roleAclsAuthorizeFeaturesGlobally(grants.roleGrants, required, true);
810
+ const ownerAccess = grants.ownerId ? await this.resolveFeatureOrganizationAccess(grants.ownerId, required, input) : { unrestricted: true, filterOrganizationIds: allowAll };
811
+ const unrestricted = keyOrganizationId === null && ownerAccess.unrestricted && roleAclsAuthorizeFeaturesGlobally(grants.roleGrants, required, true);
768
812
  if (unrestricted) return { unrestricted: true, filterOrganizationIds: allowAll };
769
813
  return {
770
814
  unrestricted: false,
771
- filterOrganizationIds: (candidates) => normalizeFeatureOrganizationCandidates(candidates).filter((organization) => (!keyOrganizationId || organization.id === keyOrganizationId) && roleAclsAuthorizeFeatures(grants.roleGrants, required, organization, true)).map((organization) => organization.id)
815
+ filterOrganizationIds: (candidates) => normalizeFeatureOrganizationCandidates(candidates).filter((organization) => ownerAccess.filterOrganizationIds([organization]).length > 0).filter((organization) => (!keyOrganizationId || organization.id === keyOrganizationId) && roleAclsAuthorizeFeatures(grants.roleGrants, required, organization, true)).map((organization) => organization.id)
772
816
  };
773
817
  }
774
818
  if (grants.kind === "user_acl") {
@@ -802,7 +846,7 @@ class RbacService {
802
846
  };
803
847
  }
804
848
  async loadFeatureOrganizationGrants(userId, tenantId) {
805
- const cacheKey = `rbac:feature-organizations:${userId}:${tenantId}`;
849
+ const cacheKey = `rbac:feature-organizations:delegated-v1:${userId}:${tenantId}`;
806
850
  const key = userId.startsWith("api_key:") ? await this.em.fork().findOne(ApiKey, { id: userId.slice("api_key:".length), deletedAt: null }) : void 0;
807
851
  const cacheable = key === void 0 || key !== null && !isSessionBoundApiKey(key);
808
852
  if (this.cache && cacheable) {
@@ -813,7 +857,10 @@ class RbacService {
813
857
  if (this.cache && cacheable) {
814
858
  await this.cache.set(cacheKey, grants, {
815
859
  ttl: this.cacheTtlMs,
816
- tags: this.buildCacheTags(userId, { tenantId, organizationId: null })
860
+ tags: [
861
+ ...this.buildCacheTags(userId, { tenantId, organizationId: null }),
862
+ ...grants.kind === "api_key" && grants.ownerId ? [this.getUserTag(grants.ownerId)] : []
863
+ ]
817
864
  });
818
865
  }
819
866
  return grants;
@@ -826,6 +873,15 @@ class RbacService {
826
873
  if (!key || key.expiresAt && key.expiresAt.getTime() < Date.now() || key.tenantId && key.tenantId !== tenantId) {
827
874
  return { kind: "none" };
828
875
  }
876
+ if (key.sessionUserId) {
877
+ if (key.tenantId !== tenantId) return { kind: "none" };
878
+ const owner = await findOneWithDecryption(em, User, {
879
+ id: key.sessionUserId,
880
+ tenantId,
881
+ deletedAt: null
882
+ }, void 0, { tenantId, organizationId: null });
883
+ if (!owner) return { kind: "none" };
884
+ }
829
885
  const roleIds2 = await resolveApiKeyRoleIds(em, key, tenantId);
830
886
  if (!roleIds2.length) return { kind: "none" };
831
887
  const roleAcls2 = await em.find(RoleAcl, {
@@ -837,6 +893,7 @@ class RbacService {
837
893
  return {
838
894
  kind: "api_key",
839
895
  organizationId: keyOrganizationId,
896
+ ownerId: key.sessionUserId ?? null,
840
897
  roleGrants: roleAcls2.map(toFeatureOrganizationRoleGrant)
841
898
  };
842
899
  }