@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.
- package/dist/modules/api_keys/data/entities.js.map +1 -1
- package/dist/modules/api_keys/services/apiKeyService.js +1 -0
- package/dist/modules/api_keys/services/apiKeyService.js.map +2 -2
- package/dist/modules/auth/lib/authorizationStateLocks.js +13 -5
- package/dist/modules/auth/lib/authorizationStateLocks.js.map +2 -2
- package/dist/modules/auth/services/rbacService.js +68 -11
- package/dist/modules/auth/services/rbacService.js.map +2 -2
- package/dist/modules/directory/utils/organizationScope.js +6 -3
- package/dist/modules/directory/utils/organizationScope.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/api_keys/data/entities.ts +1 -1
- package/src/modules/api_keys/services/apiKeyService.ts +2 -0
- package/src/modules/auth/lib/authorizationStateLocks.ts +13 -7
- package/src/modules/auth/services/rbacService.ts +86 -9
- package/src/modules/directory/utils/organizationScope.ts +6 -3
|
@@ -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 /**
|
|
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": "AAAA,SAAS,aAAa,kBAAkB;AAExC,SAAS,MAAM,eAAe;AAE9B,SAAS,cAAc;AACvB,SAAS,kBAAkB,6BAA6B;AACxD,SAAS,mBAAmB,mBAAmB,iCAAiC;AAChF,SAAS,gCAAgC;AACzC,SAAS,6BAA6B;AACtC,SAAS,oBAAoB;AAC7B,SAAS,uBAAuB;AAChC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAEP,MAAM,SAAS,aAAa,UAAU,EAAE,MAAM,EAAE,WAAW,kBAAkB,CAAC;AAE9E,MAAM,cAAc;AAkBpB,eAAe,qBACb,QACA,UACwB;AACxB,MAAI,CAAC,SAAU,QAAO;AAEtB,QAAM,MAAM,iBAAiB;AAC7B,QAAM,OAAO,sBAAsB,GAAG;AACtC,MAAI,SAAS,WAAY,QAAO;AAChC,MAAI,SAAS,eAAe;AAC1B,WAAO;AAAA,MACL;AAAA,MAEA,EAAE,SAAS;AAAA,IACb;AACA,WAAO;AAAA,EACT;AAEA,QAAM,MAAM,MAAM,IAAI,aAAa,QAAQ;AAC3C,MAAI,CAAC,KAAK;AAER,UAAM,UAAU,MAAM,IAAI,gBAAgB,QAAQ;AAClD,QAAI,CAAC,QAAS,QAAO;AACrB,UAAMA,aAAY,kBAAkB,QAAQ,QAAQ,GAAG;AACvD,WAAOA,WAAU;AAAA,EACnB;AAEA,QAAM,YAAY,kBAAkB,QAAQ,IAAI,GAAG;AACnD,SAAO,UAAU;AACnB;AAMA,eAAe,qBACb,QACA,UACwB;AACxB,MAAI,CAAC,YAAY,CAAC,OAAQ,QAAO;AAEjC,QAAM,MAAM,iBAAiB;AAC7B,QAAM,OAAO,sBAAsB,GAAG;AACtC,MAAI,SAAS,YAAY;AAGvB,WAAO,0BAA0B,MAAM,IAAI,OAAO;AAAA,EACpD;AACA,MAAI,SAAS,eAAe;AAC1B,WAAO,KAAK,4FAA4F,EAAE,SAAS,CAAC;AACpH,WAAO;AAAA,EACT;AAMA,MAAI,CAAC,0BAA0B,MAAM,EAAG,QAAO;AAE/C,QAAM,MAAM,MAAM,IAAI,aAAa,QAAQ;AAC3C,MAAI,CAAC,IAAK,QAAO;AAEjB,SAAO,kBAAkB,QAAQ,IAAI,GAAG;AAC1C;
|
|
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
|
|
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 =
|
|
155
|
-
const beforeRoles =
|
|
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,
|
|
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
|
-
|
|
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
|
|
435
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
}
|