@open-mercato/core 0.10.1-develop.7752.1.fd7c06569b → 0.10.1-develop.7756.1.8f5dec1b96
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/.turbo/turbo-build.log +1 -1
- package/dist/helpers/integration/dbFixtures.js +1 -1
- package/dist/helpers/integration/dbFixtures.js.map +1 -1
- package/dist/modules/auth/api/roles/acl/route.js +2 -2
- package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
- package/dist/modules/auth/api/roles/route.js +1 -1
- package/dist/modules/auth/api/roles/route.js.map +2 -2
- package/dist/modules/auth/api/users/acl/route.js +10 -2
- package/dist/modules/auth/api/users/acl/route.js.map +2 -2
- package/dist/modules/auth/commands/acl.js +2 -2
- package/dist/modules/auth/commands/acl.js.map +2 -2
- package/dist/modules/auth/commands/roles.js +1 -1
- package/dist/modules/auth/commands/roles.js.map +2 -2
- package/dist/modules/auth/commands/users.js +7 -3
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/auth/data/entities.js.map +2 -2
- package/dist/modules/auth/lib/executionPrincipal.js +2 -2
- package/dist/modules/auth/lib/executionPrincipal.js.map +2 -2
- package/dist/modules/auth/lib/grantChecks.js +38 -11
- package/dist/modules/auth/lib/grantChecks.js.map +2 -2
- package/dist/modules/auth/lib/setup-app.js +2 -2
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/modules/auth/migrations/Migration20260828120000_auth.js +45 -0
- package/dist/modules/auth/migrations/Migration20260828120000_auth.js.map +7 -0
- package/dist/modules/auth/services/rbacService.js +138 -59
- package/dist/modules/auth/services/rbacService.js.map +3 -3
- package/dist/modules/entities/cli.js +12 -1
- package/dist/modules/entities/cli.js.map +2 -2
- package/dist/modules/entities/lib/helpers.js +13 -0
- package/dist/modules/entities/lib/helpers.js.map +2 -2
- package/dist/modules/entities/lib/rotate-custom-entity-documents.js +157 -0
- package/dist/modules/entities/lib/rotate-custom-entity-documents.js.map +7 -0
- package/dist/modules/notifications/lib/notificationRecipients.js +2 -2
- package/dist/modules/notifications/lib/notificationRecipients.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +179 -11
- package/dist/modules/query_index/lib/engine.js.map +3 -3
- package/package.json +7 -7
- package/src/helpers/integration/dbFixtures.ts +1 -1
- package/src/modules/auth/api/roles/acl/route.ts +2 -2
- package/src/modules/auth/api/roles/route.ts +1 -1
- package/src/modules/auth/api/users/acl/route.ts +10 -2
- package/src/modules/auth/commands/acl.ts +2 -2
- package/src/modules/auth/commands/roles.ts +1 -1
- package/src/modules/auth/commands/users.ts +7 -3
- package/src/modules/auth/data/entities.ts +12 -0
- package/src/modules/auth/lib/executionPrincipal.ts +2 -2
- package/src/modules/auth/lib/grantChecks.ts +115 -11
- package/src/modules/auth/lib/setup-app.ts +2 -2
- package/src/modules/auth/migrations/Migration20260828120000_auth.ts +70 -0
- package/src/modules/auth/services/rbacService.ts +161 -76
- package/src/modules/entities/cli.ts +12 -1
- package/src/modules/entities/lib/helpers.ts +17 -0
- package/src/modules/entities/lib/rotate-custom-entity-documents.ts +200 -0
- package/src/modules/notifications/lib/notificationRecipients.ts +6 -0
- package/src/modules/query_index/lib/engine.ts +187 -9
package/.turbo/turbo-build.log
CHANGED
|
@@ -45,7 +45,7 @@ async function clearUserHomeOrganization(userId) {
|
|
|
45
45
|
async function setUserAclInDb(input) {
|
|
46
46
|
await withClient(async (client) => {
|
|
47
47
|
const existing = await client.query(
|
|
48
|
-
"select id from user_acls where user_id = $1 and tenant_id = $2 limit 1",
|
|
48
|
+
"select id from user_acls where user_id = $1 and tenant_id = $2 and deleted_at is null limit 1",
|
|
49
49
|
[input.userId, input.tenantId]
|
|
50
50
|
);
|
|
51
51
|
const featuresJson = JSON.stringify(input.features);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/helpers/integration/dbFixtures.ts"],
|
|
4
|
-
"sourcesContent": ["import { readFileSync } from 'node:fs';\nimport path from 'node:path';\nimport { Client } from 'pg';\nimport { resolveAppRoot } from './appRoot';\n\n/**\n * Database fixtures for the org-scope fail-open hardening tests.\n *\n * These helpers talk to Postgres directly via `pg` rather than bootstrapping the\n * app DI container, because:\n * - the directory create command denies non-super-admin actors (the only\n * loginable accounts here), so orgs cannot be created over the API; and\n * - granting `customers.*` through the ACL API requires a super-admin actor.\n * Raw SQL keeps the test self-contained and avoids depending on built package\n * `dist/` output for an in-process MikroORM bootstrap.\n */\n\nfunction readEnvValue(key: string): string | undefined {\n if (process.env[key]) return process.env[key];\n const candidatePaths = [\n path.resolve(resolveAppRoot(), '.env'),\n path.resolve(process.cwd(), 'apps/mercato/.env'),\n path.resolve(process.cwd(), '.env'),\n ];\n for (const envPath of candidatePaths) {\n try {\n const content = readFileSync(envPath, 'utf-8');\n const match = content.match(new RegExp(`^${key}=(.+)$`, 'm'));\n if (match?.[1]) return match[1].trim();\n } catch {\n continue;\n }\n }\n return undefined;\n}\n\nfunction resolveDatabaseUrl(): string {\n const url = readEnvValue('DATABASE_URL');\n if (!url) throw new Error('[internal] DATABASE_URL is not configured for integration DB fixtures');\n return url;\n}\n\n/**\n * The same connection string `withClient` uses, for specs that need their own pool rather\n * than a single `pg` client \u2014 a spec driving server-side library code that expects a Kysely\n * instance, for example. Reuses the `.env` fallback so those specs resolve the database the\n * same way every other fixture here does.\n */\nexport function resolveIntegrationDatabaseUrl(): string {\n return resolveDatabaseUrl();\n}\n\n/**\n * The slice of `pg`'s client these fixtures use, declared structurally.\n *\n * `pg` publishes `Client` as a class merged with a namespace, and the app project's module\n * resolution picks the namespace half in type position (`TS2709`), so naming the class here made\n * every caller outside `packages/core` fail to typecheck. A structural type is resolvable from\n * every project and keeps `query<T>()` generic, which `InstanceType<typeof Client>` did not.\n */\nexport type IntegrationDbClient = {\n query<T = Record<string, unknown>>(\n text: string,\n values?: readonly unknown[],\n ): Promise<{ rows: T[]; rowCount: number | null }>;\n};\n\nexport async function withClient<T>(run: (client: IntegrationDbClient) => Promise<T>): Promise<T> {\n const client = new Client({ connectionString: resolveDatabaseUrl() }) as unknown as IntegrationDbClient & {\n connect: () => Promise<void>;\n end: () => Promise<void>;\n };\n await client.connect();\n try {\n return await run(client);\n } finally {\n await client.end();\n }\n}\n\n/**\n * Nulls a user's home organization (`organization_id`) directly in the database.\n *\n * Required to construct the \"floating restricted user\" precondition: the JWT\n * `auth.orgId` is minted from `users.organization_id` at login, so this MUST run\n * BEFORE the user logs in.\n */\nexport async function clearUserHomeOrganization(userId: string): Promise<void> {\n await withClient(async (client) => {\n await client.query('update users set organization_id = null where id = $1', [userId]);\n });\n}\n\n/**\n * Upserts a per-user ACL row, setting the effective feature list and the\n * organization-visibility list.\n *\n * `organizations` maps to `OrganizationScope.allowedIds` (write path) and\n * `filterIds` (read path):\n * - `[orgA]` => restricted to orgA (write fail-open precondition, #2239)\n * - `[]` => restricted to zero orgs (read fail-open precondition, #2245)\n * - `null` => unrestricted\n */\nexport async function setUserAclInDb(input: {\n userId: string;\n tenantId: string;\n features: string[];\n organizations: string[] | null;\n}): Promise<void> {\n await withClient(async (client) => {\n const existing = await client.query<{ id: string }>(\n 'select id from user_acls where user_id = $1 and tenant_id = $2 limit 1',\n [input.userId, input.tenantId],\n );\n const featuresJson = JSON.stringify(input.features);\n const organizationsJson = input.organizations === null ? null : JSON.stringify(input.organizations);\n if (existing.rows.length > 0) {\n await client.query(\n 'update user_acls set features_json = $2::jsonb, organizations_json = $3::jsonb, is_super_admin = false, updated_at = now() where id = $1',\n [existing.rows[0].id, featuresJson, organizationsJson],\n );\n return;\n }\n await client.query(\n `insert into user_acls (id, user_id, tenant_id, features_json, organizations_json, is_super_admin, created_at)\n values (gen_random_uuid(), $1, $2, $3::jsonb, $4::jsonb, false, now())`,\n [input.userId, input.tenantId, featuresJson, organizationsJson],\n );\n });\n}\n\n/** Removes any per-user ACL rows for the user (best-effort test cleanup). */\nexport async function deleteUserAclInDb(userId: string): Promise<void> {\n if (!userId) return;\n await withClient(async (client) => {\n await client.query('delete from user_acls where user_id = $1', [userId]);\n });\n}\n\n/**\n * Creates an organization directly in the database within the given tenant.\n *\n * The directory create command routes through `enforceTenantSelection`, which\n * denies the (non-super-admin) accounts loginable on this instance. Landing the\n * org in a known tenant lets the floating user (created under it) share the\n * tenant \u2014 required because cross-tenant access returns 404 before the\n * org-scope guard ever runs.\n */\nexport async function createOrganizationInDb(input: { name: string; tenantId: string }): Promise<string> {\n return withClient(async (client) => {\n const result = await client.query<{ id: string }>(\n `insert into organizations\n (id, tenant_id, name, is_active, ancestor_ids, child_ids, descendant_ids, depth, created_at, updated_at)\n values (gen_random_uuid(), $1, $2, true, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, 0, now(), now())\n returning id`,\n [input.tenantId, input.name],\n );\n return result.rows[0].id;\n });\n}\n\n/** Hard-deletes an organization row (best-effort test cleanup). */\nexport async function deleteOrganizationInDb(organizationId: string | null): Promise<void> {\n if (!organizationId) return;\n await withClient(async (client) => {\n await client.query('delete from organizations where id = $1', [organizationId]);\n });\n}\n\n/**\n * Hard-deletes integration credential rows for an organization (best-effort test\n * cleanup). Integration credentials are stored per (integration_id,\n * organization_id, tenant_id, user_id) with no FK to organizations, so a\n * throwaway org's credential rows must be removed explicitly when the org row is\n * torn down.\n */\nexport async function deleteIntegrationCredentialsInDb(organizationId: string | null): Promise<void> {\n if (!organizationId) return;\n await withClient(async (client) => {\n await client.query('delete from integration_credentials where organization_id = $1', [organizationId]);\n });\n}\n"],
|
|
4
|
+
"sourcesContent": ["import { readFileSync } from 'node:fs';\nimport path from 'node:path';\nimport { Client } from 'pg';\nimport { resolveAppRoot } from './appRoot';\n\n/**\n * Database fixtures for the org-scope fail-open hardening tests.\n *\n * These helpers talk to Postgres directly via `pg` rather than bootstrapping the\n * app DI container, because:\n * - the directory create command denies non-super-admin actors (the only\n * loginable accounts here), so orgs cannot be created over the API; and\n * - granting `customers.*` through the ACL API requires a super-admin actor.\n * Raw SQL keeps the test self-contained and avoids depending on built package\n * `dist/` output for an in-process MikroORM bootstrap.\n */\n\nfunction readEnvValue(key: string): string | undefined {\n if (process.env[key]) return process.env[key];\n const candidatePaths = [\n path.resolve(resolveAppRoot(), '.env'),\n path.resolve(process.cwd(), 'apps/mercato/.env'),\n path.resolve(process.cwd(), '.env'),\n ];\n for (const envPath of candidatePaths) {\n try {\n const content = readFileSync(envPath, 'utf-8');\n const match = content.match(new RegExp(`^${key}=(.+)$`, 'm'));\n if (match?.[1]) return match[1].trim();\n } catch {\n continue;\n }\n }\n return undefined;\n}\n\nfunction resolveDatabaseUrl(): string {\n const url = readEnvValue('DATABASE_URL');\n if (!url) throw new Error('[internal] DATABASE_URL is not configured for integration DB fixtures');\n return url;\n}\n\n/**\n * The same connection string `withClient` uses, for specs that need their own pool rather\n * than a single `pg` client \u2014 a spec driving server-side library code that expects a Kysely\n * instance, for example. Reuses the `.env` fallback so those specs resolve the database the\n * same way every other fixture here does.\n */\nexport function resolveIntegrationDatabaseUrl(): string {\n return resolveDatabaseUrl();\n}\n\n/**\n * The slice of `pg`'s client these fixtures use, declared structurally.\n *\n * `pg` publishes `Client` as a class merged with a namespace, and the app project's module\n * resolution picks the namespace half in type position (`TS2709`), so naming the class here made\n * every caller outside `packages/core` fail to typecheck. A structural type is resolvable from\n * every project and keeps `query<T>()` generic, which `InstanceType<typeof Client>` did not.\n */\nexport type IntegrationDbClient = {\n query<T = Record<string, unknown>>(\n text: string,\n values?: readonly unknown[],\n ): Promise<{ rows: T[]; rowCount: number | null }>;\n};\n\nexport async function withClient<T>(run: (client: IntegrationDbClient) => Promise<T>): Promise<T> {\n const client = new Client({ connectionString: resolveDatabaseUrl() }) as unknown as IntegrationDbClient & {\n connect: () => Promise<void>;\n end: () => Promise<void>;\n };\n await client.connect();\n try {\n return await run(client);\n } finally {\n await client.end();\n }\n}\n\n/**\n * Nulls a user's home organization (`organization_id`) directly in the database.\n *\n * Required to construct the \"floating restricted user\" precondition: the JWT\n * `auth.orgId` is minted from `users.organization_id` at login, so this MUST run\n * BEFORE the user logs in.\n */\nexport async function clearUserHomeOrganization(userId: string): Promise<void> {\n await withClient(async (client) => {\n await client.query('update users set organization_id = null where id = $1', [userId]);\n });\n}\n\n/**\n * Upserts a per-user ACL row, setting the effective feature list and the\n * organization-visibility list.\n *\n * `organizations` maps to `OrganizationScope.allowedIds` (write path) and\n * `filterIds` (read path):\n * - `[orgA]` => restricted to orgA (write fail-open precondition, #2239)\n * - `[]` => restricted to zero orgs (read fail-open precondition, #2245)\n * - `null` => unrestricted\n */\nexport async function setUserAclInDb(input: {\n userId: string;\n tenantId: string;\n features: string[];\n organizations: string[] | null;\n}): Promise<void> {\n await withClient(async (client) => {\n const existing = await client.query<{ id: string }>(\n 'select id from user_acls where user_id = $1 and tenant_id = $2 and deleted_at is null limit 1',\n [input.userId, input.tenantId],\n );\n const featuresJson = JSON.stringify(input.features);\n const organizationsJson = input.organizations === null ? null : JSON.stringify(input.organizations);\n if (existing.rows.length > 0) {\n await client.query(\n 'update user_acls set features_json = $2::jsonb, organizations_json = $3::jsonb, is_super_admin = false, updated_at = now() where id = $1',\n [existing.rows[0].id, featuresJson, organizationsJson],\n );\n return;\n }\n await client.query(\n `insert into user_acls (id, user_id, tenant_id, features_json, organizations_json, is_super_admin, created_at)\n values (gen_random_uuid(), $1, $2, $3::jsonb, $4::jsonb, false, now())`,\n [input.userId, input.tenantId, featuresJson, organizationsJson],\n );\n });\n}\n\n/** Removes any per-user ACL rows for the user (best-effort test cleanup). */\nexport async function deleteUserAclInDb(userId: string): Promise<void> {\n if (!userId) return;\n await withClient(async (client) => {\n await client.query('delete from user_acls where user_id = $1', [userId]);\n });\n}\n\n/**\n * Creates an organization directly in the database within the given tenant.\n *\n * The directory create command routes through `enforceTenantSelection`, which\n * denies the (non-super-admin) accounts loginable on this instance. Landing the\n * org in a known tenant lets the floating user (created under it) share the\n * tenant \u2014 required because cross-tenant access returns 404 before the\n * org-scope guard ever runs.\n */\nexport async function createOrganizationInDb(input: { name: string; tenantId: string }): Promise<string> {\n return withClient(async (client) => {\n const result = await client.query<{ id: string }>(\n `insert into organizations\n (id, tenant_id, name, is_active, ancestor_ids, child_ids, descendant_ids, depth, created_at, updated_at)\n values (gen_random_uuid(), $1, $2, true, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, 0, now(), now())\n returning id`,\n [input.tenantId, input.name],\n );\n return result.rows[0].id;\n });\n}\n\n/** Hard-deletes an organization row (best-effort test cleanup). */\nexport async function deleteOrganizationInDb(organizationId: string | null): Promise<void> {\n if (!organizationId) return;\n await withClient(async (client) => {\n await client.query('delete from organizations where id = $1', [organizationId]);\n });\n}\n\n/**\n * Hard-deletes integration credential rows for an organization (best-effort test\n * cleanup). Integration credentials are stored per (integration_id,\n * organization_id, tenant_id, user_id) with no FK to organizations, so a\n * throwaway org's credential rows must be removed explicitly when the org row is\n * torn down.\n */\nexport async function deleteIntegrationCredentialsInDb(organizationId: string | null): Promise<void> {\n if (!organizationId) return;\n await withClient(async (client) => {\n await client.query('delete from integration_credentials where organization_id = $1', [organizationId]);\n });\n}\n"],
|
|
5
5
|
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -83,7 +83,7 @@ async function GET(req) {
|
|
|
83
83
|
throw err;
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
-
const acl = tenantScope ? await em.findOne(RoleAcl, { role, tenantId: tenantScope }) : null;
|
|
86
|
+
const acl = tenantScope ? await em.findOne(RoleAcl, { role, tenantId: tenantScope, deletedAt: null }) : null;
|
|
87
87
|
const response = acl ? {
|
|
88
88
|
isSuperAdmin: !!acl.isSuperAdmin,
|
|
89
89
|
features: Array.isArray(acl.featuresJson) ? acl.featuresJson : [],
|
|
@@ -151,7 +151,7 @@ async function PUT(req) {
|
|
|
151
151
|
throw err;
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
|
-
const acl = await em.findOne(RoleAcl, { role, tenantId: targetTenantId });
|
|
154
|
+
const acl = await em.findOne(RoleAcl, { role, tenantId: targetTenantId, deletedAt: null });
|
|
155
155
|
if (acl) {
|
|
156
156
|
try {
|
|
157
157
|
await enforceCommandOptimisticLockWithGuards(container, {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/auth/api/roles/acl/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { logCrudAccess } from '@open-mercato/shared/lib/crud/factory'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { RoleAcl, Role } from '@open-mercato/core/modules/auth/data/entities'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { resolveIsSuperAdmin } from '@open-mercato/core/modules/auth/lib/tenantAccess'\nimport { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport {\n assertActorCanGrantAcl,\n assertActorCanModifySuperAdminRoleTarget,\n normalizeGrantFeatureList,\n} from '@open-mercato/core/modules/auth/lib/grantChecks'\nimport {\n AUTH_ROLE_ACL_UPDATE_COMMAND_ID,\n type AclUpdateResult,\n type RoleAclUpdateInput,\n} from '@open-mercato/core/modules/auth/commands/acl'\n\nconst getSchema = z.object({\n roleId: z.string().uuid(),\n tenantId: z.string().uuid().optional(),\n})\nconst putSchema = z.object({\n roleId: z.string().uuid(),\n isSuperAdmin: z.boolean().optional(),\n features: z.array(z.string()).optional(),\n organizations: z.array(z.string()).nullable().optional(),\n tenantId: z.string().uuid().optional(),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['auth.acl.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['auth.acl.manage'] },\n}\n\nconst roleAclResponseSchema = z.object({\n isSuperAdmin: z.boolean(),\n features: z.array(z.string()),\n organizations: z.array(z.string()).nullable(),\n updatedAt: z.string().nullable(),\n})\n\nconst roleAclUpdateResponseSchema = z.object({\n ok: z.literal(true),\n sanitized: z.boolean(),\n})\n\nconst roleAclErrorSchema = z.object({ error: z.string() })\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n const url = new URL(req.url)\n const parsed = getSchema.safeParse({\n roleId: url.searchParams.get('roleId'),\n tenantId: url.searchParams.get('tenantId') || undefined,\n })\n if (!parsed.success) return NextResponse.json({ error: 'Invalid input' }, { status: 400 })\n const container = await createRequestContainer()\n const isSuperAdmin = await resolveIsSuperAdmin({ auth, container })\n const em = container.resolve('em') as EntityManager\n const authTenantId = auth.tenantId ?? null\n const roleFilter: Record<string, unknown> = { id: parsed.data.roleId }\n if (!isSuperAdmin && authTenantId) {\n roleFilter.$or = [{ tenantId: authTenantId }, { tenantId: null }]\n }\n const role = await em.findOne(Role, roleFilter)\n if (!role) return NextResponse.json({ error: 'Not found' }, { status: 404 })\n const roleTenantId = role?.tenantId ? String(role.tenantId) : null\n\n let tenantScope = parsed.data.tenantId ?? roleTenantId ?? authTenantId ?? null\n if (parsed.data.tenantId && parsed.data.tenantId !== tenantScope) {\n if (isSuperAdmin || parsed.data.tenantId === authTenantId) tenantScope = parsed.data.tenantId\n else return NextResponse.json({ error: 'Forbidden' }, { status: 403 })\n }\n if (!tenantScope && !isSuperAdmin) tenantScope = authTenantId ?? null\n\n if (!isSuperAdmin && auth.sub) {\n try {\n await assertActorCanModifySuperAdminRoleTarget({\n em,\n rbacService: container.resolve('rbacService') as RbacService,\n actorUserId: auth.sub,\n tenantId: tenantScope,\n organizationId: auth.orgId ?? null,\n targetRoleId: parsed.data.roleId,\n actorIsSuperAdmin: false,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n }\n\n const acl = tenantScope\n ? await em.findOne(RoleAcl, { role, tenantId: tenantScope })\n : null\n const response = acl\n ? {\n isSuperAdmin: !!acl.isSuperAdmin,\n features: Array.isArray(acl.featuresJson) ? acl.featuresJson : [],\n organizations: Array.isArray(acl.organizationsJson) ? acl.organizationsJson : null,\n updatedAt: acl.updatedAt instanceof Date ? acl.updatedAt.toISOString() : null,\n }\n : { isSuperAdmin: false, features: [], organizations: null, updatedAt: null }\n\n await logCrudAccess({\n container,\n auth,\n request: req,\n items: [{ id: parsed.data.roleId, ...response }],\n idField: 'id',\n resourceKind: 'auth.role_acl',\n organizationId: auth.orgId ?? null,\n tenantId: tenantScope,\n query: { roleId: parsed.data.roleId, tenantId: tenantScope },\n accessType: 'read:item',\n })\n\n return NextResponse.json(response)\n}\n\nexport async function PUT(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n const body = await req.json().catch(() => ({}))\n const parsed = putSchema.safeParse(body)\n if (!parsed.success) return NextResponse.json({ error: 'Invalid input' }, { status: 400 })\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const isSuperAdmin = await resolveIsSuperAdmin({ auth, container })\n const rbacService = container.resolve('rbacService') as RbacService\n const authTenantId = auth.tenantId ?? null\n const putRoleFilter: Record<string, unknown> = { id: parsed.data.roleId }\n if (!isSuperAdmin && authTenantId) {\n putRoleFilter.$or = [{ tenantId: authTenantId }, { tenantId: null }]\n }\n const role = await em.findOne(Role, putRoleFilter)\n if (!role) return NextResponse.json({ error: 'Not found' }, { status: 404 })\n\n const roleTenantId = role?.tenantId ? String(role.tenantId) : null\n\n let targetTenantId = parsed.data.tenantId ?? roleTenantId ?? authTenantId ?? null\n if (parsed.data.tenantId && parsed.data.tenantId !== targetTenantId) {\n if (isSuperAdmin || parsed.data.tenantId === authTenantId) {\n targetTenantId = parsed.data.tenantId\n } else {\n return NextResponse.json({ error: 'Forbidden' }, { status: 403 })\n }\n }\n if (!targetTenantId && !isSuperAdmin) targetTenantId = authTenantId ?? null\n if (!targetTenantId) return NextResponse.json({ error: 'Tenant required' }, { status: 400 })\n\n if (!isSuperAdmin && targetTenantId !== authTenantId) {\n return NextResponse.json({ error: 'Forbidden' }, { status: 403 })\n }\n\n if (!isSuperAdmin && auth.sub) {\n try {\n await assertActorCanModifySuperAdminRoleTarget({\n em,\n rbacService,\n actorUserId: auth.sub,\n tenantId: targetTenantId,\n organizationId: auth.orgId ?? null,\n targetRoleId: parsed.data.roleId,\n actorIsSuperAdmin: false,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n }\n\n const acl = await em.findOne(RoleAcl, { role, tenantId: targetTenantId })\n // Optimistic lock: refuse a stale ACL overwrite so two admins editing the same\n // role's features in parallel cannot silently clobber each other (#2055). The\n // check is strictly additive \u2014 when the client sends no expected-version header\n // it is a no-op. Skipped when the ACL row does not exist yet (first grant has\n // no prior version to conflict with).\n if (acl) {\n try {\n await enforceCommandOptimisticLockWithGuards(container, {\n resourceKind: 'auth.role_acl',\n resourceId: acl.id,\n current: acl.updatedAt ?? null,\n request: req,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n }\n\n const existingIsSuperAdmin = !!acl?.isSuperAdmin\n const existingFeatures = normalizeGrantFeatureList(acl?.featuresJson)\n const existingOrganizations = normalizeOrganizations(acl?.organizationsJson)\n const requestedIsSuperAdmin = parsed.data.isSuperAdmin ?? existingIsSuperAdmin\n const requestedFeatures = parsed.data.features === undefined\n ? existingFeatures\n : normalizeGrantFeatureList(parsed.data.features)\n const requestedOrganizations = parsed.data.organizations === undefined\n ? existingOrganizations\n : normalizeOrganizations(parsed.data.organizations)\n\n try {\n await assertActorCanGrantAcl({\n em,\n rbacService,\n actorUserId: auth.sub,\n tenantId: targetTenantId,\n organizationId: auth.orgId ?? null,\n isSuperAdmin: requestedIsSuperAdmin,\n features: requestedFeatures,\n organizations: requestedOrganizations,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n\n // Route the write through the command bus so the permission change lands in\n // the action log. The command owns the transactional write and the RBAC cache\n // invalidation that used to live here.\n const commandBus = container.resolve('commandBus') as CommandBus\n // A super admin may edit a role in another tenant. The actor's organization\n // belongs to *their* tenant, and the command bus resolves the log row's\n // organization as `metadata.organizationId ?? ctx.selectedOrganizationId ??\n // ctx.auth.orgId` \u2014 a `??` chain, so a handler cannot express \"explicitly no\n // organization\" on its own. Strip the organization from the context instead,\n // otherwise the entry is stamped (target tenant, actor's foreign organization)\n // and `ActionLogService` \u2014 which filters organization with strict equality \u2014\n // can never surface it for anyone.\n const isForeignTenant = targetTenantId !== (auth.tenantId ?? null)\n const actorOrgId = isForeignTenant ? null : auth.orgId ?? null\n const commandCtx: CommandRuntimeContext = {\n container,\n auth: isForeignTenant ? { ...auth, orgId: null } : auth,\n organizationScope: null,\n selectedOrganizationId: actorOrgId,\n organizationIds: actorOrgId ? [actorOrgId] : null,\n request: req,\n }\n await commandBus.execute<RoleAclUpdateInput, AclUpdateResult>(AUTH_ROLE_ACL_UPDATE_COMMAND_ID, {\n input: {\n roleId: String(role.id),\n tenantId: targetTenantId,\n isSuperAdmin: requestedIsSuperAdmin,\n features: requestedFeatures,\n organizations: requestedOrganizations,\n },\n ctx: commandCtx,\n })\n\n return NextResponse.json({\n ok: true,\n sanitized: false,\n })\n}\n\nfunction normalizeOrganizations(organizations: unknown): string[] | null {\n if (!Array.isArray(organizations)) return null\n return normalizeGrantFeatureList(organizations)\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Authentication & Accounts',\n summary: 'Role ACL management',\n methods: {\n GET: {\n summary: 'Fetch role ACL',\n description: 'Returns the feature and organization assignments associated with a role within the current tenant.',\n query: getSchema,\n responses: [\n { status: 200, description: 'Role ACL entry', schema: roleAclResponseSchema },\n { status: 400, description: 'Invalid role id', schema: roleAclErrorSchema },\n { status: 401, description: 'Unauthorized', schema: roleAclErrorSchema },\n { status: 404, description: 'Role not found', schema: roleAclErrorSchema },\n ],\n },\n PUT: {\n summary: 'Update role ACL',\n description: 'Replaces the feature list, super admin flag, and optional organization assignments for a role.',\n requestBody: {\n contentType: 'application/json',\n schema: putSchema,\n },\n responses: [\n { status: 200, description: 'Role ACL updated', schema: roleAclUpdateResponseSchema },\n { status: 400, description: 'Invalid payload', schema: roleAclErrorSchema },\n { status: 401, description: 'Unauthorized', schema: roleAclErrorSchema },\n { status: 403, description: 'Insufficient privileges to modify ACL', schema: roleAclErrorSchema },\n { status: 404, description: 'Role not found', schema: roleAclErrorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { logCrudAccess } from '@open-mercato/shared/lib/crud/factory'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { RoleAcl, Role } from '@open-mercato/core/modules/auth/data/entities'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { resolveIsSuperAdmin } from '@open-mercato/core/modules/auth/lib/tenantAccess'\nimport { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport {\n assertActorCanGrantAcl,\n assertActorCanModifySuperAdminRoleTarget,\n normalizeGrantFeatureList,\n} from '@open-mercato/core/modules/auth/lib/grantChecks'\nimport {\n AUTH_ROLE_ACL_UPDATE_COMMAND_ID,\n type AclUpdateResult,\n type RoleAclUpdateInput,\n} from '@open-mercato/core/modules/auth/commands/acl'\n\nconst getSchema = z.object({\n roleId: z.string().uuid(),\n tenantId: z.string().uuid().optional(),\n})\nconst putSchema = z.object({\n roleId: z.string().uuid(),\n isSuperAdmin: z.boolean().optional(),\n features: z.array(z.string()).optional(),\n organizations: z.array(z.string()).nullable().optional(),\n tenantId: z.string().uuid().optional(),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['auth.acl.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['auth.acl.manage'] },\n}\n\nconst roleAclResponseSchema = z.object({\n isSuperAdmin: z.boolean(),\n features: z.array(z.string()),\n organizations: z.array(z.string()).nullable(),\n updatedAt: z.string().nullable(),\n})\n\nconst roleAclUpdateResponseSchema = z.object({\n ok: z.literal(true),\n sanitized: z.boolean(),\n})\n\nconst roleAclErrorSchema = z.object({ error: z.string() })\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n const url = new URL(req.url)\n const parsed = getSchema.safeParse({\n roleId: url.searchParams.get('roleId'),\n tenantId: url.searchParams.get('tenantId') || undefined,\n })\n if (!parsed.success) return NextResponse.json({ error: 'Invalid input' }, { status: 400 })\n const container = await createRequestContainer()\n const isSuperAdmin = await resolveIsSuperAdmin({ auth, container })\n const em = container.resolve('em') as EntityManager\n const authTenantId = auth.tenantId ?? null\n const roleFilter: Record<string, unknown> = { id: parsed.data.roleId }\n if (!isSuperAdmin && authTenantId) {\n roleFilter.$or = [{ tenantId: authTenantId }, { tenantId: null }]\n }\n const role = await em.findOne(Role, roleFilter)\n if (!role) return NextResponse.json({ error: 'Not found' }, { status: 404 })\n const roleTenantId = role?.tenantId ? String(role.tenantId) : null\n\n let tenantScope = parsed.data.tenantId ?? roleTenantId ?? authTenantId ?? null\n if (parsed.data.tenantId && parsed.data.tenantId !== tenantScope) {\n if (isSuperAdmin || parsed.data.tenantId === authTenantId) tenantScope = parsed.data.tenantId\n else return NextResponse.json({ error: 'Forbidden' }, { status: 403 })\n }\n if (!tenantScope && !isSuperAdmin) tenantScope = authTenantId ?? null\n\n if (!isSuperAdmin && auth.sub) {\n try {\n await assertActorCanModifySuperAdminRoleTarget({\n em,\n rbacService: container.resolve('rbacService') as RbacService,\n actorUserId: auth.sub,\n tenantId: tenantScope,\n organizationId: auth.orgId ?? null,\n targetRoleId: parsed.data.roleId,\n actorIsSuperAdmin: false,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n }\n\n const acl = tenantScope\n ? await em.findOne(RoleAcl, { role, tenantId: tenantScope, deletedAt: null })\n : null\n const response = acl\n ? {\n isSuperAdmin: !!acl.isSuperAdmin,\n features: Array.isArray(acl.featuresJson) ? acl.featuresJson : [],\n organizations: Array.isArray(acl.organizationsJson) ? acl.organizationsJson : null,\n updatedAt: acl.updatedAt instanceof Date ? acl.updatedAt.toISOString() : null,\n }\n : { isSuperAdmin: false, features: [], organizations: null, updatedAt: null }\n\n await logCrudAccess({\n container,\n auth,\n request: req,\n items: [{ id: parsed.data.roleId, ...response }],\n idField: 'id',\n resourceKind: 'auth.role_acl',\n organizationId: auth.orgId ?? null,\n tenantId: tenantScope,\n query: { roleId: parsed.data.roleId, tenantId: tenantScope },\n accessType: 'read:item',\n })\n\n return NextResponse.json(response)\n}\n\nexport async function PUT(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n const body = await req.json().catch(() => ({}))\n const parsed = putSchema.safeParse(body)\n if (!parsed.success) return NextResponse.json({ error: 'Invalid input' }, { status: 400 })\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const isSuperAdmin = await resolveIsSuperAdmin({ auth, container })\n const rbacService = container.resolve('rbacService') as RbacService\n const authTenantId = auth.tenantId ?? null\n const putRoleFilter: Record<string, unknown> = { id: parsed.data.roleId }\n if (!isSuperAdmin && authTenantId) {\n putRoleFilter.$or = [{ tenantId: authTenantId }, { tenantId: null }]\n }\n const role = await em.findOne(Role, putRoleFilter)\n if (!role) return NextResponse.json({ error: 'Not found' }, { status: 404 })\n\n const roleTenantId = role?.tenantId ? String(role.tenantId) : null\n\n let targetTenantId = parsed.data.tenantId ?? roleTenantId ?? authTenantId ?? null\n if (parsed.data.tenantId && parsed.data.tenantId !== targetTenantId) {\n if (isSuperAdmin || parsed.data.tenantId === authTenantId) {\n targetTenantId = parsed.data.tenantId\n } else {\n return NextResponse.json({ error: 'Forbidden' }, { status: 403 })\n }\n }\n if (!targetTenantId && !isSuperAdmin) targetTenantId = authTenantId ?? null\n if (!targetTenantId) return NextResponse.json({ error: 'Tenant required' }, { status: 400 })\n\n if (!isSuperAdmin && targetTenantId !== authTenantId) {\n return NextResponse.json({ error: 'Forbidden' }, { status: 403 })\n }\n\n if (!isSuperAdmin && auth.sub) {\n try {\n await assertActorCanModifySuperAdminRoleTarget({\n em,\n rbacService,\n actorUserId: auth.sub,\n tenantId: targetTenantId,\n organizationId: auth.orgId ?? null,\n targetRoleId: parsed.data.roleId,\n actorIsSuperAdmin: false,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n }\n\n const acl = await em.findOne(RoleAcl, { role, tenantId: targetTenantId, deletedAt: null })\n // Optimistic lock: refuse a stale ACL overwrite so two admins editing the same\n // role's features in parallel cannot silently clobber each other (#2055). The\n // check is strictly additive \u2014 when the client sends no expected-version header\n // it is a no-op. Skipped when the ACL row does not exist yet (first grant has\n // no prior version to conflict with).\n if (acl) {\n try {\n await enforceCommandOptimisticLockWithGuards(container, {\n resourceKind: 'auth.role_acl',\n resourceId: acl.id,\n current: acl.updatedAt ?? null,\n request: req,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n }\n\n const existingIsSuperAdmin = !!acl?.isSuperAdmin\n const existingFeatures = normalizeGrantFeatureList(acl?.featuresJson)\n const existingOrganizations = normalizeOrganizations(acl?.organizationsJson)\n const requestedIsSuperAdmin = parsed.data.isSuperAdmin ?? existingIsSuperAdmin\n const requestedFeatures = parsed.data.features === undefined\n ? existingFeatures\n : normalizeGrantFeatureList(parsed.data.features)\n const requestedOrganizations = parsed.data.organizations === undefined\n ? existingOrganizations\n : normalizeOrganizations(parsed.data.organizations)\n\n try {\n await assertActorCanGrantAcl({\n em,\n rbacService,\n actorUserId: auth.sub,\n tenantId: targetTenantId,\n organizationId: auth.orgId ?? null,\n isSuperAdmin: requestedIsSuperAdmin,\n features: requestedFeatures,\n organizations: requestedOrganizations,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n\n // Route the write through the command bus so the permission change lands in\n // the action log. The command owns the transactional write and the RBAC cache\n // invalidation that used to live here.\n const commandBus = container.resolve('commandBus') as CommandBus\n // A super admin may edit a role in another tenant. The actor's organization\n // belongs to *their* tenant, and the command bus resolves the log row's\n // organization as `metadata.organizationId ?? ctx.selectedOrganizationId ??\n // ctx.auth.orgId` \u2014 a `??` chain, so a handler cannot express \"explicitly no\n // organization\" on its own. Strip the organization from the context instead,\n // otherwise the entry is stamped (target tenant, actor's foreign organization)\n // and `ActionLogService` \u2014 which filters organization with strict equality \u2014\n // can never surface it for anyone.\n const isForeignTenant = targetTenantId !== (auth.tenantId ?? null)\n const actorOrgId = isForeignTenant ? null : auth.orgId ?? null\n const commandCtx: CommandRuntimeContext = {\n container,\n auth: isForeignTenant ? { ...auth, orgId: null } : auth,\n organizationScope: null,\n selectedOrganizationId: actorOrgId,\n organizationIds: actorOrgId ? [actorOrgId] : null,\n request: req,\n }\n await commandBus.execute<RoleAclUpdateInput, AclUpdateResult>(AUTH_ROLE_ACL_UPDATE_COMMAND_ID, {\n input: {\n roleId: String(role.id),\n tenantId: targetTenantId,\n isSuperAdmin: requestedIsSuperAdmin,\n features: requestedFeatures,\n organizations: requestedOrganizations,\n },\n ctx: commandCtx,\n })\n\n return NextResponse.json({\n ok: true,\n sanitized: false,\n })\n}\n\nfunction normalizeOrganizations(organizations: unknown): string[] | null {\n if (!Array.isArray(organizations)) return null\n return normalizeGrantFeatureList(organizations)\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Authentication & Accounts',\n summary: 'Role ACL management',\n methods: {\n GET: {\n summary: 'Fetch role ACL',\n description: 'Returns the feature and organization assignments associated with a role within the current tenant.',\n query: getSchema,\n responses: [\n { status: 200, description: 'Role ACL entry', schema: roleAclResponseSchema },\n { status: 400, description: 'Invalid role id', schema: roleAclErrorSchema },\n { status: 401, description: 'Unauthorized', schema: roleAclErrorSchema },\n { status: 404, description: 'Role not found', schema: roleAclErrorSchema },\n ],\n },\n PUT: {\n summary: 'Update role ACL',\n description: 'Replaces the feature list, super admin flag, and optional organization assignments for a role.',\n requestBody: {\n contentType: 'application/json',\n schema: putSchema,\n },\n responses: [\n { status: 200, description: 'Role ACL updated', schema: roleAclUpdateResponseSchema },\n { status: 400, description: 'Invalid payload', schema: roleAclErrorSchema },\n { status: 401, description: 'Unauthorized', schema: roleAclErrorSchema },\n { status: 403, description: 'Insufficient privileges to modify ACL', schema: roleAclErrorSchema },\n { status: 404, description: 'Role not found', schema: roleAclErrorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -168,7 +168,7 @@ async function GET(req) {
|
|
|
168
168
|
}
|
|
169
169
|
let superAdminRoleIds = null;
|
|
170
170
|
if (!isSuperAdmin && actorTenantId) {
|
|
171
|
-
const superAdminAcls = await findWithDecryption(em, RoleAcl, { tenantId: actorTenantId, isSuperAdmin: true }, {}, { tenantId: actorTenantId, organizationId: null });
|
|
171
|
+
const superAdminAcls = await findWithDecryption(em, RoleAcl, { tenantId: actorTenantId, isSuperAdmin: true, deletedAt: null }, {}, { tenantId: actorTenantId, organizationId: null });
|
|
172
172
|
if (superAdminAcls.length) {
|
|
173
173
|
superAdminRoleIds = new Set(
|
|
174
174
|
superAdminAcls.map((acl) => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/auth/api/roles/route.ts"],
|
|
4
|
-
"sourcesContent": ["/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { logCrudAccess, makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { Role, RoleAcl, UserRole } from '@open-mercato/core/modules/auth/data/entities'\nimport { Tenant } from '@open-mercato/core/modules/directory/data/entities'\nimport { E } from '#generated/entities.ids.generated'\nimport { loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'\nimport { roleCrudEvents, roleCrudIndexer } from '@open-mercato/core/modules/auth/commands/roles'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { assertActorCanModifySuperAdminRoleTarget } from '@open-mercato/core/modules/auth/lib/grantChecks'\nimport { enforceRoleTenantAccess } from '@open-mercato/core/modules/auth/lib/roleTenantGuard'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { runWithCacheTenant } from '@open-mercato/cache'\nimport {\n buildCollectionTags,\n isCrudCacheEnabled,\n resolveCrudCache,\n} from '@open-mercato/shared/lib/crud/cache'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst ROLES_LIST_CACHE_TTL_MS = 120_000\nconst logger = createLogger('auth').child({ component: 'roles' })\n\nfunction buildRolesListCacheKey(scope: {\n tenantId: string | null\n isSuperAdmin: boolean\n actorTenantId: string | null\n tenantFilter: string | null\n id?: string\n page: number\n pageSize: number\n search?: string\n}): string {\n const parts = [\n 'auth:roles:list',\n `tenant:${scope.tenantId ?? 'null'}`,\n `super:${scope.isSuperAdmin ? '1' : '0'}`,\n `actor:${scope.actorTenantId ?? 'null'}`,\n `filter:${scope.tenantFilter ?? 'null'}`,\n `id:${scope.id ?? ''}`,\n `page:${scope.page}`,\n `size:${scope.pageSize}`,\n `q:${scope.search ?? ''}`,\n ]\n return parts.join('|')\n}\n\nconst querySchema = z.object({\n id: z.string().uuid().optional(),\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(50),\n search: z.string().optional(),\n tenantId: z.string().uuid().optional(),\n}).passthrough()\n\nconst roleCreateSchema = z.object({\n name: z.string().min(2).max(100),\n tenantId: z.string().uuid().optional(),\n})\n\nconst roleUpdateSchema = z.object({\n id: z.string().uuid(),\n name: z.string().min(2).max(100).optional(),\n tenantId: z.string().uuid().optional(),\n})\n\nconst roleListItemSchema = z.object({\n id: z.string().uuid(),\n name: z.string(),\n usersCount: z.number().int().nonnegative(),\n tenantId: z.string().uuid().nullable(),\n tenantIds: z.array(z.string().uuid()).optional(),\n tenantName: z.string().nullable(),\n updatedAt: z.string().nullable().optional(),\n})\n\nconst roleListResponseSchema = z.object({\n items: z.array(roleListItemSchema),\n total: z.number().int().nonnegative(),\n totalPages: z.number().int().positive(),\n isSuperAdmin: z.boolean().optional(),\n})\n\nconst okResponseSchema = z.object({ ok: z.literal(true) })\n\nconst errorResponseSchema = z.object({ error: z.string() })\n\nconst routeMetadata = {\n GET: { requireAuth: true, requireFeatures: ['auth.roles.list'] },\n POST: { requireAuth: true, requireFeatures: ['auth.roles.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['auth.roles.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['auth.roles.manage'] },\n}\n\nexport const metadata = routeMetadata\n\nconst rawBodySchema = z.object({}).passthrough()\ntype CrudInput = Record<string, unknown>\n\nconst crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({\n metadata: routeMetadata,\n orm: {\n entity: Role,\n idField: 'id',\n orgField: null,\n tenantField: null,\n softDeleteField: 'deletedAt',\n },\n events: roleCrudEvents,\n indexer: roleCrudIndexer,\n actions: {\n create: {\n commandId: 'auth.roles.create',\n schema: rawBodySchema,\n mapInput: async ({ parsed, ctx }) =>\n enforceRoleTenantAccess('create', parsed, { auth: ctx.auth, container: ctx.container }),\n response: ({ result }) => ({ id: String(result.id) }),\n status: 201,\n },\n update: {\n commandId: 'auth.roles.update',\n schema: rawBodySchema,\n mapInput: async ({ parsed, ctx }) => {\n if (ctx.request && typeof parsed.id === 'string' && parsed.id.length) {\n await assertCanModifySuperAdminRole(ctx.request, parsed.id)\n }\n return enforceRoleTenantAccess('update', parsed, { auth: ctx.auth, container: ctx.container })\n },\n response: () => ({ ok: true }),\n },\n delete: {\n commandId: 'auth.roles.delete',\n mapInput: async ({ parsed, raw, ctx }) => {\n const targetId = resolveDeleteTargetId(parsed, raw)\n if (ctx.request && targetId) {\n await assertCanModifySuperAdminRole(ctx.request, targetId)\n }\n return enforceRoleTenantAccess('delete', parsed, { auth: ctx.auth, container: ctx.container })\n },\n response: () => ({ ok: true }),\n },\n },\n})\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) return NextResponse.json({ items: [], total: 0, totalPages: 1 })\n const url = new URL(req.url)\n const parsed = querySchema.safeParse({\n id: url.searchParams.get('id') || undefined,\n page: url.searchParams.get('page') || undefined,\n pageSize: url.searchParams.get('pageSize') || undefined,\n search: url.searchParams.get('search') || undefined,\n tenantId: url.searchParams.get('tenantId') || undefined,\n })\n if (!parsed.success) return NextResponse.json({ items: [], total: 0, totalPages: 1 })\n const container = await createRequestContainer()\n const em = (container.resolve('em') as EntityManager)\n let isSuperAdmin = false\n try {\n if (auth.sub) {\n const rbacService = container.resolve('rbacService') as any\n const acl = await rbacService.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })\n isSuperAdmin = !!acl?.isSuperAdmin\n }\n } catch (err) {\n logger.error('Failed to resolve rbac', { err })\n }\n const actorTenantId = auth.tenantId ? String(auth.tenantId) : null\n if (!isSuperAdmin && !actorTenantId) {\n return NextResponse.json({ items: [], total: 0, totalPages: 1, isSuperAdmin })\n }\n const { id, page, pageSize, search, tenantId: requestedTenantId } = parsed.data\n const tenantFilter = isSuperAdmin && requestedTenantId ? String(requestedTenantId) : null\n const cacheTenantId = isSuperAdmin ? tenantFilter : actorTenantId\n // An unfiltered super-admin response spans tenants and cannot be invalidated\n // by any one tenant's collection tags, so keep that view uncached.\n const cache = isCrudCacheEnabled() && cacheTenantId ? resolveCrudCache(container) : null\n const cacheKey = cache\n ? buildRolesListCacheKey({\n tenantId: cacheTenantId,\n isSuperAdmin,\n actorTenantId,\n tenantFilter,\n id,\n page,\n pageSize,\n search,\n })\n : null\n if (cache && cacheKey) {\n const cached = await runWithCacheTenant(cacheTenantId, () => cache.get(cacheKey))\n if (cached) return NextResponse.json(cached)\n }\n let superAdminRoleIds: Set<string> | null = null\n if (!isSuperAdmin && actorTenantId) {\n const superAdminAcls = await findWithDecryption(em, RoleAcl, { tenantId: actorTenantId, isSuperAdmin: true }, {}, { tenantId: actorTenantId, organizationId: null })\n if (superAdminAcls.length) {\n superAdminRoleIds = new Set(\n superAdminAcls\n .map((acl) => {\n const roleRef = acl.role\n const idValue = roleRef?.id\n return idValue ? String(idValue) : null\n })\n .filter((id): id is string => !!id),\n )\n } else {\n superAdminRoleIds = new Set()\n }\n }\n const filters: any[] = [{ deletedAt: null }]\n if (id) filters.push({ id })\n if (search) filters.push({ name: { $ilike: `%${escapeLikePattern(search)}%` } })\n if (!isSuperAdmin && actorTenantId) {\n filters.push({ tenantId: actorTenantId })\n filters.push({ name: { $ne: 'superadmin' } })\n if (superAdminRoleIds && superAdminRoleIds.size) {\n filters.push({ id: { $nin: Array.from(superAdminRoleIds) } })\n }\n } else if (tenantFilter) {\n filters.push({ tenantId: tenantFilter })\n }\n const where = filters.length > 1 ? { $and: filters } : filters[0]\n const [rows, count] = await em.findAndCount(Role, where, { limit: pageSize, offset: (page - 1) * pageSize })\n const roleIds = rows.map((r: any) => String(r.id))\n const counts: Record<string, number> = {}\n if (roleIds.length) {\n const userRoleFilter: FilterQuery<UserRole> = { role: { $in: roleIds }, deletedAt: null }\n const links = await findWithDecryption(em, UserRole, userRoleFilter, {}, { tenantId: null, organizationId: null })\n for (const l of links) {\n const rid = String((l as any).role?.id || (l as any).role)\n counts[rid] = (counts[rid] || 0) + 1\n }\n }\n const roleTenantIds = rows\n .map((role: any) => (role.tenantId ? String(role.tenantId) : null))\n .filter((tenantId): tenantId is string => typeof tenantId === 'string' && tenantId.length > 0)\n const uniqueTenantIds = Array.from(new Set(roleTenantIds))\n let tenantMap: Record<string, string> = {}\n if (uniqueTenantIds.length) {\n const tenants = await findWithDecryption(em, Tenant, { id: { $in: uniqueTenantIds as any }, deletedAt: null }, {}, { tenantId: null, organizationId: null })\n tenantMap = tenants.reduce<Record<string, string>>((acc, tenant) => {\n const tid = tenant?.id ? String(tenant.id) : null\n if (!tid) return acc\n const rawName = (tenant as any)?.name\n const name = typeof rawName === 'string' && rawName.length > 0 ? rawName : tid\n acc[tid] = name\n return acc\n }, {})\n }\n const tenantByRole: Record<string, string | null> = {}\n for (const role of rows) {\n const rid = String(role.id)\n tenantByRole[rid] = role.tenantId ? String(role.tenantId) : null\n }\n const tenantFallbacks = Array.from(new Set<string | null>([\n auth.tenantId ?? null,\n tenantFilter ?? null,\n ...Object.values(tenantByRole),\n ]))\n const cfByRole = roleIds.length\n ? await loadCustomFieldValues({\n em,\n entityId: E.auth.role,\n recordIds: roleIds,\n tenantIdByRecord: tenantByRole,\n tenantFallbacks,\n })\n : {}\n const items = rows.map((r: any) => {\n const idStr = String(r.id)\n const tenantId = tenantByRole[idStr]\n const tenantName = tenantId ? tenantMap[tenantId] ?? tenantId : null\n const exposeTenant = isSuperAdmin || (tenantId && auth.tenantId && tenantId === auth.tenantId)\n return {\n id: idStr,\n name: String(r.name),\n usersCount: counts[idStr] || 0,\n tenantId: tenantId ?? null,\n tenantIds: exposeTenant && tenantId ? [tenantId] : [],\n tenantName: exposeTenant ? tenantName : null,\n updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : null,\n ...(cfByRole[idStr] || {}),\n }\n })\n const totalPages = Math.max(1, Math.ceil(count / pageSize))\n await logCrudAccess({\n container,\n auth,\n request: req,\n items,\n idField: 'id',\n resourceKind: 'auth.role',\n organizationId: null,\n tenantId: auth.tenantId ?? null,\n query: parsed.data,\n accessType: id ? 'read:item' : undefined,\n })\n const payload = { items, total: count, totalPages, isSuperAdmin }\n if (cache && cacheKey) {\n try {\n await runWithCacheTenant(cacheTenantId, () =>\n cache.set(cacheKey, payload, {\n ttl: ROLES_LIST_CACHE_TTL_MS,\n tags: [\n // Roles are tenant-level (org:null) \u2014 flushed on auth.roles.* command writes.\n ...buildCollectionTags('auth.role', cacheTenantId, [null]),\n // usersCount derives from UserRole grants \u2014 auth.users.* command writes\n // flush both tenant-level and record-organization collection tags.\n ...buildCollectionTags('auth.user', cacheTenantId, [null]),\n // Role-ACL feature changes flush rbac:tenant:<T> from the roles/acl route.\n `rbac:tenant:${cacheTenantId ?? 'null'}`,\n ],\n }),\n )\n } catch (err) {\n logger.warn('Failed to set roles list cache', { err })\n }\n }\n return NextResponse.json(payload)\n}\n\nexport const POST = crud.POST\nexport const PUT = crud.PUT\nexport const DELETE = async (req: Request) => {\n const targetId = new URL(req.url).searchParams.get('id')\n if (targetId) {\n try {\n await assertCanModifySuperAdminRole(req, targetId)\n } catch (err) {\n if (err instanceof CrudHttpError) {\n return NextResponse.json(err.body, { status: err.status })\n }\n throw err\n }\n }\n return crud.DELETE(req)\n}\n\nasync function assertCanModifySuperAdminRole(req: Request, targetRoleId: string) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub) throw new CrudHttpError(401, { error: 'Unauthorized' })\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n await assertActorCanModifySuperAdminRoleTarget({\n em,\n rbacService: container.resolve('rbacService') as RbacService,\n actorUserId: auth.sub,\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n targetRoleId,\n })\n}\n\nfunction resolveDeleteTargetId(parsed: unknown, raw: unknown): string | null {\n const fromParsed = readId(parsed as Record<string, unknown> | null | undefined)\n if (fromParsed) return fromParsed\n const rawRecord = raw as { body?: Record<string, unknown>; query?: Record<string, unknown> } | null | undefined\n return readId(rawRecord?.query) ?? readId(rawRecord?.body)\n}\n\nfunction readId(record: Record<string, unknown> | null | undefined): string | null {\n const value = record?.id\n return typeof value === 'string' && value.length > 0 ? value : null\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Authentication & Accounts',\n summary: 'Role management',\n methods: {\n GET: {\n summary: 'List roles',\n description:\n 'Returns available roles within the current tenant. Super administrators receive visibility across tenants.',\n query: querySchema,\n responses: [\n { status: 200, description: 'Role collection', schema: roleListResponseSchema },\n ],\n },\n POST: {\n summary: 'Create role',\n description: 'Creates a new role anchored to the caller\\'s tenant. Non-superadmins cannot target another tenant; supplying a foreign `tenantId` is rejected.',\n requestBody: {\n contentType: 'application/json',\n schema: roleCreateSchema,\n },\n responses: [\n {\n status: 201,\n description: 'Role created',\n schema: z.object({ id: z.string().uuid() }),\n },\n ],\n errors: [\n { status: 400, description: 'Invalid payload', schema: errorResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n ],\n },\n PUT: {\n summary: 'Update role',\n description: 'Updates mutable fields on an existing role.',\n requestBody: {\n contentType: 'application/json',\n schema: roleUpdateSchema,\n },\n responses: [\n {\n status: 200,\n description: 'Role updated',\n schema: okResponseSchema,\n },\n ],\n errors: [\n { status: 400, description: 'Invalid payload', schema: errorResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n { status: 404, description: 'Role not found', schema: errorResponseSchema },\n ],\n },\n DELETE: {\n summary: 'Delete role',\n description: 'Deletes a role by identifier. Fails when users remain assigned.',\n query: z.object({ id: z.string().uuid().describe('Role identifier') }),\n responses: [\n { status: 200, description: 'Role deleted', schema: okResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Role cannot be deleted', schema: errorResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n { status: 404, description: 'Role not found', schema: errorResponseSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { logCrudAccess, makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { Role, RoleAcl, UserRole } from '@open-mercato/core/modules/auth/data/entities'\nimport { Tenant } from '@open-mercato/core/modules/directory/data/entities'\nimport { E } from '#generated/entities.ids.generated'\nimport { loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'\nimport { roleCrudEvents, roleCrudIndexer } from '@open-mercato/core/modules/auth/commands/roles'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { assertActorCanModifySuperAdminRoleTarget } from '@open-mercato/core/modules/auth/lib/grantChecks'\nimport { enforceRoleTenantAccess } from '@open-mercato/core/modules/auth/lib/roleTenantGuard'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { runWithCacheTenant } from '@open-mercato/cache'\nimport {\n buildCollectionTags,\n isCrudCacheEnabled,\n resolveCrudCache,\n} from '@open-mercato/shared/lib/crud/cache'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst ROLES_LIST_CACHE_TTL_MS = 120_000\nconst logger = createLogger('auth').child({ component: 'roles' })\n\nfunction buildRolesListCacheKey(scope: {\n tenantId: string | null\n isSuperAdmin: boolean\n actorTenantId: string | null\n tenantFilter: string | null\n id?: string\n page: number\n pageSize: number\n search?: string\n}): string {\n const parts = [\n 'auth:roles:list',\n `tenant:${scope.tenantId ?? 'null'}`,\n `super:${scope.isSuperAdmin ? '1' : '0'}`,\n `actor:${scope.actorTenantId ?? 'null'}`,\n `filter:${scope.tenantFilter ?? 'null'}`,\n `id:${scope.id ?? ''}`,\n `page:${scope.page}`,\n `size:${scope.pageSize}`,\n `q:${scope.search ?? ''}`,\n ]\n return parts.join('|')\n}\n\nconst querySchema = z.object({\n id: z.string().uuid().optional(),\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(50),\n search: z.string().optional(),\n tenantId: z.string().uuid().optional(),\n}).passthrough()\n\nconst roleCreateSchema = z.object({\n name: z.string().min(2).max(100),\n tenantId: z.string().uuid().optional(),\n})\n\nconst roleUpdateSchema = z.object({\n id: z.string().uuid(),\n name: z.string().min(2).max(100).optional(),\n tenantId: z.string().uuid().optional(),\n})\n\nconst roleListItemSchema = z.object({\n id: z.string().uuid(),\n name: z.string(),\n usersCount: z.number().int().nonnegative(),\n tenantId: z.string().uuid().nullable(),\n tenantIds: z.array(z.string().uuid()).optional(),\n tenantName: z.string().nullable(),\n updatedAt: z.string().nullable().optional(),\n})\n\nconst roleListResponseSchema = z.object({\n items: z.array(roleListItemSchema),\n total: z.number().int().nonnegative(),\n totalPages: z.number().int().positive(),\n isSuperAdmin: z.boolean().optional(),\n})\n\nconst okResponseSchema = z.object({ ok: z.literal(true) })\n\nconst errorResponseSchema = z.object({ error: z.string() })\n\nconst routeMetadata = {\n GET: { requireAuth: true, requireFeatures: ['auth.roles.list'] },\n POST: { requireAuth: true, requireFeatures: ['auth.roles.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['auth.roles.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['auth.roles.manage'] },\n}\n\nexport const metadata = routeMetadata\n\nconst rawBodySchema = z.object({}).passthrough()\ntype CrudInput = Record<string, unknown>\n\nconst crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({\n metadata: routeMetadata,\n orm: {\n entity: Role,\n idField: 'id',\n orgField: null,\n tenantField: null,\n softDeleteField: 'deletedAt',\n },\n events: roleCrudEvents,\n indexer: roleCrudIndexer,\n actions: {\n create: {\n commandId: 'auth.roles.create',\n schema: rawBodySchema,\n mapInput: async ({ parsed, ctx }) =>\n enforceRoleTenantAccess('create', parsed, { auth: ctx.auth, container: ctx.container }),\n response: ({ result }) => ({ id: String(result.id) }),\n status: 201,\n },\n update: {\n commandId: 'auth.roles.update',\n schema: rawBodySchema,\n mapInput: async ({ parsed, ctx }) => {\n if (ctx.request && typeof parsed.id === 'string' && parsed.id.length) {\n await assertCanModifySuperAdminRole(ctx.request, parsed.id)\n }\n return enforceRoleTenantAccess('update', parsed, { auth: ctx.auth, container: ctx.container })\n },\n response: () => ({ ok: true }),\n },\n delete: {\n commandId: 'auth.roles.delete',\n mapInput: async ({ parsed, raw, ctx }) => {\n const targetId = resolveDeleteTargetId(parsed, raw)\n if (ctx.request && targetId) {\n await assertCanModifySuperAdminRole(ctx.request, targetId)\n }\n return enforceRoleTenantAccess('delete', parsed, { auth: ctx.auth, container: ctx.container })\n },\n response: () => ({ ok: true }),\n },\n },\n})\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) return NextResponse.json({ items: [], total: 0, totalPages: 1 })\n const url = new URL(req.url)\n const parsed = querySchema.safeParse({\n id: url.searchParams.get('id') || undefined,\n page: url.searchParams.get('page') || undefined,\n pageSize: url.searchParams.get('pageSize') || undefined,\n search: url.searchParams.get('search') || undefined,\n tenantId: url.searchParams.get('tenantId') || undefined,\n })\n if (!parsed.success) return NextResponse.json({ items: [], total: 0, totalPages: 1 })\n const container = await createRequestContainer()\n const em = (container.resolve('em') as EntityManager)\n let isSuperAdmin = false\n try {\n if (auth.sub) {\n const rbacService = container.resolve('rbacService') as any\n const acl = await rbacService.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })\n isSuperAdmin = !!acl?.isSuperAdmin\n }\n } catch (err) {\n logger.error('Failed to resolve rbac', { err })\n }\n const actorTenantId = auth.tenantId ? String(auth.tenantId) : null\n if (!isSuperAdmin && !actorTenantId) {\n return NextResponse.json({ items: [], total: 0, totalPages: 1, isSuperAdmin })\n }\n const { id, page, pageSize, search, tenantId: requestedTenantId } = parsed.data\n const tenantFilter = isSuperAdmin && requestedTenantId ? String(requestedTenantId) : null\n const cacheTenantId = isSuperAdmin ? tenantFilter : actorTenantId\n // An unfiltered super-admin response spans tenants and cannot be invalidated\n // by any one tenant's collection tags, so keep that view uncached.\n const cache = isCrudCacheEnabled() && cacheTenantId ? resolveCrudCache(container) : null\n const cacheKey = cache\n ? buildRolesListCacheKey({\n tenantId: cacheTenantId,\n isSuperAdmin,\n actorTenantId,\n tenantFilter,\n id,\n page,\n pageSize,\n search,\n })\n : null\n if (cache && cacheKey) {\n const cached = await runWithCacheTenant(cacheTenantId, () => cache.get(cacheKey))\n if (cached) return NextResponse.json(cached)\n }\n let superAdminRoleIds: Set<string> | null = null\n if (!isSuperAdmin && actorTenantId) {\n const superAdminAcls = await findWithDecryption(em, RoleAcl, { tenantId: actorTenantId, isSuperAdmin: true, deletedAt: null }, {}, { tenantId: actorTenantId, organizationId: null })\n if (superAdminAcls.length) {\n superAdminRoleIds = new Set(\n superAdminAcls\n .map((acl) => {\n const roleRef = acl.role\n const idValue = roleRef?.id\n return idValue ? String(idValue) : null\n })\n .filter((id): id is string => !!id),\n )\n } else {\n superAdminRoleIds = new Set()\n }\n }\n const filters: any[] = [{ deletedAt: null }]\n if (id) filters.push({ id })\n if (search) filters.push({ name: { $ilike: `%${escapeLikePattern(search)}%` } })\n if (!isSuperAdmin && actorTenantId) {\n filters.push({ tenantId: actorTenantId })\n filters.push({ name: { $ne: 'superadmin' } })\n if (superAdminRoleIds && superAdminRoleIds.size) {\n filters.push({ id: { $nin: Array.from(superAdminRoleIds) } })\n }\n } else if (tenantFilter) {\n filters.push({ tenantId: tenantFilter })\n }\n const where = filters.length > 1 ? { $and: filters } : filters[0]\n const [rows, count] = await em.findAndCount(Role, where, { limit: pageSize, offset: (page - 1) * pageSize })\n const roleIds = rows.map((r: any) => String(r.id))\n const counts: Record<string, number> = {}\n if (roleIds.length) {\n const userRoleFilter: FilterQuery<UserRole> = { role: { $in: roleIds }, deletedAt: null }\n const links = await findWithDecryption(em, UserRole, userRoleFilter, {}, { tenantId: null, organizationId: null })\n for (const l of links) {\n const rid = String((l as any).role?.id || (l as any).role)\n counts[rid] = (counts[rid] || 0) + 1\n }\n }\n const roleTenantIds = rows\n .map((role: any) => (role.tenantId ? String(role.tenantId) : null))\n .filter((tenantId): tenantId is string => typeof tenantId === 'string' && tenantId.length > 0)\n const uniqueTenantIds = Array.from(new Set(roleTenantIds))\n let tenantMap: Record<string, string> = {}\n if (uniqueTenantIds.length) {\n const tenants = await findWithDecryption(em, Tenant, { id: { $in: uniqueTenantIds as any }, deletedAt: null }, {}, { tenantId: null, organizationId: null })\n tenantMap = tenants.reduce<Record<string, string>>((acc, tenant) => {\n const tid = tenant?.id ? String(tenant.id) : null\n if (!tid) return acc\n const rawName = (tenant as any)?.name\n const name = typeof rawName === 'string' && rawName.length > 0 ? rawName : tid\n acc[tid] = name\n return acc\n }, {})\n }\n const tenantByRole: Record<string, string | null> = {}\n for (const role of rows) {\n const rid = String(role.id)\n tenantByRole[rid] = role.tenantId ? String(role.tenantId) : null\n }\n const tenantFallbacks = Array.from(new Set<string | null>([\n auth.tenantId ?? null,\n tenantFilter ?? null,\n ...Object.values(tenantByRole),\n ]))\n const cfByRole = roleIds.length\n ? await loadCustomFieldValues({\n em,\n entityId: E.auth.role,\n recordIds: roleIds,\n tenantIdByRecord: tenantByRole,\n tenantFallbacks,\n })\n : {}\n const items = rows.map((r: any) => {\n const idStr = String(r.id)\n const tenantId = tenantByRole[idStr]\n const tenantName = tenantId ? tenantMap[tenantId] ?? tenantId : null\n const exposeTenant = isSuperAdmin || (tenantId && auth.tenantId && tenantId === auth.tenantId)\n return {\n id: idStr,\n name: String(r.name),\n usersCount: counts[idStr] || 0,\n tenantId: tenantId ?? null,\n tenantIds: exposeTenant && tenantId ? [tenantId] : [],\n tenantName: exposeTenant ? tenantName : null,\n updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : null,\n ...(cfByRole[idStr] || {}),\n }\n })\n const totalPages = Math.max(1, Math.ceil(count / pageSize))\n await logCrudAccess({\n container,\n auth,\n request: req,\n items,\n idField: 'id',\n resourceKind: 'auth.role',\n organizationId: null,\n tenantId: auth.tenantId ?? null,\n query: parsed.data,\n accessType: id ? 'read:item' : undefined,\n })\n const payload = { items, total: count, totalPages, isSuperAdmin }\n if (cache && cacheKey) {\n try {\n await runWithCacheTenant(cacheTenantId, () =>\n cache.set(cacheKey, payload, {\n ttl: ROLES_LIST_CACHE_TTL_MS,\n tags: [\n // Roles are tenant-level (org:null) \u2014 flushed on auth.roles.* command writes.\n ...buildCollectionTags('auth.role', cacheTenantId, [null]),\n // usersCount derives from UserRole grants \u2014 auth.users.* command writes\n // flush both tenant-level and record-organization collection tags.\n ...buildCollectionTags('auth.user', cacheTenantId, [null]),\n // Role-ACL feature changes flush rbac:tenant:<T> from the roles/acl route.\n `rbac:tenant:${cacheTenantId ?? 'null'}`,\n ],\n }),\n )\n } catch (err) {\n logger.warn('Failed to set roles list cache', { err })\n }\n }\n return NextResponse.json(payload)\n}\n\nexport const POST = crud.POST\nexport const PUT = crud.PUT\nexport const DELETE = async (req: Request) => {\n const targetId = new URL(req.url).searchParams.get('id')\n if (targetId) {\n try {\n await assertCanModifySuperAdminRole(req, targetId)\n } catch (err) {\n if (err instanceof CrudHttpError) {\n return NextResponse.json(err.body, { status: err.status })\n }\n throw err\n }\n }\n return crud.DELETE(req)\n}\n\nasync function assertCanModifySuperAdminRole(req: Request, targetRoleId: string) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub) throw new CrudHttpError(401, { error: 'Unauthorized' })\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n await assertActorCanModifySuperAdminRoleTarget({\n em,\n rbacService: container.resolve('rbacService') as RbacService,\n actorUserId: auth.sub,\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n targetRoleId,\n })\n}\n\nfunction resolveDeleteTargetId(parsed: unknown, raw: unknown): string | null {\n const fromParsed = readId(parsed as Record<string, unknown> | null | undefined)\n if (fromParsed) return fromParsed\n const rawRecord = raw as { body?: Record<string, unknown>; query?: Record<string, unknown> } | null | undefined\n return readId(rawRecord?.query) ?? readId(rawRecord?.body)\n}\n\nfunction readId(record: Record<string, unknown> | null | undefined): string | null {\n const value = record?.id\n return typeof value === 'string' && value.length > 0 ? value : null\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Authentication & Accounts',\n summary: 'Role management',\n methods: {\n GET: {\n summary: 'List roles',\n description:\n 'Returns available roles within the current tenant. Super administrators receive visibility across tenants.',\n query: querySchema,\n responses: [\n { status: 200, description: 'Role collection', schema: roleListResponseSchema },\n ],\n },\n POST: {\n summary: 'Create role',\n description: 'Creates a new role anchored to the caller\\'s tenant. Non-superadmins cannot target another tenant; supplying a foreign `tenantId` is rejected.',\n requestBody: {\n contentType: 'application/json',\n schema: roleCreateSchema,\n },\n responses: [\n {\n status: 201,\n description: 'Role created',\n schema: z.object({ id: z.string().uuid() }),\n },\n ],\n errors: [\n { status: 400, description: 'Invalid payload', schema: errorResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n ],\n },\n PUT: {\n summary: 'Update role',\n description: 'Updates mutable fields on an existing role.',\n requestBody: {\n contentType: 'application/json',\n schema: roleUpdateSchema,\n },\n responses: [\n {\n status: 200,\n description: 'Role updated',\n schema: okResponseSchema,\n },\n ],\n errors: [\n { status: 400, description: 'Invalid payload', schema: errorResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n { status: 404, description: 'Role not found', schema: errorResponseSchema },\n ],\n },\n DELETE: {\n summary: 'Delete role',\n description: 'Deletes a role by identifier. Fails when users remain assigned.',\n query: z.object({ id: z.string().uuid().describe('Role identifier') }),\n responses: [\n { status: 200, description: 'Role deleted', schema: okResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Role cannot be deleted', schema: errorResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n { status: 404, description: 'Role not found', schema: errorResponseSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["id"]
|
|
7
7
|
}
|
|
@@ -117,7 +117,11 @@ async function GET(req) {
|
|
|
117
117
|
throw err;
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
|
-
const acl = tenantId ? await em.findOne(UserAcl, {
|
|
120
|
+
const acl = tenantId ? await em.findOne(UserAcl, {
|
|
121
|
+
user: parsed.data.userId,
|
|
122
|
+
tenantId,
|
|
123
|
+
deletedAt: null
|
|
124
|
+
}) : null;
|
|
121
125
|
const response = acl ? {
|
|
122
126
|
hasCustomAcl: true,
|
|
123
127
|
isSuperAdmin: !!acl.isSuperAdmin,
|
|
@@ -192,7 +196,11 @@ async function PUT(req) {
|
|
|
192
196
|
throw err;
|
|
193
197
|
}
|
|
194
198
|
}
|
|
195
|
-
const acl = await em.findOne(UserAcl, {
|
|
199
|
+
const acl = await em.findOne(UserAcl, {
|
|
200
|
+
user: parsed.data.userId,
|
|
201
|
+
tenantId,
|
|
202
|
+
deletedAt: null
|
|
203
|
+
});
|
|
196
204
|
if (acl) {
|
|
197
205
|
try {
|
|
198
206
|
await enforceCommandOptimisticLockWithGuards(container, {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/auth/api/users/acl/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest, type AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { logCrudAccess } from '@open-mercato/shared/lib/crud/factory'\nimport { forbidden, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { User, UserAcl } from '@open-mercato/core/modules/auth/data/entities'\nimport {\n assertActorCanAccessUserTarget,\n assertActorCanGrantAcl,\n assertActorCanModifySuperAdminUserTarget,\n normalizeGrantFeatureList,\n} from '@open-mercato/core/modules/auth/lib/grantChecks'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport {\n AUTH_USER_ACL_UPDATE_COMMAND_ID,\n type AclUpdateResult,\n type UserAclUpdateInput,\n} from '@open-mercato/core/modules/auth/commands/acl'\n\nconst getSchema = z.object({\n userId: z.string().uuid(),\n tenantId: z.string().uuid().optional(),\n})\nconst putSchema = z.object({\n userId: z.string().uuid(),\n isSuperAdmin: z.boolean().optional(),\n features: z.array(z.string()).optional(),\n organizations: z.array(z.string()).nullable().optional(),\n tenantId: z.string().uuid().optional(),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['auth.acl.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['auth.acl.manage'] },\n}\n\nconst userAclResponseSchema = z.object({\n hasCustomAcl: z.boolean(),\n isSuperAdmin: z.boolean(),\n features: z.array(z.string()),\n organizations: z.array(z.string()).nullable(),\n updatedAt: z.string().nullable(),\n})\n\nconst userAclUpdateResponseSchema = z.object({\n ok: z.literal(true),\n sanitized: z.boolean(),\n})\n\nconst userAclErrorSchema = z.object({ error: z.string() })\n\ntype TenantResolution = { tenantId: string | null } | { error: NextResponse }\n\n/**\n * The single tenant scope both handlers work in. GET and PUT MUST resolve it the\n * same way: the admin user form PUTs the ACL panel's state on every save, so a\n * GET that reads a narrower scope than the PUT writes hands the operator an\n * empty form and turns the next name-only edit into a silent revocation, with no\n * `updatedAt` to arm the optimistic lock against it.\n *\n * `user_acls.tenant_id` is NOT NULL while `users.tenant_id` is nullable, so a\n * global account legitimately signs in with `auth.tenantId === null` and the\n * scope has to come from the target user instead. That fallback is reserved for\n * a super admin: for anyone else it would let the target pick the scope its own\n * access is then checked against, and it decrypts a possibly foreign user ahead\n * of every guard. A tenant-less non-super-admin therefore resolves to `null`,\n * which reads as \"no override\" and refuses to write \u2014 the behaviour that held\n * before this route resolved a scope at all. The read is also skipped whenever a\n * tenant is already known.\n *\n * An explicit `tenantId` wins, mirroring the role ACL route (additive there and\n * here; no caller sends it yet) \u2014 but only for a super admin or for the actor's\n * own tenant, so it cannot widen anyone's reach. The result is that for a\n * non-super-admin the resolved scope is always the actor's own.\n */\nasync function resolveAclTenantId(args: {\n em: EntityManager\n auth: NonNullable<AuthContext>\n actorIsSuperAdmin: boolean\n userId: string\n requestedTenantId?: string\n}): Promise<TenantResolution> {\n const authTenantId = args.auth.tenantId ?? null\n if (args.requestedTenantId && args.requestedTenantId !== authTenantId && !args.actorIsSuperAdmin) {\n return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) }\n }\n const known = args.requestedTenantId ?? authTenantId\n if (known) return { tenantId: known }\n if (!args.actorIsSuperAdmin) return { tenantId: null }\n const targetUser = await findOneWithDecryption(\n args.em,\n User,\n { id: args.userId } as FilterQuery<User>,\n {},\n { tenantId: null, organizationId: args.auth.orgId ?? null },\n )\n return { tenantId: targetUser?.tenantId ? String(targetUser.tenantId) : null }\n}\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n const url = new URL(req.url)\n const parsed = getSchema.safeParse({\n userId: url.searchParams.get('userId'),\n tenantId: url.searchParams.get('tenantId') || undefined,\n })\n if (!parsed.success) return NextResponse.json({ error: 'Invalid input' }, { status: 400 })\n const container = await createRequestContainer()\n const em = container.resolve('em') as any\n const rbacService = container.resolve('rbacService') as any\n // Every grant check is answered in the actor's own scope, never in the scope\n // resolved for the record: passing the resolved one would ask a foreign tenant\n // whether this caller may act there, and for a tenant-less actor \u2014 whose scope\n // comes from the target \u2014 it would compare the target against itself.\n const actorTenantId = auth.tenantId ?? null\n const actorAcl = auth.sub\n ? await rbacService.loadAcl(auth.sub, { tenantId: actorTenantId, organizationId: auth.orgId ?? null })\n : null\n const actorIsSuperAdmin = !!actorAcl?.isSuperAdmin\n\n const resolution = await resolveAclTenantId({\n em: em as EntityManager,\n auth,\n actorIsSuperAdmin,\n userId: parsed.data.userId,\n requestedTenantId: parsed.data.tenantId,\n })\n if ('error' in resolution) return resolution.error\n // An unresolvable scope reads as \"no override\", which is what PUT then refuses\n // to write (`Tenant required`) \u2014 so the pair still cannot destroy a row.\n const tenantId = resolution.tenantId\n\n if (!actorIsSuperAdmin && auth.sub) {\n try {\n await assertActorCanModifySuperAdminUserTarget({\n em: em as EntityManager,\n rbacService: rbacService as RbacService,\n actorUserId: auth.sub,\n tenantId: actorTenantId,\n organizationId: auth.orgId ?? null,\n targetUserId: parsed.data.userId,\n actorIsSuperAdmin: false,\n })\n await assertActorCanAccessUserTarget({\n em: em as EntityManager,\n rbacService: rbacService as RbacService,\n actorUserId: auth.sub,\n tenantId: actorTenantId,\n organizationId: auth.orgId ?? null,\n targetUserId: parsed.data.userId,\n actorIsSuperAdmin: false,\n organizationScope: await resolveOrganizationScopeForRequest({\n container,\n auth,\n request: req,\n tenantId: actorTenantId,\n }),\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n }\n const acl = tenantId\n ? await em.findOne(UserAcl, { user: parsed.data.userId as any, tenantId })\n : null\n const response = acl\n ? {\n hasCustomAcl: true,\n isSuperAdmin: !!acl.isSuperAdmin,\n features: Array.isArray(acl.featuresJson) ? acl.featuresJson : [],\n organizations: Array.isArray(acl.organizationsJson) ? acl.organizationsJson : null,\n updatedAt: acl.updatedAt instanceof Date ? acl.updatedAt.toISOString() : null,\n }\n : { hasCustomAcl: false, isSuperAdmin: false, features: [], organizations: null, updatedAt: null }\n\n await logCrudAccess({\n container,\n auth,\n request: req,\n items: [{ id: parsed.data.userId, ...response }],\n idField: 'id',\n resourceKind: 'auth.user_acl',\n organizationId: auth.orgId ?? null,\n tenantId,\n query: { userId: parsed.data.userId, tenantId },\n accessType: 'read:item',\n })\n\n return NextResponse.json(response)\n}\n\nexport async function PUT(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n const body = await req.json().catch(() => ({}))\n const parsed = putSchema.safeParse(body)\n if (!parsed.success) return NextResponse.json({ error: 'Invalid input' }, { status: 400 })\n const container = await createRequestContainer()\n const em = container.resolve('em') as any\n const rbacService = container.resolve('rbacService') as any\n\n // Every grant check is answered in the actor's own scope, never in the scope\n // resolved for the record: passing the resolved one would ask a foreign tenant\n // whether this caller may act there, and for a tenant-less actor \u2014 whose scope\n // comes from the target \u2014 it would compare the target against itself.\n const actorTenantId = auth.tenantId ?? null\n const actorAcl = auth.sub\n ? await rbacService.loadAcl(auth.sub, { tenantId: actorTenantId, organizationId: auth.orgId ?? null })\n : null\n const actorIsSuperAdmin = !!actorAcl?.isSuperAdmin\n\n // The scope the row is read and written in. `auth.tenantId` is `string | null`\n // \u2014 never `undefined`, since every producer normalizes with `?? null` \u2014 so the\n // pre-fix lookup ran `tenant_id IS NULL` against a NOT NULL column: it matched\n // no row, leaving create/update to fail the NOT NULL constraint and clear to\n // no-op silently. Nothing cross-tenant was ever matched; the fix turns a 500\n // into a working, correctly scoped write.\n const resolution = await resolveAclTenantId({\n em: em as EntityManager,\n auth,\n actorIsSuperAdmin,\n userId: parsed.data.userId,\n requestedTenantId: parsed.data.tenantId,\n })\n if ('error' in resolution) return resolution.error\n const tenantId = resolution.tenantId\n if (!tenantId) return NextResponse.json({ error: 'Tenant required' }, { status: 400 })\n\n if (!actorIsSuperAdmin && auth.sub) {\n try {\n await assertActorCanModifySuperAdminUserTarget({\n em: em as EntityManager,\n rbacService: rbacService as RbacService,\n actorUserId: auth.sub,\n tenantId: actorTenantId,\n organizationId: auth.orgId ?? null,\n targetUserId: parsed.data.userId,\n actorIsSuperAdmin: false,\n })\n await assertActorCanAccessUserTarget({\n em: em as EntityManager,\n rbacService: rbacService as RbacService,\n actorUserId: auth.sub,\n tenantId: actorTenantId,\n organizationId: auth.orgId ?? null,\n targetUserId: parsed.data.userId,\n actorIsSuperAdmin: false,\n organizationScope: await resolveOrganizationScopeForRequest({\n container,\n auth,\n request: req,\n tenantId: actorTenantId,\n }),\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n }\n\n const acl = await em.findOne(UserAcl, { user: parsed.data.userId as any, tenantId })\n // Optimistic lock: refuse a stale per-user ACL overwrite so concurrent edits\n // cannot silently clobber each other (#2055). Strictly additive \u2014 a no-op when\n // the client sends no expected-version header; skipped when no ACL row exists.\n if (acl) {\n try {\n await enforceCommandOptimisticLockWithGuards(container, {\n resourceKind: 'auth.user_acl',\n resourceId: acl.id,\n current: acl.updatedAt ?? null,\n request: req,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n }\n const existingIsSuperAdmin = acl ? !!acl.isSuperAdmin : false\n const existingFeatures = acl ? normalizeGrantFeatureList(acl.featuresJson) : []\n const existingOrganizations = acl ? normalizeOrganizations(acl.organizationsJson) : null\n\n // A per-user ACL is an absolute override, so an omitted dimension must keep\n // its stored value. Normalizing an omitted `features` to `[]` or an omitted\n // `organizations` to `null` turned a single-dimension edit into a silent\n // clear, deleting the row and widening the user back to their full role.\n const featuresWereProvided = parsed.data.features !== undefined\n const requestedFeatures = featuresWereProvided\n ? normalizeGrantFeatureList(parsed.data.features)\n : existingFeatures\n const requestedOrganizations = parsed.data.organizations === undefined\n ? existingOrganizations\n : normalizeOrganizations(parsed.data.organizations)\n\n const requestedIsSuperAdmin = parsed.data.isSuperAdmin ?? existingIsSuperAdmin\n\n try {\n await assertActorCanGrantAcl({\n em: em as EntityManager,\n rbacService: rbacService as RbacService,\n actorUserId: auth.sub,\n tenantId: actorTenantId,\n organizationId: auth.orgId ?? null,\n isSuperAdmin: requestedIsSuperAdmin,\n features: requestedFeatures,\n organizations: requestedOrganizations,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n\n // An omitted feature list is already stored and in effect. Re-sanitizing it\n // during an unrelated organization edit would silently revoke grants the\n // actor did not touch, so only an explicitly submitted list is sanitized.\n const effectiveFeatures = actorIsSuperAdmin || !featuresWereProvided\n ? requestedFeatures\n : sanitizeTenantFeatures(requestedFeatures)\n\n let effectiveIsSuperAdmin = requestedIsSuperAdmin\n\n if (!actorIsSuperAdmin) {\n if (requestedIsSuperAdmin && !existingIsSuperAdmin) {\n throw forbidden('Only super administrators can grant super admin access.')\n }\n if (existingIsSuperAdmin && requestedIsSuperAdmin === false) {\n effectiveIsSuperAdmin = false\n } else {\n effectiveIsSuperAdmin = existingIsSuperAdmin\n }\n }\n\n // Retaining an organization-only override with no features would revoke every\n // role-granted feature instead of narrowing the role. Refuse that state rather\n // than persisting it or silently dropping the organization scope.\n if (!effectiveIsSuperAdmin && effectiveFeatures.length === 0 && hasOrganizationRestriction(requestedOrganizations)) {\n const { translate } = await resolveTranslations()\n return NextResponse.json({\n error: translate(\n 'auth.acl.organizationWarning',\n 'Organization restrictions require at least one feature override. Add a feature or module wildcard, or clear the organization scope before saving.',\n ),\n }, { status: 400 })\n }\n\n // An unrestricted organization list carries no override on its own, and the guard\n // above already refused the restricted-but-featureless case, so the override is\n // custom exactly when it grants super admin or at least one feature.\n const hasCustomAcl = effectiveIsSuperAdmin || effectiveFeatures.length > 0\n\n // What the caller asked for, handed to the command only when it exceeds what\n // is about to be written. `assertActorCanGrantAcl` above refuses the blatant\n // escalations; what reaches the strip is quieter \u2014 a restricted grant the\n // actor does hold \u2014 and it leaves before/after identical, so the audit entry\n // would otherwise be indistinguishable from a no-op and skipped as one.\n //\n // Features are the only axis that can be trimmed. A super-admin request is\n // either honoured or refused outright above, never silently downgraded, so\n // comparing the two flags here would be dead weight.\n //\n // Deliberately independent of the `sanitized` response flag below:\n // `hasRestrictedChanges` stays false when the trimmed result equals the\n // existing ACL, to avoid nagging the user about a save that changed nothing \u2014\n // but that is precisely an attempt the trail must keep.\n const strippedFeatures = requestedFeatures.filter((feature) => !effectiveFeatures.includes(feature))\n\n // Route the write through the command bus so the permission change lands in\n // the action log. The command owns the transactional write (or removal) and\n // the RBAC cache invalidation that used to live here.\n const commandBus = container.resolve('commandBus') as CommandBus\n // A tenant-less actor edits an override in the target user's tenant, so their\n // organization belongs to a tenant the entry is not about. The bus resolves\n // the log row's organization as `metadata.organizationId ??\n // ctx.selectedOrganizationId ?? ctx.auth.orgId` \u2014 a `??` chain, so the\n // handler's `resolveOrganizationId` cannot express \"explicitly no\n // organization\" and the actor's would be restored. Strip it from the context\n // instead, exactly as the roles route does: otherwise the entry carries a\n // (target tenant, foreign organization) pair that `ActionLogService` \u2014 which\n // filters organization with strict equality \u2014 can never surface for anyone.\n const isForeignTenant = tenantId !== (auth.tenantId ?? null)\n const actorOrgId = isForeignTenant ? null : auth.orgId ?? null\n const commandCtx: CommandRuntimeContext = {\n container,\n auth: isForeignTenant ? { ...auth, orgId: null } : auth,\n organizationScope: null,\n selectedOrganizationId: actorOrgId,\n organizationIds: actorOrgId ? [actorOrgId] : null,\n request: req,\n }\n await commandBus.execute<UserAclUpdateInput, AclUpdateResult>(AUTH_USER_ACL_UPDATE_COMMAND_ID, {\n input: {\n userId: parsed.data.userId,\n tenantId,\n isSuperAdmin: effectiveIsSuperAdmin,\n features: effectiveFeatures,\n organizations: requestedOrganizations,\n clear: !hasCustomAcl,\n requested: strippedFeatures.length ? { features: requestedFeatures } : null,\n },\n ctx: commandCtx,\n })\n\n return NextResponse.json({\n ok: true,\n sanitized: !actorIsSuperAdmin && (hasRestrictedChanges(requestedFeatures, effectiveFeatures, existingFeatures) || requestedIsSuperAdmin !== effectiveIsSuperAdmin),\n })\n}\n\nfunction normalizeOrganizations(organizations: unknown): string[] | null {\n if (!Array.isArray(organizations)) return null\n return normalizeGrantFeatureList(organizations)\n}\n\n// Whether the caller expressed an intentional narrowing. `null` and `__all__`\n// are the two documented ways to say \"every organization\"; an empty list is the\n// editor's \"no organization picked\" state, which this route treats as\n// unrestricted rather than a deliberate restriction. Only a concrete list\n// narrows, so only a concrete list has to justify itself against the feature\n// grant below.\nfunction hasOrganizationRestriction(organizations: string[] | null): boolean {\n if (!organizations || organizations.length === 0) return false\n return !organizations.includes('__all__')\n}\n\nfunction sanitizeTenantFeatures(features: string[]): string[] {\n return features.filter((feature) => !isTenantRestrictedFeature(feature))\n}\n\nfunction isTenantRestrictedFeature(feature: string): boolean {\n if (feature === '*' || feature === 'directory.*') return true\n if (feature.startsWith('directory.tenants')) return true\n return false\n}\n\nfunction hasRestrictedChanges(requested: string[], effective: string[], existing: string[]): boolean {\n if (requested.length === effective.length) return false\n const effectiveSet = new Set(effective)\n const existingSet = new Set(existing)\n // If the effective set matches existing, we only trimmed restricted duplicates and should not report\n if (effectiveSet.size === existingSet.size) {\n let identical = true\n for (const value of effectiveSet) {\n if (!existingSet.has(value)) {\n identical = false\n break\n }\n }\n if (identical) return false\n }\n return true\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Authentication & Accounts',\n summary: 'User ACL management',\n methods: {\n GET: {\n summary: 'Fetch user ACL',\n description: 'Returns custom ACL overrides for a user, scoped to the requested tenant when supplied and to the actor or target user tenant otherwise.',\n query: getSchema,\n responses: [\n { status: 200, description: 'User ACL entry', schema: userAclResponseSchema },\n { status: 400, description: 'Invalid user id', schema: userAclErrorSchema },\n { status: 401, description: 'Unauthorized', schema: userAclErrorSchema },\n { status: 403, description: 'Insufficient privileges for the requested tenant scope', schema: userAclErrorSchema },\n ],\n },\n PUT: {\n summary: 'Update user ACL',\n description: 'Updates a per-user ACL override. Omitted super admin, feature, and organization fields preserve their stored values. Authorization evaluates the merged ACL, so a partial request returns 403 when a preserved grant is outside the actor\\'s grantable ACL. An organization-scoped non-super-admin override requires at least one feature grant.',\n requestBody: {\n contentType: 'application/json',\n schema: putSchema,\n },\n responses: [\n { status: 200, description: 'User ACL updated', schema: userAclUpdateResponseSchema },\n { status: 400, description: 'Invalid payload or unresolved tenant scope', schema: userAclErrorSchema },\n { status: 401, description: 'Unauthorized', schema: userAclErrorSchema },\n { status: 403, description: 'Insufficient privileges to modify ACL', schema: userAclErrorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest, type AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { logCrudAccess } from '@open-mercato/shared/lib/crud/factory'\nimport { forbidden, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { User, UserAcl } from '@open-mercato/core/modules/auth/data/entities'\nimport {\n assertActorCanAccessUserTarget,\n assertActorCanGrantAcl,\n assertActorCanModifySuperAdminUserTarget,\n normalizeGrantFeatureList,\n} from '@open-mercato/core/modules/auth/lib/grantChecks'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport {\n AUTH_USER_ACL_UPDATE_COMMAND_ID,\n type AclUpdateResult,\n type UserAclUpdateInput,\n} from '@open-mercato/core/modules/auth/commands/acl'\n\nconst getSchema = z.object({\n userId: z.string().uuid(),\n tenantId: z.string().uuid().optional(),\n})\nconst putSchema = z.object({\n userId: z.string().uuid(),\n isSuperAdmin: z.boolean().optional(),\n features: z.array(z.string()).optional(),\n organizations: z.array(z.string()).nullable().optional(),\n tenantId: z.string().uuid().optional(),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['auth.acl.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['auth.acl.manage'] },\n}\n\nconst userAclResponseSchema = z.object({\n hasCustomAcl: z.boolean(),\n isSuperAdmin: z.boolean(),\n features: z.array(z.string()),\n organizations: z.array(z.string()).nullable(),\n updatedAt: z.string().nullable(),\n})\n\nconst userAclUpdateResponseSchema = z.object({\n ok: z.literal(true),\n sanitized: z.boolean(),\n})\n\nconst userAclErrorSchema = z.object({ error: z.string() })\n\ntype TenantResolution = { tenantId: string | null } | { error: NextResponse }\n\n/**\n * The single tenant scope both handlers work in. GET and PUT MUST resolve it the\n * same way: the admin user form PUTs the ACL panel's state on every save, so a\n * GET that reads a narrower scope than the PUT writes hands the operator an\n * empty form and turns the next name-only edit into a silent revocation, with no\n * `updatedAt` to arm the optimistic lock against it.\n *\n * `user_acls.tenant_id` is NOT NULL while `users.tenant_id` is nullable, so a\n * global account legitimately signs in with `auth.tenantId === null` and the\n * scope has to come from the target user instead. That fallback is reserved for\n * a super admin: for anyone else it would let the target pick the scope its own\n * access is then checked against, and it decrypts a possibly foreign user ahead\n * of every guard. A tenant-less non-super-admin therefore resolves to `null`,\n * which reads as \"no override\" and refuses to write \u2014 the behaviour that held\n * before this route resolved a scope at all. The read is also skipped whenever a\n * tenant is already known.\n *\n * An explicit `tenantId` wins, mirroring the role ACL route (additive there and\n * here; no caller sends it yet) \u2014 but only for a super admin or for the actor's\n * own tenant, so it cannot widen anyone's reach. The result is that for a\n * non-super-admin the resolved scope is always the actor's own.\n */\nasync function resolveAclTenantId(args: {\n em: EntityManager\n auth: NonNullable<AuthContext>\n actorIsSuperAdmin: boolean\n userId: string\n requestedTenantId?: string\n}): Promise<TenantResolution> {\n const authTenantId = args.auth.tenantId ?? null\n if (args.requestedTenantId && args.requestedTenantId !== authTenantId && !args.actorIsSuperAdmin) {\n return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) }\n }\n const known = args.requestedTenantId ?? authTenantId\n if (known) return { tenantId: known }\n if (!args.actorIsSuperAdmin) return { tenantId: null }\n const targetUser = await findOneWithDecryption(\n args.em,\n User,\n { id: args.userId } as FilterQuery<User>,\n {},\n { tenantId: null, organizationId: args.auth.orgId ?? null },\n )\n return { tenantId: targetUser?.tenantId ? String(targetUser.tenantId) : null }\n}\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n const url = new URL(req.url)\n const parsed = getSchema.safeParse({\n userId: url.searchParams.get('userId'),\n tenantId: url.searchParams.get('tenantId') || undefined,\n })\n if (!parsed.success) return NextResponse.json({ error: 'Invalid input' }, { status: 400 })\n const container = await createRequestContainer()\n const em = container.resolve('em') as any\n const rbacService = container.resolve('rbacService') as any\n // Every grant check is answered in the actor's own scope, never in the scope\n // resolved for the record: passing the resolved one would ask a foreign tenant\n // whether this caller may act there, and for a tenant-less actor \u2014 whose scope\n // comes from the target \u2014 it would compare the target against itself.\n const actorTenantId = auth.tenantId ?? null\n const actorAcl = auth.sub\n ? await rbacService.loadAcl(auth.sub, { tenantId: actorTenantId, organizationId: auth.orgId ?? null })\n : null\n const actorIsSuperAdmin = !!actorAcl?.isSuperAdmin\n\n const resolution = await resolveAclTenantId({\n em: em as EntityManager,\n auth,\n actorIsSuperAdmin,\n userId: parsed.data.userId,\n requestedTenantId: parsed.data.tenantId,\n })\n if ('error' in resolution) return resolution.error\n // An unresolvable scope reads as \"no override\", which is what PUT then refuses\n // to write (`Tenant required`) \u2014 so the pair still cannot destroy a row.\n const tenantId = resolution.tenantId\n\n if (!actorIsSuperAdmin && auth.sub) {\n try {\n await assertActorCanModifySuperAdminUserTarget({\n em: em as EntityManager,\n rbacService: rbacService as RbacService,\n actorUserId: auth.sub,\n tenantId: actorTenantId,\n organizationId: auth.orgId ?? null,\n targetUserId: parsed.data.userId,\n actorIsSuperAdmin: false,\n })\n await assertActorCanAccessUserTarget({\n em: em as EntityManager,\n rbacService: rbacService as RbacService,\n actorUserId: auth.sub,\n tenantId: actorTenantId,\n organizationId: auth.orgId ?? null,\n targetUserId: parsed.data.userId,\n actorIsSuperAdmin: false,\n organizationScope: await resolveOrganizationScopeForRequest({\n container,\n auth,\n request: req,\n tenantId: actorTenantId,\n }),\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n }\n const acl = tenantId\n ? await em.findOne(UserAcl, {\n user: parsed.data.userId as unknown as User,\n tenantId,\n deletedAt: null,\n } as FilterQuery<UserAcl>)\n : null\n const response = acl\n ? {\n hasCustomAcl: true,\n isSuperAdmin: !!acl.isSuperAdmin,\n features: Array.isArray(acl.featuresJson) ? acl.featuresJson : [],\n organizations: Array.isArray(acl.organizationsJson) ? acl.organizationsJson : null,\n updatedAt: acl.updatedAt instanceof Date ? acl.updatedAt.toISOString() : null,\n }\n : { hasCustomAcl: false, isSuperAdmin: false, features: [], organizations: null, updatedAt: null }\n\n await logCrudAccess({\n container,\n auth,\n request: req,\n items: [{ id: parsed.data.userId, ...response }],\n idField: 'id',\n resourceKind: 'auth.user_acl',\n organizationId: auth.orgId ?? null,\n tenantId,\n query: { userId: parsed.data.userId, tenantId },\n accessType: 'read:item',\n })\n\n return NextResponse.json(response)\n}\n\nexport async function PUT(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n const body = await req.json().catch(() => ({}))\n const parsed = putSchema.safeParse(body)\n if (!parsed.success) return NextResponse.json({ error: 'Invalid input' }, { status: 400 })\n const container = await createRequestContainer()\n const em = container.resolve('em') as any\n const rbacService = container.resolve('rbacService') as any\n\n // Every grant check is answered in the actor's own scope, never in the scope\n // resolved for the record: passing the resolved one would ask a foreign tenant\n // whether this caller may act there, and for a tenant-less actor \u2014 whose scope\n // comes from the target \u2014 it would compare the target against itself.\n const actorTenantId = auth.tenantId ?? null\n const actorAcl = auth.sub\n ? await rbacService.loadAcl(auth.sub, { tenantId: actorTenantId, organizationId: auth.orgId ?? null })\n : null\n const actorIsSuperAdmin = !!actorAcl?.isSuperAdmin\n\n // The scope the row is read and written in. `auth.tenantId` is `string | null`\n // \u2014 never `undefined`, since every producer normalizes with `?? null` \u2014 so the\n // pre-fix lookup ran `tenant_id IS NULL` against a NOT NULL column: it matched\n // no row, leaving create/update to fail the NOT NULL constraint and clear to\n // no-op silently. Nothing cross-tenant was ever matched; the fix turns a 500\n // into a working, correctly scoped write.\n const resolution = await resolveAclTenantId({\n em: em as EntityManager,\n auth,\n actorIsSuperAdmin,\n userId: parsed.data.userId,\n requestedTenantId: parsed.data.tenantId,\n })\n if ('error' in resolution) return resolution.error\n const tenantId = resolution.tenantId\n if (!tenantId) return NextResponse.json({ error: 'Tenant required' }, { status: 400 })\n\n if (!actorIsSuperAdmin && auth.sub) {\n try {\n await assertActorCanModifySuperAdminUserTarget({\n em: em as EntityManager,\n rbacService: rbacService as RbacService,\n actorUserId: auth.sub,\n tenantId: actorTenantId,\n organizationId: auth.orgId ?? null,\n targetUserId: parsed.data.userId,\n actorIsSuperAdmin: false,\n })\n await assertActorCanAccessUserTarget({\n em: em as EntityManager,\n rbacService: rbacService as RbacService,\n actorUserId: auth.sub,\n tenantId: actorTenantId,\n organizationId: auth.orgId ?? null,\n targetUserId: parsed.data.userId,\n actorIsSuperAdmin: false,\n organizationScope: await resolveOrganizationScopeForRequest({\n container,\n auth,\n request: req,\n tenantId: actorTenantId,\n }),\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n }\n\n const acl = await em.findOne(UserAcl, {\n user: parsed.data.userId as unknown as User,\n tenantId,\n deletedAt: null,\n } as FilterQuery<UserAcl>)\n // Optimistic lock: refuse a stale per-user ACL overwrite so concurrent edits\n // cannot silently clobber each other (#2055). Strictly additive \u2014 a no-op when\n // the client sends no expected-version header; skipped when no ACL row exists.\n if (acl) {\n try {\n await enforceCommandOptimisticLockWithGuards(container, {\n resourceKind: 'auth.user_acl',\n resourceId: acl.id,\n current: acl.updatedAt ?? null,\n request: req,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n }\n const existingIsSuperAdmin = acl ? !!acl.isSuperAdmin : false\n const existingFeatures = acl ? normalizeGrantFeatureList(acl.featuresJson) : []\n const existingOrganizations = acl ? normalizeOrganizations(acl.organizationsJson) : null\n\n // A per-user ACL is an absolute override, so an omitted dimension must keep\n // its stored value. Normalizing an omitted `features` to `[]` or an omitted\n // `organizations` to `null` turned a single-dimension edit into a silent\n // clear, deleting the row and widening the user back to their full role.\n const featuresWereProvided = parsed.data.features !== undefined\n const requestedFeatures = featuresWereProvided\n ? normalizeGrantFeatureList(parsed.data.features)\n : existingFeatures\n const requestedOrganizations = parsed.data.organizations === undefined\n ? existingOrganizations\n : normalizeOrganizations(parsed.data.organizations)\n\n const requestedIsSuperAdmin = parsed.data.isSuperAdmin ?? existingIsSuperAdmin\n\n try {\n await assertActorCanGrantAcl({\n em: em as EntityManager,\n rbacService: rbacService as RbacService,\n actorUserId: auth.sub,\n tenantId: actorTenantId,\n organizationId: auth.orgId ?? null,\n isSuperAdmin: requestedIsSuperAdmin,\n features: requestedFeatures,\n organizations: requestedOrganizations,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n\n // An omitted feature list is already stored and in effect. Re-sanitizing it\n // during an unrelated organization edit would silently revoke grants the\n // actor did not touch, so only an explicitly submitted list is sanitized.\n const effectiveFeatures = actorIsSuperAdmin || !featuresWereProvided\n ? requestedFeatures\n : sanitizeTenantFeatures(requestedFeatures)\n\n let effectiveIsSuperAdmin = requestedIsSuperAdmin\n\n if (!actorIsSuperAdmin) {\n if (requestedIsSuperAdmin && !existingIsSuperAdmin) {\n throw forbidden('Only super administrators can grant super admin access.')\n }\n if (existingIsSuperAdmin && requestedIsSuperAdmin === false) {\n effectiveIsSuperAdmin = false\n } else {\n effectiveIsSuperAdmin = existingIsSuperAdmin\n }\n }\n\n // Retaining an organization-only override with no features would revoke every\n // role-granted feature instead of narrowing the role. Refuse that state rather\n // than persisting it or silently dropping the organization scope.\n if (!effectiveIsSuperAdmin && effectiveFeatures.length === 0 && hasOrganizationRestriction(requestedOrganizations)) {\n const { translate } = await resolveTranslations()\n return NextResponse.json({\n error: translate(\n 'auth.acl.organizationWarning',\n 'Organization restrictions require at least one feature override. Add a feature or module wildcard, or clear the organization scope before saving.',\n ),\n }, { status: 400 })\n }\n\n // An unrestricted organization list carries no override on its own, and the guard\n // above already refused the restricted-but-featureless case, so the override is\n // custom exactly when it grants super admin or at least one feature.\n const hasCustomAcl = effectiveIsSuperAdmin || effectiveFeatures.length > 0\n\n // What the caller asked for, handed to the command only when it exceeds what\n // is about to be written. `assertActorCanGrantAcl` above refuses the blatant\n // escalations; what reaches the strip is quieter \u2014 a restricted grant the\n // actor does hold \u2014 and it leaves before/after identical, so the audit entry\n // would otherwise be indistinguishable from a no-op and skipped as one.\n //\n // Features are the only axis that can be trimmed. A super-admin request is\n // either honoured or refused outright above, never silently downgraded, so\n // comparing the two flags here would be dead weight.\n //\n // Deliberately independent of the `sanitized` response flag below:\n // `hasRestrictedChanges` stays false when the trimmed result equals the\n // existing ACL, to avoid nagging the user about a save that changed nothing \u2014\n // but that is precisely an attempt the trail must keep.\n const strippedFeatures = requestedFeatures.filter((feature) => !effectiveFeatures.includes(feature))\n\n // Route the write through the command bus so the permission change lands in\n // the action log. The command owns the transactional write (or removal) and\n // the RBAC cache invalidation that used to live here.\n const commandBus = container.resolve('commandBus') as CommandBus\n // A tenant-less actor edits an override in the target user's tenant, so their\n // organization belongs to a tenant the entry is not about. The bus resolves\n // the log row's organization as `metadata.organizationId ??\n // ctx.selectedOrganizationId ?? ctx.auth.orgId` \u2014 a `??` chain, so the\n // handler's `resolveOrganizationId` cannot express \"explicitly no\n // organization\" and the actor's would be restored. Strip it from the context\n // instead, exactly as the roles route does: otherwise the entry carries a\n // (target tenant, foreign organization) pair that `ActionLogService` \u2014 which\n // filters organization with strict equality \u2014 can never surface for anyone.\n const isForeignTenant = tenantId !== (auth.tenantId ?? null)\n const actorOrgId = isForeignTenant ? null : auth.orgId ?? null\n const commandCtx: CommandRuntimeContext = {\n container,\n auth: isForeignTenant ? { ...auth, orgId: null } : auth,\n organizationScope: null,\n selectedOrganizationId: actorOrgId,\n organizationIds: actorOrgId ? [actorOrgId] : null,\n request: req,\n }\n await commandBus.execute<UserAclUpdateInput, AclUpdateResult>(AUTH_USER_ACL_UPDATE_COMMAND_ID, {\n input: {\n userId: parsed.data.userId,\n tenantId,\n isSuperAdmin: effectiveIsSuperAdmin,\n features: effectiveFeatures,\n organizations: requestedOrganizations,\n clear: !hasCustomAcl,\n requested: strippedFeatures.length ? { features: requestedFeatures } : null,\n },\n ctx: commandCtx,\n })\n\n return NextResponse.json({\n ok: true,\n sanitized: !actorIsSuperAdmin && (hasRestrictedChanges(requestedFeatures, effectiveFeatures, existingFeatures) || requestedIsSuperAdmin !== effectiveIsSuperAdmin),\n })\n}\n\nfunction normalizeOrganizations(organizations: unknown): string[] | null {\n if (!Array.isArray(organizations)) return null\n return normalizeGrantFeatureList(organizations)\n}\n\n// Whether the caller expressed an intentional narrowing. `null` and `__all__`\n// are the two documented ways to say \"every organization\"; an empty list is the\n// editor's \"no organization picked\" state, which this route treats as\n// unrestricted rather than a deliberate restriction. Only a concrete list\n// narrows, so only a concrete list has to justify itself against the feature\n// grant below.\nfunction hasOrganizationRestriction(organizations: string[] | null): boolean {\n if (!organizations || organizations.length === 0) return false\n return !organizations.includes('__all__')\n}\n\nfunction sanitizeTenantFeatures(features: string[]): string[] {\n return features.filter((feature) => !isTenantRestrictedFeature(feature))\n}\n\nfunction isTenantRestrictedFeature(feature: string): boolean {\n if (feature === '*' || feature === 'directory.*') return true\n if (feature.startsWith('directory.tenants')) return true\n return false\n}\n\nfunction hasRestrictedChanges(requested: string[], effective: string[], existing: string[]): boolean {\n if (requested.length === effective.length) return false\n const effectiveSet = new Set(effective)\n const existingSet = new Set(existing)\n // If the effective set matches existing, we only trimmed restricted duplicates and should not report\n if (effectiveSet.size === existingSet.size) {\n let identical = true\n for (const value of effectiveSet) {\n if (!existingSet.has(value)) {\n identical = false\n break\n }\n }\n if (identical) return false\n }\n return true\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Authentication & Accounts',\n summary: 'User ACL management',\n methods: {\n GET: {\n summary: 'Fetch user ACL',\n description: 'Returns custom ACL overrides for a user, scoped to the requested tenant when supplied and to the actor or target user tenant otherwise.',\n query: getSchema,\n responses: [\n { status: 200, description: 'User ACL entry', schema: userAclResponseSchema },\n { status: 400, description: 'Invalid user id', schema: userAclErrorSchema },\n { status: 401, description: 'Unauthorized', schema: userAclErrorSchema },\n { status: 403, description: 'Insufficient privileges for the requested tenant scope', schema: userAclErrorSchema },\n ],\n },\n PUT: {\n summary: 'Update user ACL',\n description: 'Updates a per-user ACL override. Omitted super admin, feature, and organization fields preserve their stored values. Authorization evaluates the merged ACL, so a partial request returns 403 when a preserved grant is outside the actor\\'s grantable ACL. An organization-scoped non-super-admin override requires at least one feature grant.',\n requestBody: {\n contentType: 'application/json',\n schema: putSchema,\n },\n responses: [\n { status: 200, description: 'User ACL updated', schema: userAclUpdateResponseSchema },\n { status: 400, description: 'Invalid payload or unresolved tenant scope', schema: userAclErrorSchema },\n { status: 401, description: 'Unauthorized', schema: userAclErrorSchema },\n { status: 403, description: 'Insufficient privileges to modify ACL', schema: userAclErrorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -153,7 +153,7 @@ const updateRoleAclCommand = createAclUpdateCommand({
|
|
|
153
153
|
lockParent: async (em, input) => {
|
|
154
154
|
await lockRoleWriterAuthorizationState(em, [input.roleId]);
|
|
155
155
|
},
|
|
156
|
-
loadAcl: (em, input) => em.findOne(RoleAcl, { role: input.roleId, tenantId: input.tenantId }),
|
|
156
|
+
loadAcl: (em, input) => em.findOne(RoleAcl, { role: input.roleId, tenantId: input.tenantId, deletedAt: null }),
|
|
157
157
|
persist: async ({ em, input, existing }) => {
|
|
158
158
|
const acl = existing ?? em.create(RoleAcl, {
|
|
159
159
|
role: em.getReference(Role, input.roleId),
|
|
@@ -190,7 +190,7 @@ const updateUserAclCommand = createAclUpdateCommand({
|
|
|
190
190
|
lockParent: async (em, input) => {
|
|
191
191
|
await lockUserAclWriterAuthorizationState(em, [input.userId]);
|
|
192
192
|
},
|
|
193
|
-
loadAcl: (em, input) => em.findOne(UserAcl, { user: input.userId, tenantId: input.tenantId }),
|
|
193
|
+
loadAcl: (em, input) => em.findOne(UserAcl, { user: input.userId, tenantId: input.tenantId, deletedAt: null }),
|
|
194
194
|
persist: async ({ em, input, existing }) => {
|
|
195
195
|
if (input.clear) {
|
|
196
196
|
if (!existing) return;
|