@open-mercato/core 0.4.2-canary-7732371765 → 0.4.2-canary-15e78de280
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/generated/entities/api_key/index.js +2 -0
- package/dist/generated/entities/api_key/index.js.map +2 -2
- package/dist/modules/api_keys/data/entities.js +3 -0
- package/dist/modules/api_keys/data/entities.js.map +2 -2
- package/dist/modules/api_keys/migrations/Migration20260125204102.js +13 -0
- package/dist/modules/api_keys/migrations/Migration20260125204102.js.map +7 -0
- package/dist/modules/api_keys/services/apiKeyService.js +41 -0
- package/dist/modules/api_keys/services/apiKeyService.js.map +3 -3
- package/dist/modules/auth/services/rbacService.js.map +2 -2
- package/generated/entities/api_key/index.ts +1 -0
- package/package.json +2 -2
- package/src/modules/api_keys/data/entities.ts +4 -0
- package/src/modules/api_keys/migrations/.snapshot-open-mercato.json +9 -0
- package/src/modules/api_keys/migrations/Migration20260125204102.ts +13 -0
- package/src/modules/api_keys/services/apiKeyService.ts +85 -0
- package/src/modules/auth/services/rbacService.ts +1 -1
|
@@ -9,6 +9,7 @@ const roles_json = "roles_json";
|
|
|
9
9
|
const created_by = "created_by";
|
|
10
10
|
const session_token = "session_token";
|
|
11
11
|
const session_user_id = "session_user_id";
|
|
12
|
+
const session_secret_encrypted = "session_secret_encrypted";
|
|
12
13
|
const last_used_at = "last_used_at";
|
|
13
14
|
const expires_at = "expires_at";
|
|
14
15
|
const created_at = "created_at";
|
|
@@ -27,6 +28,7 @@ export {
|
|
|
27
28
|
name,
|
|
28
29
|
organization_id,
|
|
29
30
|
roles_json,
|
|
31
|
+
session_secret_encrypted,
|
|
30
32
|
session_token,
|
|
31
33
|
session_user_id,
|
|
32
34
|
tenant_id,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../generated/entities/api_key/index.ts"],
|
|
4
|
-
"sourcesContent": ["export const id = 'id'\nexport const name = 'name'\nexport const description = 'description'\nexport const tenant_id = 'tenant_id'\nexport const organization_id = 'organization_id'\nexport const key_hash = 'key_hash'\nexport const key_prefix = 'key_prefix'\nexport const roles_json = 'roles_json'\nexport const created_by = 'created_by'\nexport const session_token = 'session_token'\nexport const session_user_id = 'session_user_id'\nexport const last_used_at = 'last_used_at'\nexport const expires_at = 'expires_at'\nexport const created_at = 'created_at'\nexport const updated_at = 'updated_at'\nexport const deleted_at = 'deleted_at'\n"],
|
|
5
|
-
"mappings": "AAAO,MAAM,KAAK;AACX,MAAM,OAAO;AACb,MAAM,cAAc;AACpB,MAAM,YAAY;AAClB,MAAM,kBAAkB;AACxB,MAAM,WAAW;AACjB,MAAM,aAAa;AACnB,MAAM,aAAa;AACnB,MAAM,aAAa;AACnB,MAAM,gBAAgB;AACtB,MAAM,kBAAkB;AACxB,MAAM,eAAe;AACrB,MAAM,aAAa;AACnB,MAAM,aAAa;AACnB,MAAM,aAAa;AACnB,MAAM,aAAa;",
|
|
4
|
+
"sourcesContent": ["export const id = 'id'\nexport const name = 'name'\nexport const description = 'description'\nexport const tenant_id = 'tenant_id'\nexport const organization_id = 'organization_id'\nexport const key_hash = 'key_hash'\nexport const key_prefix = 'key_prefix'\nexport const roles_json = 'roles_json'\nexport const created_by = 'created_by'\nexport const session_token = 'session_token'\nexport const session_user_id = 'session_user_id'\nexport const session_secret_encrypted = 'session_secret_encrypted'\nexport const last_used_at = 'last_used_at'\nexport const expires_at = 'expires_at'\nexport const created_at = 'created_at'\nexport const updated_at = 'updated_at'\nexport const deleted_at = 'deleted_at'\n"],
|
|
5
|
+
"mappings": "AAAO,MAAM,KAAK;AACX,MAAM,OAAO;AACb,MAAM,cAAc;AACpB,MAAM,YAAY;AAClB,MAAM,kBAAkB;AACxB,MAAM,WAAW;AACjB,MAAM,aAAa;AACnB,MAAM,aAAa;AACnB,MAAM,aAAa;AACnB,MAAM,gBAAgB;AACtB,MAAM,kBAAkB;AACxB,MAAM,2BAA2B;AACjC,MAAM,eAAe;AACrB,MAAM,aAAa;AACnB,MAAM,aAAa;AACnB,MAAM,aAAa;AACnB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -47,6 +47,9 @@ __decorateClass([
|
|
|
47
47
|
__decorateClass([
|
|
48
48
|
Property({ name: "session_user_id", type: "uuid", nullable: true })
|
|
49
49
|
], ApiKey.prototype, "sessionUserId", 2);
|
|
50
|
+
__decorateClass([
|
|
51
|
+
Property({ name: "session_secret_encrypted", type: "text", nullable: true })
|
|
52
|
+
], ApiKey.prototype, "sessionSecretEncrypted", 2);
|
|
50
53
|
__decorateClass([
|
|
51
54
|
Property({ name: "last_used_at", type: Date, nullable: true })
|
|
52
55
|
], ApiKey.prototype, "lastUsedAt", 2);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/api_keys/data/entities.ts"],
|
|
4
|
-
"sourcesContent": ["import { Entity, PrimaryKey, Property, Unique } from '@mikro-orm/core'\n\n@Entity({ tableName: 'api_keys' })\n@Unique({ properties: ['keyPrefix'] })\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 /** Session token for ephemeral session-scoped keys (used by AI chat) */\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 @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
|
-
"mappings": ";;;;;;;;;;AAAA,SAAS,QAAQ,YAAY,UAAU,cAAc;AAI9C,IAAM,SAAN,MAAa;AAAA,EAAb;
|
|
4
|
+
"sourcesContent": ["import { Entity, PrimaryKey, Property, Unique } from '@mikro-orm/core'\n\n@Entity({ tableName: 'api_keys' })\n@Unique({ properties: ['keyPrefix'] })\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 /** Session token for ephemeral session-scoped keys (used by AI chat) */\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 @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
|
+
"mappings": ";;;;;;;;;;AAAA,SAAS,QAAQ,YAAY,UAAU,cAAc;AAI9C,IAAM,SAAN,MAAa;AAAA,EAAb;AA+CL,qBAAkB,oBAAI,KAAK;AAAA;AAO7B;AApDE;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;AAIA;AAAA,EADC,SAAS,EAAE,MAAM,iBAAiB,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GA7BtD,OA8BX;AAIA;AAAA,EADC,SAAS,EAAE,MAAM,mBAAmB,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAjCxD,OAkCX;AAIA;AAAA,EADC,SAAS,EAAE,MAAM,4BAA4B,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GArCjE,OAsCX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,gBAAgB,MAAM,MAAM,UAAU,KAAK,CAAC;AAAA,GAxCnD,OAyCX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,MAAM,UAAU,KAAK,CAAC;AAAA,GA3CjD,OA4CX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,MAAM,UAAU,MAAM,oBAAI,KAAK,EAAE,CAAC;AAAA,GA9C7D,OA+CX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,MAAM,UAAU,MAAM,oBAAI,KAAK,GAAG,UAAU,KAAK,CAAC;AAAA,GAjD7E,OAkDX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,MAAM,UAAU,KAAK,CAAC;AAAA,GApDjD,OAqDX;AArDW,SAAN;AAAA,EAFN,OAAO,EAAE,WAAW,WAAW,CAAC;AAAA,EAChC,OAAO,EAAE,YAAY,CAAC,WAAW,EAAE,CAAC;AAAA,GACxB;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Migration } from "@mikro-orm/migrations";
|
|
2
|
+
class Migration20260125204102 extends Migration {
|
|
3
|
+
async up() {
|
|
4
|
+
this.addSql(`alter table "api_keys" add column "session_secret_encrypted" text null;`);
|
|
5
|
+
}
|
|
6
|
+
async down() {
|
|
7
|
+
this.addSql(`alter table "api_keys" drop column "session_secret_encrypted";`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export {
|
|
11
|
+
Migration20260125204102
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=Migration20260125204102.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/api_keys/migrations/Migration20260125204102.ts"],
|
|
4
|
+
"sourcesContent": ["import { Migration } from '@mikro-orm/migrations';\n\nexport class Migration20260125204102 extends Migration {\n\n override async up(): Promise<void> {\n this.addSql(`alter table \"api_keys\" add column \"session_secret_encrypted\" text null;`);\n }\n\n override async down(): Promise<void> {\n this.addSql(`alter table \"api_keys\" drop column \"session_secret_encrypted\";`);\n }\n\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,iBAAiB;AAEnB,MAAM,gCAAgC,UAAU;AAAA,EAErD,MAAe,KAAoB;AACjC,SAAK,OAAO,yEAAyE;AAAA,EACvF;AAAA,EAEA,MAAe,OAAsB;AACnC,SAAK,OAAO,gEAAgE;AAAA,EAC9E;AAEF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,7 +1,31 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { hash, compare } from "bcryptjs";
|
|
3
3
|
import { ApiKey } from "../data/entities.js";
|
|
4
|
+
import { createKmsService } from "@open-mercato/shared/lib/encryption/kms";
|
|
5
|
+
import { encryptWithAesGcm, decryptWithAesGcm } from "@open-mercato/shared/lib/encryption/aes";
|
|
4
6
|
const BCRYPT_COST = 10;
|
|
7
|
+
async function encryptSessionSecret(secret, tenantId) {
|
|
8
|
+
if (!tenantId) return null;
|
|
9
|
+
const kms = createKmsService();
|
|
10
|
+
if (!kms.isHealthy()) return null;
|
|
11
|
+
const dek = await kms.getTenantDek(tenantId);
|
|
12
|
+
if (!dek) {
|
|
13
|
+
const created = await kms.createTenantDek(tenantId);
|
|
14
|
+
if (!created) return null;
|
|
15
|
+
const encrypted2 = encryptWithAesGcm(secret, created.key);
|
|
16
|
+
return encrypted2.value;
|
|
17
|
+
}
|
|
18
|
+
const encrypted = encryptWithAesGcm(secret, dek.key);
|
|
19
|
+
return encrypted.value;
|
|
20
|
+
}
|
|
21
|
+
async function decryptSessionSecret(encrypted, tenantId) {
|
|
22
|
+
if (!tenantId || !encrypted) return null;
|
|
23
|
+
const kms = createKmsService();
|
|
24
|
+
if (!kms.isHealthy()) return null;
|
|
25
|
+
const dek = await kms.getTenantDek(tenantId);
|
|
26
|
+
if (!dek) return null;
|
|
27
|
+
return decryptWithAesGcm(encrypted, dek.key);
|
|
28
|
+
}
|
|
5
29
|
function generateApiKeySecret() {
|
|
6
30
|
const short = randomBytes(4).toString("hex");
|
|
7
31
|
const body = randomBytes(24).toString("hex");
|
|
@@ -64,6 +88,7 @@ async function createSessionApiKey(em, input) {
|
|
|
64
88
|
const ttl = input.ttlMinutes ?? 30;
|
|
65
89
|
const expiresAt = new Date(Date.now() + ttl * 60 * 1e3);
|
|
66
90
|
const keyHash = await hashApiKey(secret);
|
|
91
|
+
const encryptedSecret = await encryptSessionSecret(secret, input.tenantId ?? null);
|
|
67
92
|
const record = em.create(ApiKey, {
|
|
68
93
|
name: `__session_${input.sessionToken}__`,
|
|
69
94
|
description: "Ephemeral session API key for AI chat",
|
|
@@ -75,6 +100,7 @@ async function createSessionApiKey(em, input) {
|
|
|
75
100
|
createdBy: input.userId,
|
|
76
101
|
sessionToken: input.sessionToken,
|
|
77
102
|
sessionUserId: input.userId,
|
|
103
|
+
sessionSecretEncrypted: encryptedSecret,
|
|
78
104
|
expiresAt,
|
|
79
105
|
createdAt: /* @__PURE__ */ new Date()
|
|
80
106
|
});
|
|
@@ -95,6 +121,20 @@ async function findApiKeyBySessionToken(em, sessionToken) {
|
|
|
95
121
|
if (record.expiresAt && record.expiresAt.getTime() < Date.now()) return null;
|
|
96
122
|
return record;
|
|
97
123
|
}
|
|
124
|
+
async function findSessionApiKeyWithSecret(em, sessionToken) {
|
|
125
|
+
const record = await findApiKeyBySessionToken(em, sessionToken);
|
|
126
|
+
if (!record) return null;
|
|
127
|
+
if (!record.sessionSecretEncrypted) {
|
|
128
|
+
console.warn("[ApiKeyService] Session key has no encrypted secret:", sessionToken.slice(0, 12));
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const secret = await decryptSessionSecret(record.sessionSecretEncrypted, record.tenantId ?? null);
|
|
132
|
+
if (!secret) {
|
|
133
|
+
console.warn("[ApiKeyService] Failed to decrypt session secret:", sessionToken.slice(0, 12));
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return { key: record, secret };
|
|
137
|
+
}
|
|
98
138
|
async function deleteSessionApiKey(em, sessionToken) {
|
|
99
139
|
const record = await em.findOne(ApiKey, { sessionToken, deletedAt: null });
|
|
100
140
|
if (!record) return;
|
|
@@ -125,6 +165,7 @@ export {
|
|
|
125
165
|
deleteSessionApiKey,
|
|
126
166
|
findApiKeyBySecret,
|
|
127
167
|
findApiKeyBySessionToken,
|
|
168
|
+
findSessionApiKeyWithSecret,
|
|
128
169
|
generateApiKeySecret,
|
|
129
170
|
generateSessionToken,
|
|
130
171
|
hashApiKey,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/api_keys/services/apiKeyService.ts"],
|
|
4
|
-
"sourcesContent": ["import { randomBytes } 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 { Role } from '@open-mercato/core/modules/auth/data/entities'\nimport { ApiKey } from '../data/entities'\n\nconst BCRYPT_COST = 10\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: { rbac?: RbacService } = {},\n): Promise<ApiKeyWithSecret> {\n const { secret, prefix } = generateApiKeySecret()\n const keyHash = await hashApiKey(secret)\n const 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: Array.isArray(input.roles) ? input.roles : [],\n createdBy: input.createdBy ?? null,\n expiresAt: input.expiresAt ?? null,\n createdAt: new Date(),\n })\n await em.persistAndFlush(record)\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 } = {},\n): Promise<void> {\n const record = await em.findOne(ApiKey, { id })\n if (!record) return\n record.deletedAt = new Date()\n await em.persistAndFlush(record)\n if (opts.rbac) {\n await opts.rbac.invalidateUserCache(`api_key:${record.id}`)\n }\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)\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 * 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 */\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 const record = em.create(ApiKey, {\n name: `__session_${input.sessionToken}__`,\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: input.userRoles,\n createdBy: input.userId,\n sessionToken: input.sessionToken,\n sessionUserId: input.userId,\n expiresAt,\n createdAt: new Date(),\n })\n\n await em.persistAndFlush(record)\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 const record = await em.findOne(ApiKey, {\n sessionToken,\n deletedAt: null,\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 * Delete an ephemeral API key by its session token.\n */\nexport async function deleteSessionApiKey(\n em: EntityManager,\n sessionToken: string\n): Promise<void> {\n const record = await em.findOne(ApiKey, { sessionToken, deletedAt: null })\n if (!record) return\n\n record.deletedAt = new Date()\n await em.persistAndFlush(record)\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 */\nexport async function withOnetimeApiKey<T>(\n em: EntityManager,\n input: CreateApiKeyInput,\n fn: (secret: string) => Promise<T>\n): Promise<T> {\n const { record, secret } = await createApiKey(em, {\n ...input,\n name: input.name || '__onetime__',\n description: input.description || 'One-time API key',\n })\n\n try {\n // Execute the function with the API key\n const result = await fn(secret)\n return result\n } finally {\n // Always delete the API key, even if the function throws\n try {\n await em.removeAndFlush(record)\n } catch (error) {\n // Log but don't throw - we don't want cleanup errors to mask the original error\n console.error('[withOnetimeApiKey] Failed to delete one-time API key:', error)\n }\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,mBAAmB;AAE5B,SAAS,MAAM,eAAe;AAG9B,SAAS,cAAc;
|
|
6
|
-
"names": []
|
|
4
|
+
"sourcesContent": ["import { randomBytes } 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 { Role } from '@open-mercato/core/modules/auth/data/entities'\nimport { ApiKey } from '../data/entities'\nimport { createKmsService } from '@open-mercato/shared/lib/encryption/kms'\nimport { encryptWithAesGcm, decryptWithAesGcm } from '@open-mercato/shared/lib/encryption/aes'\n\nconst BCRYPT_COST = 10\n\n// =============================================================================\n// Session Secret Encryption Helpers\n// =============================================================================\n\n/**\n * Encrypt an API key secret for storage.\n * Uses tenant-specific DEK if available, otherwise returns null.\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 if (!kms.isHealthy()) return null\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 * Decrypt an API key secret from storage.\n * Returns null if decryption fails or no DEK available.\n */\nasync function decryptSessionSecret(\n encrypted: string,\n tenantId: string | null\n): Promise<string | null> {\n if (!tenantId || !encrypted) return null\n\n const kms = createKmsService()\n if (!kms.isHealthy()) return null\n\n const dek = await kms.getTenantDek(tenantId)\n if (!dek) return null\n\n return decryptWithAesGcm(encrypted, 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: { rbac?: RbacService } = {},\n): Promise<ApiKeyWithSecret> {\n const { secret, prefix } = generateApiKeySecret()\n const keyHash = await hashApiKey(secret)\n const 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: Array.isArray(input.roles) ? input.roles : [],\n createdBy: input.createdBy ?? null,\n expiresAt: input.expiresAt ?? null,\n createdAt: new Date(),\n })\n await em.persistAndFlush(record)\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 } = {},\n): Promise<void> {\n const record = await em.findOne(ApiKey, { id })\n if (!record) return\n record.deletedAt = new Date()\n await em.persistAndFlush(record)\n if (opts.rbac) {\n await opts.rbac.invalidateUserCache(`api_key:${record.id}`)\n }\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)\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 * 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 const record = em.create(ApiKey, {\n name: `__session_${input.sessionToken}__`,\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: input.userRoles,\n createdBy: input.userId,\n sessionToken: input.sessionToken,\n sessionUserId: input.userId,\n sessionSecretEncrypted: encryptedSecret,\n expiresAt,\n createdAt: new Date(),\n })\n\n await em.persistAndFlush(record)\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 const record = await em.findOne(ApiKey, {\n sessionToken,\n deletedAt: null,\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 console.warn('[ApiKeyService] Session key has no encrypted secret:', sessionToken.slice(0, 12))\n return null\n }\n\n // Decrypt the secret\n const secret = await decryptSessionSecret(record.sessionSecretEncrypted, record.tenantId ?? null)\n if (!secret) {\n console.warn('[ApiKeyService] Failed to decrypt session secret:', sessionToken.slice(0, 12))\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 const record = await em.findOne(ApiKey, { sessionToken, deletedAt: null })\n if (!record) return\n\n record.deletedAt = new Date()\n await em.persistAndFlush(record)\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 */\nexport async function withOnetimeApiKey<T>(\n em: EntityManager,\n input: CreateApiKeyInput,\n fn: (secret: string) => Promise<T>\n): Promise<T> {\n const { record, secret } = await createApiKey(em, {\n ...input,\n name: input.name || '__onetime__',\n description: input.description || 'One-time API key',\n })\n\n try {\n // Execute the function with the API key\n const result = await fn(secret)\n return result\n } finally {\n // Always delete the API key, even if the function throws\n try {\n await em.removeAndFlush(record)\n } catch (error) {\n // Log but don't throw - we don't want cleanup errors to mask the original error\n console.error('[withOnetimeApiKey] Failed to delete one-time API key:', error)\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["encrypted"]
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/auth/services/rbacService.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport { getCurrentCacheTenant, runWithCacheTenant } from '@open-mercato/cache'\nimport { UserAcl, RoleAcl, User, UserRole } from '@open-mercato/core/modules/auth/data/entities'\nimport { ApiKey } from '@open-mercato/core/modules/api_keys/data/entities'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\n\ninterface AclData {\n isSuperAdmin: boolean\n features: string[]\n organizations: string[] | null\n}\n\nfunction isAclData(value: unknown): value is AclData {\n if (typeof value !== 'object' || value === null) return false\n const record = value as Partial<AclData>\n if (typeof record.isSuperAdmin !== 'boolean') return false\n if (!Array.isArray(record.features) || record.features.some((feature) => typeof feature !== 'string')) return false\n if (record.organizations !== null && record.organizations !== undefined) {\n if (!Array.isArray(record.organizations)) return false\n if (record.organizations.some((org) => typeof org !== 'string')) return false\n }\n return true\n}\n\nexport class RbacService {\n private cacheTtlMs: number = 5 * 60 * 1000 // 5 minutes default\n private cache: CacheStrategy | null = null\n private globalSuperAdminCache = new Map<string, boolean>()\n\n constructor(private em: EntityManager, cache?: CacheStrategy) {\n this.cache = cache || null\n }\n\n /**\n * Set cache TTL in milliseconds\n * @param ttlMs - Time to live in milliseconds\n */\n setCacheTtl(ttlMs: number) {\n this.cacheTtlMs = ttlMs\n }\n\n /**\n * Checks if a required feature is satisfied by a granted feature permission.\n * \n * Wildcard patterns:\n * - `*` (global wildcard): Grants access to all features\n * - `prefix.*` (module wildcard): Grants access to all features starting with `prefix.`\n * and also the exact prefix itself (e.g., `entities.*` matches both `entities` and `entities.records.view`)\n * - Exact match: Feature must match exactly (e.g., `users.view` only matches `users.view`)\n * \n * @param required - The feature being requested (e.g., 'entities.records.view')\n * @param granted - The feature permission granted (e.g., 'entities.*' or '*')\n * @returns true if the granted permission satisfies the required feature\n * \n * @example\n * matchFeature('users.view', '*') // true - global wildcard\n * matchFeature('entities.records.view', 'entities.*') // true - module wildcard\n * matchFeature('entities', 'entities.*') // true - exact prefix match\n * matchFeature('users.view', 'entities.*') // false - different module\n * matchFeature('users.view', 'users.view') // true - exact match\n */\n private matchFeature(required: string, granted: string): boolean {\n if (granted === '*') return true\n if (granted.endsWith('.*')) {\n const prefix = granted.slice(0, -2)\n return required === prefix || required.startsWith(prefix + '.')\n }\n return granted === required\n }\n\n private hasAllFeatures(required: string[], granted: string[]): boolean {\n if (!required.length) return true\n if (!granted.length) return false\n return required.every((req) => granted.some((g) => this.matchFeature(req, g)))\n }\n\n private getCacheKey(userId: string, scope: { tenantId: string | null; organizationId: string | null }): string {\n return `rbac:${userId}:${scope.tenantId || 'null'}:${scope.organizationId || 'null'}`\n }\n\n private getUserTag(userId: string): string {\n return `rbac:user:${userId}`\n }\n\n private getTenantTag(tenantId: string): string {\n return `rbac:tenant:${tenantId}`\n }\n\n private getOrganizationTag(organizationId: string): string {\n return `rbac:org:${organizationId}`\n }\n\n private async getFromCache(cacheKey: string): Promise<AclData | null> {\n if (!this.cache) return null\n const cached = await this.cache.get(cacheKey)\n if (!cached) return null\n return isAclData(cached) ? cached : null\n }\n\n private async setCache(cacheKey: string, data: AclData, userId: string, scope: { tenantId: string | null; organizationId: string | null }): Promise<void> {\n if (!this.cache) return\n\n const tags = [\n this.getUserTag(userId),\n 'rbac:all'\n ]\n\n if (scope.tenantId) {\n tags.push(this.getTenantTag(scope.tenantId))\n }\n\n if (scope.organizationId) {\n tags.push(this.getOrganizationTag(scope.organizationId))\n }\n\n await this.cache.set(cacheKey, data, {\n ttl: this.cacheTtlMs,\n tags\n })\n }\n\n /**\n * Invalidates cached ACL data for a specific user across all tenants and organizations.\n * Call this when a user's roles or user-specific ACL is modified.\n * \n * @param userId - The ID of the user whose cache should be invalidated\n */\n async invalidateUserCache(userId: string): Promise<void> {\n this.globalSuperAdminCache.delete(userId)\n await this.deleteCacheByTags([this.getUserTag(userId)])\n }\n\n /**\n * Invalidates cached ACL data for all users within a specific tenant.\n * Call this when a role's ACL is modified, since roles are tenant-scoped\n * and affect all users in that tenant who have that role.\n * \n * @param tenantId - The ID of the tenant whose cache should be invalidated\n */\n async invalidateTenantCache(tenantId: string): Promise<void> {\n this.globalSuperAdminCache.clear()\n await this.deleteCacheByTags([this.getTenantTag(tenantId)], [tenantId])\n }\n\n /**\n * Invalidates cached ACL data for all users within a specific organization.\n * Call this when organization-level permissions or visibility changes.\n * \n * @param organizationId - The ID of the organization whose cache should be invalidated\n */\n async invalidateOrganizationCache(organizationId: string): Promise<void> {\n await this.deleteCacheByTags([this.getOrganizationTag(organizationId)])\n }\n\n /**\n * Clears all cached ACL data.\n * Use this for bulk operations or system-wide ACL changes.\n */\n async invalidateAllCache(): Promise<void> {\n this.globalSuperAdminCache.clear()\n await this.deleteCacheByTags(['rbac:all'])\n }\n\n private async deleteCacheByTags(tags: string[], tenantHints?: Array<string | null>): Promise<void> {\n if (!this.cache) return\n const contexts = new Set<string | null>()\n const current = getCurrentCacheTenant()\n contexts.add(current ?? null)\n contexts.add(null)\n if (Array.isArray(tenantHints)) {\n for (const hint of tenantHints) {\n contexts.add(hint ?? null)\n }\n }\n for (const ctx of contexts) {\n if (ctx === current) {\n await this.cache.deleteByTags(tags)\n } else {\n await runWithCacheTenant(ctx, async () => {\n await this.cache!.deleteByTags(tags)\n })\n }\n }\n }\n\n private async isGlobalSuperAdmin(userId: string): Promise<boolean> {\n if (this.globalSuperAdminCache.has(userId)) return this.globalSuperAdminCache.get(userId)!\n const em = this.em.fork()\n const userSuper = await em.findOne(UserAcl, { user: userId as any, isSuperAdmin: true })\n if (userSuper && (userSuper as any).isSuperAdmin) {\n this.globalSuperAdminCache.set(userId, true)\n return true\n }\n const links = await findWithDecryption(\n em,\n UserRole,\n { user: userId as any },\n { populate: ['role'] },\n { tenantId: null, organizationId: null },\n )\n const linkList = Array.isArray(links) ? links : []\n if (!linkList.length) {\n this.globalSuperAdminCache.set(userId, false)\n return false\n }\n const roleIds = Array.from(new Set(linkList.map((link) => {\n const role = link.role as any\n return role?.id ? String(role.id) : null\n }).filter((id): id is string => typeof id === 'string' && id.length > 0)))\n if (!roleIds.length) {\n this.globalSuperAdminCache.set(userId, false)\n return false\n }\n const roleSuper = await em.findOne(RoleAcl, { isSuperAdmin: true, role: { $in: roleIds as any } } as any)\n const result = !!(roleSuper && (roleSuper as any).isSuperAdmin)\n this.globalSuperAdminCache.set(userId, result)\n return result\n }\n\n /**\n * Loads the Access Control List (ACL) for a user within a given scope.\n * \n * The ACL resolution follows this priority:\n * 1. Per-user ACL (UserAcl) - if exists, use it exclusively\n * 2. Aggregated role ACLs (RoleAcl) - combine permissions from all user's roles\n * \n * Results are cached for performance (default 5 minutes TTL).\n * Cache is automatically invalidated when ACL-related data changes.\n * \n * @param userId - The ID of the user\n * @param scope - The tenant and organization context for ACL evaluation\n * @returns An object containing:\n * - isSuperAdmin: If true, user has unrestricted access to all features\n * - features: Array of feature strings (may include wildcards like 'entities.*')\n * - organizations: Array of organization IDs user can access, or null for all organizations\n * \n * @example\n * const acl = await rbacService.loadAcl('user-123', { tenantId: 'tenant-1', organizationId: 'org-1' })\n * // Returns: { isSuperAdmin: false, features: ['users.view', 'entities.*'], organizations: ['org-1', 'org-2'] }\n */\n async loadAcl(userId: string, scope: { tenantId: string | null; organizationId: string | null }): Promise<{\n isSuperAdmin: boolean\n features: string[]\n organizations: string[] | null\n }> {\n const cacheKey = this.getCacheKey(userId, scope)\n const cached = await this.getFromCache(cacheKey)\n if (cached) return cached\n\n if (!userId.startsWith('api_key:')) {\n if (await this.isGlobalSuperAdmin(userId)) {\n const result = { isSuperAdmin: true, features: ['*'], organizations: null }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n }\n\n if (userId.startsWith('api_key:')) {\n const apiKeyId = userId.slice('api_key:'.length)\n const em = this.em.fork()\n const key = await em.findOne(ApiKey, { id: apiKeyId, deletedAt: null })\n if (!key || (key.expiresAt && key.expiresAt.getTime() < Date.now())) {\n const result = { isSuperAdmin: false, features: [], organizations: null }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n const tenantId = scope.tenantId || key.tenantId || null\n const roleIds = Array.isArray(key.rolesJson) ? key.rolesJson.filter(Boolean) : []\n let isSuper = false\n const features: string[] = []\n let organizations: string[] | null = key.organizationId ? [key.organizationId] : null\n if (tenantId && roleIds.length) {\n const racls = await em.find(RoleAcl, { tenantId, role: { $in: roleIds as any } } as any)\n for (const acl of racls) {\n isSuper = isSuper || !!acl.isSuperAdmin\n if (Array.isArray(acl.featuresJson)) {\n for (const f of acl.featuresJson) if (!features.includes(f)) features.push(f)\n }\n if (organizations !== null) {\n if (acl.organizationsJson == null) {\n organizations = null\n } else {\n organizations = Array.from(new Set([...(organizations || []), ...acl.organizationsJson]))\n }\n }\n }\n }\n const result = { isSuperAdmin: isSuper, features, organizations }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n\n // Use a forked EntityManager to avoid inheriting an aborted transaction from callers\n const em = this.em.fork()\n const user = await em.findOne(User, { id: userId })\n if (!user) {\n const result = { isSuperAdmin: false, features: [], organizations: null }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n const tenantId = scope.tenantId || user.tenantId || null\n const orgId = scope.organizationId || user.organizationId || null\n\n if (!tenantId) {\n const result = { isSuperAdmin: false, features: [], organizations: null }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n\n // Per-user ACL first\n const uacl = await em.findOne(UserAcl, { user: userId as any, tenantId })\n if (uacl) {\n const result = {\n isSuperAdmin: !!uacl.isSuperAdmin,\n features: Array.isArray(uacl.featuresJson) ? (uacl.featuresJson as string[]) : [],\n organizations: Array.isArray(uacl.organizationsJson) ? (uacl.organizationsJson as string[]) : null,\n }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n\n // Aggregate role ACLs\n const links = await findWithDecryption(\n em,\n UserRole,\n { user: userId as any, role: { tenantId } } as any,\n { populate: ['role'] },\n { tenantId, organizationId: orgId },\n )\n const linkList = Array.isArray(links) ? links : []\n const roleIds = linkList.map((l) => (l.role as any)?.id).filter(Boolean)\n let isSuper = false\n const features: string[] = []\n let organizations: string[] | null = []\n if (roleIds.length) {\n const racls = await em.find(RoleAcl, { tenantId, role: { $in: roleIds as any } } as any, {})\n const roleAcls = Array.isArray(racls) ? racls : []\n for (const r of roleAcls) {\n isSuper = isSuper || !!r.isSuperAdmin\n if (Array.isArray(r.featuresJson)) for (const f of r.featuresJson) if (!features.includes(f)) features.push(f)\n if (organizations !== null) {\n if (r.organizationsJson == null) organizations = null\n else organizations = Array.from(new Set([...(organizations || []), ...r.organizationsJson]))\n }\n }\n }\n if (organizations && orgId && !organizations.includes(orgId)) {\n // Out-of-scope org; caller will enforce\n }\n const result = { isSuperAdmin: isSuper, features, organizations }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n\n /**\n * Checks if a user has all required features within a given scope.\n * \n * This is the primary authorization check method used throughout the application.\n * It combines feature checking with organization visibility validation.\n * \n * Authorization logic:\n * 1. No features required \u2192 always returns true\n * 2. User is super admin \u2192 always returns true\n * 3. Organization restriction check: If the user's ACL has a restricted organization list\n * and the requested organization is not in that list \u2192 returns false\n * 4. Feature matching: User must have all required features (supports wildcards)\n * \n * @param userId - The ID of the user\n * @param required - Array of feature strings to check (e.g., ['users.view', 'users.edit'])\n * @param scope - The tenant and organization context for authorization\n * @returns true if the user has all required features and organization access, false otherwise\n * \n * @example\n * // Check if user can view and edit users\n * const canManageUsers = await rbacService.userHasAllFeatures(\n * 'user-123',\n * ['users.view', 'users.edit'],\n * { tenantId: 'tenant-1', organizationId: 'org-1' }\n * )\n * \n * @example\n * // Check with wildcard features\n * const canAccessEntities = await rbacService.userHasAllFeatures(\n * 'user-123',\n * ['entities.records.view'],\n * { tenantId: 'tenant-1', organizationId: 'org-1' }\n * )\n * // Returns true if user has 'entities.*', '*', or 'entities.records.view'\n */\n async userHasAllFeatures(userId: string, required: string[], scope: { tenantId: string | null; organizationId: string | null }): Promise<boolean> {\n if (!required.length) return true\n const acl = await this.loadAcl(userId, scope)\n if (acl.isSuperAdmin) return true\n if (acl.organizations && scope.organizationId && !acl.organizations.includes(scope.organizationId)) return false\n return this.hasAllFeatures(required, acl.features)\n }\n}\n"],
|
|
5
|
-
"mappings": "AAEA,SAAS,uBAAuB,0BAA0B;AAC1D,SAAS,SAAS,SAAS,MAAM,gBAAgB;AACjD,SAAS,cAAc;AACvB,SAAS,0BAA0B;AAQnC,SAAS,UAAU,OAAkC;AACnD,MAAI,OAAO,UAAU,YAAY,UAAU,KAAM,QAAO;AACxD,QAAM,SAAS;AACf,MAAI,OAAO,OAAO,iBAAiB,UAAW,QAAO;AACrD,MAAI,CAAC,MAAM,QAAQ,OAAO,QAAQ,KAAK,OAAO,SAAS,KAAK,CAAC,YAAY,OAAO,YAAY,QAAQ,EAAG,QAAO;AAC9G,MAAI,OAAO,kBAAkB,QAAQ,OAAO,kBAAkB,QAAW;AACvE,QAAI,CAAC,MAAM,QAAQ,OAAO,aAAa,EAAG,QAAO;AACjD,QAAI,OAAO,cAAc,KAAK,CAAC,QAAQ,OAAO,QAAQ,QAAQ,EAAG,QAAO;AAAA,EAC1E;AACA,SAAO;AACT;AAEO,MAAM,YAAY;AAAA,EAKvB,YAAoB,IAAmB,OAAuB;AAA1C;AAJpB,SAAQ,aAAqB,IAAI,KAAK;AACtC;AAAA,SAAQ,QAA8B;AACtC,SAAQ,wBAAwB,oBAAI,IAAqB;AAGvD,SAAK,QAAQ,SAAS;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,YAAY,OAAe;AACzB,SAAK,aAAa;AAAA,EACpB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAsBQ,aAAa,UAAkB,SAA0B;AAC/D,QAAI,YAAY,IAAK,QAAO;AAC5B,QAAI,QAAQ,SAAS,IAAI,GAAG;AAC1B,YAAM,SAAS,QAAQ,MAAM,GAAG,EAAE;AAClC,aAAO,aAAa,UAAU,SAAS,WAAW,SAAS,GAAG;AAAA,IAChE;AACA,WAAO,YAAY;AAAA,EACrB;AAAA,
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport { getCurrentCacheTenant, runWithCacheTenant } from '@open-mercato/cache'\nimport { UserAcl, RoleAcl, User, UserRole } from '@open-mercato/core/modules/auth/data/entities'\nimport { ApiKey } from '@open-mercato/core/modules/api_keys/data/entities'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\n\ninterface AclData {\n isSuperAdmin: boolean\n features: string[]\n organizations: string[] | null\n}\n\nfunction isAclData(value: unknown): value is AclData {\n if (typeof value !== 'object' || value === null) return false\n const record = value as Partial<AclData>\n if (typeof record.isSuperAdmin !== 'boolean') return false\n if (!Array.isArray(record.features) || record.features.some((feature) => typeof feature !== 'string')) return false\n if (record.organizations !== null && record.organizations !== undefined) {\n if (!Array.isArray(record.organizations)) return false\n if (record.organizations.some((org) => typeof org !== 'string')) return false\n }\n return true\n}\n\nexport class RbacService {\n private cacheTtlMs: number = 5 * 60 * 1000 // 5 minutes default\n private cache: CacheStrategy | null = null\n private globalSuperAdminCache = new Map<string, boolean>()\n\n constructor(private em: EntityManager, cache?: CacheStrategy) {\n this.cache = cache || null\n }\n\n /**\n * Set cache TTL in milliseconds\n * @param ttlMs - Time to live in milliseconds\n */\n setCacheTtl(ttlMs: number) {\n this.cacheTtlMs = ttlMs\n }\n\n /**\n * Checks if a required feature is satisfied by a granted feature permission.\n * \n * Wildcard patterns:\n * - `*` (global wildcard): Grants access to all features\n * - `prefix.*` (module wildcard): Grants access to all features starting with `prefix.`\n * and also the exact prefix itself (e.g., `entities.*` matches both `entities` and `entities.records.view`)\n * - Exact match: Feature must match exactly (e.g., `users.view` only matches `users.view`)\n * \n * @param required - The feature being requested (e.g., 'entities.records.view')\n * @param granted - The feature permission granted (e.g., 'entities.*' or '*')\n * @returns true if the granted permission satisfies the required feature\n * \n * @example\n * matchFeature('users.view', '*') // true - global wildcard\n * matchFeature('entities.records.view', 'entities.*') // true - module wildcard\n * matchFeature('entities', 'entities.*') // true - exact prefix match\n * matchFeature('users.view', 'entities.*') // false - different module\n * matchFeature('users.view', 'users.view') // true - exact match\n */\n private matchFeature(required: string, granted: string): boolean {\n if (granted === '*') return true\n if (granted.endsWith('.*')) {\n const prefix = granted.slice(0, -2)\n return required === prefix || required.startsWith(prefix + '.')\n }\n return granted === required\n }\n\n public hasAllFeatures(required: string[], granted: string[]): boolean {\n if (!required.length) return true\n if (!granted.length) return false\n return required.every((req) => granted.some((g) => this.matchFeature(req, g)))\n }\n\n private getCacheKey(userId: string, scope: { tenantId: string | null; organizationId: string | null }): string {\n return `rbac:${userId}:${scope.tenantId || 'null'}:${scope.organizationId || 'null'}`\n }\n\n private getUserTag(userId: string): string {\n return `rbac:user:${userId}`\n }\n\n private getTenantTag(tenantId: string): string {\n return `rbac:tenant:${tenantId}`\n }\n\n private getOrganizationTag(organizationId: string): string {\n return `rbac:org:${organizationId}`\n }\n\n private async getFromCache(cacheKey: string): Promise<AclData | null> {\n if (!this.cache) return null\n const cached = await this.cache.get(cacheKey)\n if (!cached) return null\n return isAclData(cached) ? cached : null\n }\n\n private async setCache(cacheKey: string, data: AclData, userId: string, scope: { tenantId: string | null; organizationId: string | null }): Promise<void> {\n if (!this.cache) return\n\n const tags = [\n this.getUserTag(userId),\n 'rbac:all'\n ]\n\n if (scope.tenantId) {\n tags.push(this.getTenantTag(scope.tenantId))\n }\n\n if (scope.organizationId) {\n tags.push(this.getOrganizationTag(scope.organizationId))\n }\n\n await this.cache.set(cacheKey, data, {\n ttl: this.cacheTtlMs,\n tags\n })\n }\n\n /**\n * Invalidates cached ACL data for a specific user across all tenants and organizations.\n * Call this when a user's roles or user-specific ACL is modified.\n * \n * @param userId - The ID of the user whose cache should be invalidated\n */\n async invalidateUserCache(userId: string): Promise<void> {\n this.globalSuperAdminCache.delete(userId)\n await this.deleteCacheByTags([this.getUserTag(userId)])\n }\n\n /**\n * Invalidates cached ACL data for all users within a specific tenant.\n * Call this when a role's ACL is modified, since roles are tenant-scoped\n * and affect all users in that tenant who have that role.\n * \n * @param tenantId - The ID of the tenant whose cache should be invalidated\n */\n async invalidateTenantCache(tenantId: string): Promise<void> {\n this.globalSuperAdminCache.clear()\n await this.deleteCacheByTags([this.getTenantTag(tenantId)], [tenantId])\n }\n\n /**\n * Invalidates cached ACL data for all users within a specific organization.\n * Call this when organization-level permissions or visibility changes.\n * \n * @param organizationId - The ID of the organization whose cache should be invalidated\n */\n async invalidateOrganizationCache(organizationId: string): Promise<void> {\n await this.deleteCacheByTags([this.getOrganizationTag(organizationId)])\n }\n\n /**\n * Clears all cached ACL data.\n * Use this for bulk operations or system-wide ACL changes.\n */\n async invalidateAllCache(): Promise<void> {\n this.globalSuperAdminCache.clear()\n await this.deleteCacheByTags(['rbac:all'])\n }\n\n private async deleteCacheByTags(tags: string[], tenantHints?: Array<string | null>): Promise<void> {\n if (!this.cache) return\n const contexts = new Set<string | null>()\n const current = getCurrentCacheTenant()\n contexts.add(current ?? null)\n contexts.add(null)\n if (Array.isArray(tenantHints)) {\n for (const hint of tenantHints) {\n contexts.add(hint ?? null)\n }\n }\n for (const ctx of contexts) {\n if (ctx === current) {\n await this.cache.deleteByTags(tags)\n } else {\n await runWithCacheTenant(ctx, async () => {\n await this.cache!.deleteByTags(tags)\n })\n }\n }\n }\n\n private async isGlobalSuperAdmin(userId: string): Promise<boolean> {\n if (this.globalSuperAdminCache.has(userId)) return this.globalSuperAdminCache.get(userId)!\n const em = this.em.fork()\n const userSuper = await em.findOne(UserAcl, { user: userId as any, isSuperAdmin: true })\n if (userSuper && (userSuper as any).isSuperAdmin) {\n this.globalSuperAdminCache.set(userId, true)\n return true\n }\n const links = await findWithDecryption(\n em,\n UserRole,\n { user: userId as any },\n { populate: ['role'] },\n { tenantId: null, organizationId: null },\n )\n const linkList = Array.isArray(links) ? links : []\n if (!linkList.length) {\n this.globalSuperAdminCache.set(userId, false)\n return false\n }\n const roleIds = Array.from(new Set(linkList.map((link) => {\n const role = link.role as any\n return role?.id ? String(role.id) : null\n }).filter((id): id is string => typeof id === 'string' && id.length > 0)))\n if (!roleIds.length) {\n this.globalSuperAdminCache.set(userId, false)\n return false\n }\n const roleSuper = await em.findOne(RoleAcl, { isSuperAdmin: true, role: { $in: roleIds as any } } as any)\n const result = !!(roleSuper && (roleSuper as any).isSuperAdmin)\n this.globalSuperAdminCache.set(userId, result)\n return result\n }\n\n /**\n * Loads the Access Control List (ACL) for a user within a given scope.\n * \n * The ACL resolution follows this priority:\n * 1. Per-user ACL (UserAcl) - if exists, use it exclusively\n * 2. Aggregated role ACLs (RoleAcl) - combine permissions from all user's roles\n * \n * Results are cached for performance (default 5 minutes TTL).\n * Cache is automatically invalidated when ACL-related data changes.\n * \n * @param userId - The ID of the user\n * @param scope - The tenant and organization context for ACL evaluation\n * @returns An object containing:\n * - isSuperAdmin: If true, user has unrestricted access to all features\n * - features: Array of feature strings (may include wildcards like 'entities.*')\n * - organizations: Array of organization IDs user can access, or null for all organizations\n * \n * @example\n * const acl = await rbacService.loadAcl('user-123', { tenantId: 'tenant-1', organizationId: 'org-1' })\n * // Returns: { isSuperAdmin: false, features: ['users.view', 'entities.*'], organizations: ['org-1', 'org-2'] }\n */\n async loadAcl(userId: string, scope: { tenantId: string | null; organizationId: string | null }): Promise<{\n isSuperAdmin: boolean\n features: string[]\n organizations: string[] | null\n }> {\n const cacheKey = this.getCacheKey(userId, scope)\n const cached = await this.getFromCache(cacheKey)\n if (cached) return cached\n\n if (!userId.startsWith('api_key:')) {\n if (await this.isGlobalSuperAdmin(userId)) {\n const result = { isSuperAdmin: true, features: ['*'], organizations: null }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n }\n\n if (userId.startsWith('api_key:')) {\n const apiKeyId = userId.slice('api_key:'.length)\n const em = this.em.fork()\n const key = await em.findOne(ApiKey, { id: apiKeyId, deletedAt: null })\n if (!key || (key.expiresAt && key.expiresAt.getTime() < Date.now())) {\n const result = { isSuperAdmin: false, features: [], organizations: null }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n const tenantId = scope.tenantId || key.tenantId || null\n const roleIds = Array.isArray(key.rolesJson) ? key.rolesJson.filter(Boolean) : []\n let isSuper = false\n const features: string[] = []\n let organizations: string[] | null = key.organizationId ? [key.organizationId] : null\n if (tenantId && roleIds.length) {\n const racls = await em.find(RoleAcl, { tenantId, role: { $in: roleIds as any } } as any)\n for (const acl of racls) {\n isSuper = isSuper || !!acl.isSuperAdmin\n if (Array.isArray(acl.featuresJson)) {\n for (const f of acl.featuresJson) if (!features.includes(f)) features.push(f)\n }\n if (organizations !== null) {\n if (acl.organizationsJson == null) {\n organizations = null\n } else {\n organizations = Array.from(new Set([...(organizations || []), ...acl.organizationsJson]))\n }\n }\n }\n }\n const result = { isSuperAdmin: isSuper, features, organizations }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n\n // Use a forked EntityManager to avoid inheriting an aborted transaction from callers\n const em = this.em.fork()\n const user = await em.findOne(User, { id: userId })\n if (!user) {\n const result = { isSuperAdmin: false, features: [], organizations: null }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n const tenantId = scope.tenantId || user.tenantId || null\n const orgId = scope.organizationId || user.organizationId || null\n\n if (!tenantId) {\n const result = { isSuperAdmin: false, features: [], organizations: null }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n\n // Per-user ACL first\n const uacl = await em.findOne(UserAcl, { user: userId as any, tenantId })\n if (uacl) {\n const result = {\n isSuperAdmin: !!uacl.isSuperAdmin,\n features: Array.isArray(uacl.featuresJson) ? (uacl.featuresJson as string[]) : [],\n organizations: Array.isArray(uacl.organizationsJson) ? (uacl.organizationsJson as string[]) : null,\n }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n\n // Aggregate role ACLs\n const links = await findWithDecryption(\n em,\n UserRole,\n { user: userId as any, role: { tenantId } } as any,\n { populate: ['role'] },\n { tenantId, organizationId: orgId },\n )\n const linkList = Array.isArray(links) ? links : []\n const roleIds = linkList.map((l) => (l.role as any)?.id).filter(Boolean)\n let isSuper = false\n const features: string[] = []\n let organizations: string[] | null = []\n if (roleIds.length) {\n const racls = await em.find(RoleAcl, { tenantId, role: { $in: roleIds as any } } as any, {})\n const roleAcls = Array.isArray(racls) ? racls : []\n for (const r of roleAcls) {\n isSuper = isSuper || !!r.isSuperAdmin\n if (Array.isArray(r.featuresJson)) for (const f of r.featuresJson) if (!features.includes(f)) features.push(f)\n if (organizations !== null) {\n if (r.organizationsJson == null) organizations = null\n else organizations = Array.from(new Set([...(organizations || []), ...r.organizationsJson]))\n }\n }\n }\n if (organizations && orgId && !organizations.includes(orgId)) {\n // Out-of-scope org; caller will enforce\n }\n const result = { isSuperAdmin: isSuper, features, organizations }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n\n /**\n * Checks if a user has all required features within a given scope.\n * \n * This is the primary authorization check method used throughout the application.\n * It combines feature checking with organization visibility validation.\n * \n * Authorization logic:\n * 1. No features required \u2192 always returns true\n * 2. User is super admin \u2192 always returns true\n * 3. Organization restriction check: If the user's ACL has a restricted organization list\n * and the requested organization is not in that list \u2192 returns false\n * 4. Feature matching: User must have all required features (supports wildcards)\n * \n * @param userId - The ID of the user\n * @param required - Array of feature strings to check (e.g., ['users.view', 'users.edit'])\n * @param scope - The tenant and organization context for authorization\n * @returns true if the user has all required features and organization access, false otherwise\n * \n * @example\n * // Check if user can view and edit users\n * const canManageUsers = await rbacService.userHasAllFeatures(\n * 'user-123',\n * ['users.view', 'users.edit'],\n * { tenantId: 'tenant-1', organizationId: 'org-1' }\n * )\n * \n * @example\n * // Check with wildcard features\n * const canAccessEntities = await rbacService.userHasAllFeatures(\n * 'user-123',\n * ['entities.records.view'],\n * { tenantId: 'tenant-1', organizationId: 'org-1' }\n * )\n * // Returns true if user has 'entities.*', '*', or 'entities.records.view'\n */\n async userHasAllFeatures(userId: string, required: string[], scope: { tenantId: string | null; organizationId: string | null }): Promise<boolean> {\n if (!required.length) return true\n const acl = await this.loadAcl(userId, scope)\n if (acl.isSuperAdmin) return true\n if (acl.organizations && scope.organizationId && !acl.organizations.includes(scope.organizationId)) return false\n return this.hasAllFeatures(required, acl.features)\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["result", "em", "tenantId", "roleIds", "isSuper", "features", "organizations"]
|
|
7
7
|
}
|
|
@@ -9,6 +9,7 @@ export const roles_json = 'roles_json'
|
|
|
9
9
|
export const created_by = 'created_by'
|
|
10
10
|
export const session_token = 'session_token'
|
|
11
11
|
export const session_user_id = 'session_user_id'
|
|
12
|
+
export const session_secret_encrypted = 'session_secret_encrypted'
|
|
12
13
|
export const last_used_at = 'last_used_at'
|
|
13
14
|
export const expires_at = 'expires_at'
|
|
14
15
|
export const created_at = 'created_at'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.4.2-canary-
|
|
3
|
+
"version": "0.4.2-canary-15e78de280",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -207,7 +207,7 @@
|
|
|
207
207
|
}
|
|
208
208
|
},
|
|
209
209
|
"dependencies": {
|
|
210
|
-
"@open-mercato/shared": "0.4.2-canary-
|
|
210
|
+
"@open-mercato/shared": "0.4.2-canary-15e78de280",
|
|
211
211
|
"@xyflow/react": "^12.6.0",
|
|
212
212
|
"date-fns": "^4.1.0",
|
|
213
213
|
"date-fns-tz": "^3.2.0"
|
|
@@ -38,6 +38,10 @@ export class ApiKey {
|
|
|
38
38
|
@Property({ name: 'session_user_id', type: 'uuid', nullable: true })
|
|
39
39
|
sessionUserId?: string | null
|
|
40
40
|
|
|
41
|
+
/** Encrypted API key secret for session keys (recoverable for API calls) */
|
|
42
|
+
@Property({ name: 'session_secret_encrypted', type: 'text', nullable: true })
|
|
43
|
+
sessionSecretEncrypted?: string | null
|
|
44
|
+
|
|
41
45
|
@Property({ name: 'last_used_at', type: Date, nullable: true })
|
|
42
46
|
lastUsedAt?: Date | null
|
|
43
47
|
|
|
@@ -106,6 +106,15 @@
|
|
|
106
106
|
"nullable": true,
|
|
107
107
|
"mappedType": "uuid"
|
|
108
108
|
},
|
|
109
|
+
"session_secret_encrypted": {
|
|
110
|
+
"name": "session_secret_encrypted",
|
|
111
|
+
"type": "text",
|
|
112
|
+
"unsigned": false,
|
|
113
|
+
"autoincrement": false,
|
|
114
|
+
"primary": false,
|
|
115
|
+
"nullable": true,
|
|
116
|
+
"mappedType": "text"
|
|
117
|
+
},
|
|
109
118
|
"last_used_at": {
|
|
110
119
|
"name": "last_used_at",
|
|
111
120
|
"type": "timestamptz",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
|
|
3
|
+
export class Migration20260125204102 extends Migration {
|
|
4
|
+
|
|
5
|
+
override async up(): Promise<void> {
|
|
6
|
+
this.addSql(`alter table "api_keys" add column "session_secret_encrypted" text null;`);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
override async down(): Promise<void> {
|
|
10
|
+
this.addSql(`alter table "api_keys" drop column "session_secret_encrypted";`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
}
|
|
@@ -4,9 +4,60 @@ import { hash, compare } from 'bcryptjs'
|
|
|
4
4
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
5
5
|
import { Role } from '@open-mercato/core/modules/auth/data/entities'
|
|
6
6
|
import { ApiKey } from '../data/entities'
|
|
7
|
+
import { createKmsService } from '@open-mercato/shared/lib/encryption/kms'
|
|
8
|
+
import { encryptWithAesGcm, decryptWithAesGcm } from '@open-mercato/shared/lib/encryption/aes'
|
|
7
9
|
|
|
8
10
|
const BCRYPT_COST = 10
|
|
9
11
|
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// Session Secret Encryption Helpers
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Encrypt an API key secret for storage.
|
|
18
|
+
* Uses tenant-specific DEK if available, otherwise returns null.
|
|
19
|
+
*/
|
|
20
|
+
async function encryptSessionSecret(
|
|
21
|
+
secret: string,
|
|
22
|
+
tenantId: string | null
|
|
23
|
+
): Promise<string | null> {
|
|
24
|
+
if (!tenantId) return null
|
|
25
|
+
|
|
26
|
+
const kms = createKmsService()
|
|
27
|
+
if (!kms.isHealthy()) return null
|
|
28
|
+
|
|
29
|
+
const dek = await kms.getTenantDek(tenantId)
|
|
30
|
+
if (!dek) {
|
|
31
|
+
// Try to create a DEK if one doesn't exist
|
|
32
|
+
const created = await kms.createTenantDek(tenantId)
|
|
33
|
+
if (!created) return null
|
|
34
|
+
const encrypted = encryptWithAesGcm(secret, created.key)
|
|
35
|
+
return encrypted.value
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const encrypted = encryptWithAesGcm(secret, dek.key)
|
|
39
|
+
return encrypted.value
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Decrypt an API key secret from storage.
|
|
44
|
+
* Returns null if decryption fails or no DEK available.
|
|
45
|
+
*/
|
|
46
|
+
async function decryptSessionSecret(
|
|
47
|
+
encrypted: string,
|
|
48
|
+
tenantId: string | null
|
|
49
|
+
): Promise<string | null> {
|
|
50
|
+
if (!tenantId || !encrypted) return null
|
|
51
|
+
|
|
52
|
+
const kms = createKmsService()
|
|
53
|
+
if (!kms.isHealthy()) return null
|
|
54
|
+
|
|
55
|
+
const dek = await kms.getTenantDek(tenantId)
|
|
56
|
+
if (!dek) return null
|
|
57
|
+
|
|
58
|
+
return decryptWithAesGcm(encrypted, dek.key)
|
|
59
|
+
}
|
|
60
|
+
|
|
10
61
|
export type CreateApiKeyInput = {
|
|
11
62
|
name: string
|
|
12
63
|
description?: string | null
|
|
@@ -117,6 +168,7 @@ export function generateSessionToken(): string {
|
|
|
117
168
|
/**
|
|
118
169
|
* Create an ephemeral API key scoped to a chat session.
|
|
119
170
|
* The key inherits the user's roles and expires after ttlMinutes (default 30).
|
|
171
|
+
* The API key secret is encrypted and stored so it can be recovered for API calls.
|
|
120
172
|
*/
|
|
121
173
|
export async function createSessionApiKey(
|
|
122
174
|
em: EntityManager,
|
|
@@ -127,6 +179,9 @@ export async function createSessionApiKey(
|
|
|
127
179
|
const expiresAt = new Date(Date.now() + ttl * 60 * 1000)
|
|
128
180
|
const keyHash = await hashApiKey(secret)
|
|
129
181
|
|
|
182
|
+
// Encrypt the secret for later retrieval (used by MCP server for API calls)
|
|
183
|
+
const encryptedSecret = await encryptSessionSecret(secret, input.tenantId ?? null)
|
|
184
|
+
|
|
130
185
|
const record = em.create(ApiKey, {
|
|
131
186
|
name: `__session_${input.sessionToken}__`,
|
|
132
187
|
description: 'Ephemeral session API key for AI chat',
|
|
@@ -138,6 +193,7 @@ export async function createSessionApiKey(
|
|
|
138
193
|
createdBy: input.userId,
|
|
139
194
|
sessionToken: input.sessionToken,
|
|
140
195
|
sessionUserId: input.userId,
|
|
196
|
+
sessionSecretEncrypted: encryptedSecret,
|
|
141
197
|
expiresAt,
|
|
142
198
|
createdAt: new Date(),
|
|
143
199
|
})
|
|
@@ -172,6 +228,35 @@ export async function findApiKeyBySessionToken(
|
|
|
172
228
|
return record
|
|
173
229
|
}
|
|
174
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Find a session API key with its decrypted secret.
|
|
233
|
+
* Returns null if not found, expired, deleted, or decryption fails.
|
|
234
|
+
* This is used by the MCP server to recover the API key secret for making
|
|
235
|
+
* authenticated API calls on behalf of the user.
|
|
236
|
+
*/
|
|
237
|
+
export async function findSessionApiKeyWithSecret(
|
|
238
|
+
em: EntityManager,
|
|
239
|
+
sessionToken: string
|
|
240
|
+
): Promise<{ key: ApiKey; secret: string } | null> {
|
|
241
|
+
const record = await findApiKeyBySessionToken(em, sessionToken)
|
|
242
|
+
if (!record) return null
|
|
243
|
+
|
|
244
|
+
// If no encrypted secret stored, cannot recover
|
|
245
|
+
if (!record.sessionSecretEncrypted) {
|
|
246
|
+
console.warn('[ApiKeyService] Session key has no encrypted secret:', sessionToken.slice(0, 12))
|
|
247
|
+
return null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Decrypt the secret
|
|
251
|
+
const secret = await decryptSessionSecret(record.sessionSecretEncrypted, record.tenantId ?? null)
|
|
252
|
+
if (!secret) {
|
|
253
|
+
console.warn('[ApiKeyService] Failed to decrypt session secret:', sessionToken.slice(0, 12))
|
|
254
|
+
return null
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { key: record, secret }
|
|
258
|
+
}
|
|
259
|
+
|
|
175
260
|
/**
|
|
176
261
|
* Delete an ephemeral API key by its session token.
|
|
177
262
|
*/
|
|
@@ -69,7 +69,7 @@ export class RbacService {
|
|
|
69
69
|
return granted === required
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
public hasAllFeatures(required: string[], granted: string[]): boolean {
|
|
73
73
|
if (!required.length) return true
|
|
74
74
|
if (!granted.length) return false
|
|
75
75
|
return required.every((req) => granted.some((g) => this.matchFeature(req, g)))
|