@open-mercato/core 0.6.7-develop.6673.1.a9bf54a072 → 0.6.7-develop.6676.1.7dad6df292
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/modules/audit_logs/api/audit-logs/access/route.js +5 -1
- package/dist/modules/audit_logs/api/audit-logs/access/route.js.map +2 -2
- package/dist/modules/audit_logs/api/audit-logs/actions/export/route.js +5 -1
- package/dist/modules/audit_logs/api/audit-logs/actions/export/route.js.map +2 -2
- package/dist/modules/audit_logs/api/audit-logs/actions/route.js +5 -1
- package/dist/modules/audit_logs/api/audit-logs/actions/route.js.map +2 -2
- package/dist/modules/audit_logs/api/audit-logs/readScope.js +12 -0
- package/dist/modules/audit_logs/api/audit-logs/readScope.js.map +7 -0
- package/dist/modules/directory/api/organization-switcher/route.js +84 -20
- package/dist/modules/directory/api/organization-switcher/route.js.map +2 -2
- package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js +5 -1
- package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js.map +2 -2
- package/dist/modules/directory/utils/organizationScope.js +11 -3
- package/dist/modules/directory/utils/organizationScope.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/audit_logs/api/audit-logs/access/route.ts +5 -0
- package/src/modules/audit_logs/api/audit-logs/actions/export/route.ts +5 -0
- package/src/modules/audit_logs/api/audit-logs/actions/route.ts +5 -0
- package/src/modules/audit_logs/api/audit-logs/readScope.ts +28 -0
- package/src/modules/directory/api/organization-switcher/route.ts +97 -26
- package/src/modules/directory/subscribers/invalidateOrgScopeCache.ts +4 -1
- package/src/modules/directory/utils/organizationScope.ts +9 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/directory/api/organization-switcher/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse, type NextRequest } from 'next/server'\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 { Organization } from '@open-mercato/core/modules/directory/data/entities'\nimport { computeHierarchyForOrganizations, type ComputedHierarchy } from '@open-mercato/core/modules/directory/lib/hierarchy'\nimport { isAllOrganizationsSelection } from '@open-mercato/core/modules/directory/constants'\nimport {\n getSelectedOrganizationFromRequest,\n getSelectedTenantFromRequest,\n resolveOrganizationScope,\n} from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { directoryTag, directoryErrorSchema, organizationSwitcherResponseSchema } from '../openapi'\nimport { Tenant } from '@open-mercato/core/modules/directory/data/entities'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport type { FilterQuery } from '@mikro-orm/core'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('directory').child({ component: 'organization-switcher' })\n\ntype OrganizationMenuNode = {\n id: string\n name: string\n depth: number\n selectable: boolean\n children: OrganizationMenuNode[]\n}\n\nfunction buildOrganizationMenu(\n hierarchy: ComputedHierarchy,\n accessible: string[] | null,\n): { nodes: OrganizationMenuNode[]; selectableIds: Set<string> } {\n const roots: OrganizationMenuNode[] = []\n const selectableIds = new Set<string>()\n if (!hierarchy.ordered.length) return { nodes: roots, selectableIds }\n\n const includeSet = new Set<string>()\n if (accessible === null) {\n for (const node of hierarchy.ordered) {\n includeSet.add(node.id)\n selectableIds.add(node.id)\n }\n } else {\n const accessibleSet = new Set(accessible)\n for (const id of accessibleSet) {\n const node = hierarchy.map.get(id)\n if (!node) continue\n includeSet.add(id)\n selectableIds.add(id)\n for (const desc of node.descendantIds) {\n includeSet.add(desc)\n selectableIds.add(desc)\n }\n for (const anc of node.ancestorIds) includeSet.add(anc)\n }\n }\n\n const menuNodes = new Map<string, OrganizationMenuNode>()\n for (const node of hierarchy.ordered) {\n if (!includeSet.has(node.id)) continue\n const menuNode: OrganizationMenuNode = {\n id: node.id,\n name: node.name,\n depth: node.depth,\n selectable: accessible === null ? true : selectableIds.has(node.id),\n children: [],\n }\n menuNodes.set(node.id, menuNode)\n }\n\n const ensureChild = (parent: OrganizationMenuNode, child: OrganizationMenuNode) => {\n if (!parent.children.some((existing) => existing.id === child.id)) parent.children.push(child)\n }\n\n for (const node of hierarchy.ordered) {\n if (!includeSet.has(node.id)) continue\n const menuNode = menuNodes.get(node.id)!\n const parentId = node.parentId\n if (parentId && menuNodes.has(parentId)) {\n ensureChild(menuNodes.get(parentId)!, menuNode)\n } else if (!roots.some((existing) => existing.id === menuNode.id)) {\n roots.push(menuNode)\n }\n }\n\n return { nodes: roots, selectableIds }\n}\n\nexport const metadata = {\n GET: { requireAuth: true },\n}\n\nexport async function GET(req: NextRequest) {\n const auth = await getAuthFromRequest(req)\n if (!auth || !auth.sub) {\n return NextResponse.json({ items: [], selectedId: null, canManage: false, tenantId: null, tenants: [], isSuperAdmin: false }, { status: auth ? 200 : 401 })\n }\n\n const url = new URL(req.url)\n\n try {\n const container = await createRequestContainer()\n const em = container.resolve<EntityManager>('em')\n const rbac = container.resolve<RbacService>('rbacService')\n\n const rawTenantParam = url.searchParams.get('tenantId')\n const cookieTenant = getSelectedTenantFromRequest(req)\n const actorTenantId = typeof auth.tenantId === 'string' && auth.tenantId.trim().length > 0 ? auth.tenantId.trim() : null\n const actorIsSuperAdmin = auth.isSuperAdmin === true\n\n let requestedTenantId = rawTenantParam ?? (cookieTenant ?? undefined)\n if (requestedTenantId === '') requestedTenantId = undefined\n let tenantId = typeof requestedTenantId === 'string' && requestedTenantId.trim().length > 0 ? requestedTenantId.trim() : null\n\n let tenantRecords: { id: string; name: string; isActive: boolean }[] = []\n if (actorIsSuperAdmin) {\n const tenants = await em.find(Tenant, { deletedAt: null }, { orderBy: { name: 'ASC' } })\n tenantRecords = tenants.map((tenant: Tenant) => ({\n id: String(tenant.id),\n name: typeof tenant.name === 'string' && tenant.name.length > 0 ? tenant.name : String(tenant.id),\n isActive: tenant.isActive !== false,\n }))\n if (!tenantId) tenantId = actorTenantId ?? (tenantRecords[0]?.id ?? null)\n if (tenantId && tenantRecords.length && !tenantRecords.some((record) => record.id === tenantId)) {\n tenantId = tenantRecords[0]?.id ?? tenantId\n }\n } else {\n tenantId = actorTenantId\n }\n\n if (!tenantId) {\n return NextResponse.json({\n items: [],\n selectedId: null,\n canManage: false,\n tenantId: null,\n tenants: tenantRecords,\n isSuperAdmin: actorIsSuperAdmin,\n })\n }\n\n const scopedOrgId = actorTenantId && actorTenantId === tenantId ? auth.orgId ?? null : null\n const acl = await rbac.loadAcl(auth.sub, { tenantId, organizationId: scopedOrgId })\n const aclIsSuperAdmin = acl?.isSuperAdmin === true\n const effectiveIsSuperAdmin = actorIsSuperAdmin || aclIsSuperAdmin\n const hasManageFeature =\n aclIsSuperAdmin ||\n await rbac.userHasAllFeatures(auth.sub, ['directory.organizations.manage'], {\n tenantId,\n organizationId: scopedOrgId,\n }) ||\n actorIsSuperAdmin\n\n const orgFilter: FilterQuery<Organization> = {\n tenant: tenantId,\n deletedAt: null,\n }\n const orgEntities: Organization[] = await em.find(\n Organization,\n orgFilter,\n { orderBy: { name: 'ASC' } },\n )\n const hierarchy = computeHierarchyForOrganizations(orgEntities, tenantId)\n const rawSelected = getSelectedOrganizationFromRequest(req)\n let hasSelectionCookie = rawSelected !== null\n const requestedAll = isAllOrganizationsSelection(rawSelected)\n const scope = await resolveOrganizationScope({\n em,\n rbac,\n auth: {\n ...auth,\n tenantId,\n orgId: scopedOrgId,\n },\n selectedId: rawSelected,\n tenantId,\n })\n const accessible = scope.allowedIds\n const menuData = buildOrganizationMenu(hierarchy, accessible)\n\n let selectedId = requestedAll ? null : (scope.selectedId ?? null)\n if (selectedId && !menuData.selectableIds.has(selectedId)) {\n selectedId = null\n if (!requestedAll) {\n hasSelectionCookie = false\n }\n }\n if (!selectedId && !hasSelectionCookie && auth.orgId) {\n if (accessible === null || menuData.selectableIds.has(auth.orgId)) {\n selectedId = auth.orgId\n }\n }\n\n const showMenu = menuData.nodes.length > 0 || hasManageFeature || effectiveIsSuperAdmin\n if (!showMenu) {\n return NextResponse.json({ items: [], selectedId: null, canManage: false })\n }\n\n const canViewAllOrganizations = accessible === null\n const response = {\n items: menuData.nodes,\n selectedId,\n canManage: !!hasManageFeature,\n canViewAllOrganizations,\n tenantId,\n tenants: tenantRecords,\n isSuperAdmin: effectiveIsSuperAdmin,\n }\n\n await logCrudAccess({\n container,\n auth,\n request: req,\n items: response.items,\n idField: 'id',\n resourceKind: 'directory.organization_switcher',\n organizationId: response.selectedId,\n tenantId,\n query: Object.fromEntries(url.searchParams.entries()),\n })\n\n return NextResponse.json(response)\n } catch (err) {\n logger.error('Failed to build organization switcher payload', { err })\n return NextResponse.json({ items: [], selectedId: null, canManage: false, tenantId: null, tenants: [], isSuperAdmin: false }, { status: 500 })\n }\n}\n\nconst organizationSwitcherGetDoc: OpenApiMethodDoc = {\n summary: 'Load organization switcher menu',\n description: 'Returns the hierarchical menu of organizations the current user may switch to within the active tenant.',\n tags: [directoryTag],\n responses: [\n { status: 200, description: 'Organization switcher payload.', schema: organizationSwitcherResponseSchema },\n ],\n errors: [\n { status: 401, description: 'Authentication required', schema: directoryErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: directoryTag,\n summary: 'Organization switcher menu',\n methods: {\n GET: organizationSwitcherGetDoc,\n },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAsC;AAC/C,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,qBAAqB;AAC9B,SAAS,oBAAoB;AAC7B,SAAS,wCAAgE;AACzE,SAAS,mCAAmC;AAC5C;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;
|
|
4
|
+
"sourcesContent": ["import { NextResponse, type NextRequest } from 'next/server'\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 { Organization } from '@open-mercato/core/modules/directory/data/entities'\nimport { computeHierarchyForOrganizations, type ComputedHierarchy } from '@open-mercato/core/modules/directory/lib/hierarchy'\nimport { isAllOrganizationsSelection } from '@open-mercato/core/modules/directory/constants'\nimport {\n buildOrgScopeTenantCacheTag,\n getSelectedOrganizationFromRequest,\n getSelectedTenantFromRequest,\n resolveOrganizationScope,\n} from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { runWithCacheTenant, type CacheStrategy } from '@open-mercato/cache'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport {\n directoryTag,\n directoryErrorSchema,\n directoryIdSchema,\n organizationSwitcherResponseSchema,\n} from '../openapi'\nimport { Tenant } from '@open-mercato/core/modules/directory/data/entities'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport type { FilterQuery } from '@mikro-orm/core'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('directory').child({ component: 'organization-switcher' })\nconst ORGANIZATION_SWITCHER_CACHE_TTL_MS = 60_000\nconst organizationSwitcherCachePayloadSchema = organizationSwitcherResponseSchema.or(\n organizationSwitcherResponseSchema.pick({\n items: true,\n selectedId: true,\n canManage: true,\n }),\n)\n\nfunction buildSelectionCacheKeyPart(rawSelected: string | null): string | null {\n if (isAllOrganizationsSelection(rawSelected)) return 'mode:all'\n if (rawSelected === null) return 'mode:none'\n const parsedOrganizationId = directoryIdSchema.safeParse(rawSelected)\n return parsedOrganizationId.success ? `org:${parsedOrganizationId.data.toLowerCase()}` : null\n}\n\ntype OrganizationMenuNode = {\n id: string\n name: string\n depth: number\n selectable: boolean\n children: OrganizationMenuNode[]\n}\n\nfunction buildOrganizationMenu(\n hierarchy: ComputedHierarchy,\n accessible: string[] | null,\n): { nodes: OrganizationMenuNode[]; selectableIds: Set<string> } {\n const roots: OrganizationMenuNode[] = []\n const selectableIds = new Set<string>()\n if (!hierarchy.ordered.length) return { nodes: roots, selectableIds }\n\n const includeSet = new Set<string>()\n if (accessible === null) {\n for (const node of hierarchy.ordered) {\n includeSet.add(node.id)\n selectableIds.add(node.id)\n }\n } else {\n const accessibleSet = new Set(accessible)\n for (const id of accessibleSet) {\n const node = hierarchy.map.get(id)\n if (!node) continue\n includeSet.add(id)\n selectableIds.add(id)\n for (const desc of node.descendantIds) {\n includeSet.add(desc)\n selectableIds.add(desc)\n }\n for (const anc of node.ancestorIds) includeSet.add(anc)\n }\n }\n\n const menuNodes = new Map<string, OrganizationMenuNode>()\n for (const node of hierarchy.ordered) {\n if (!includeSet.has(node.id)) continue\n const menuNode: OrganizationMenuNode = {\n id: node.id,\n name: node.name,\n depth: node.depth,\n selectable: accessible === null ? true : selectableIds.has(node.id),\n children: [],\n }\n menuNodes.set(node.id, menuNode)\n }\n\n const ensureChild = (parent: OrganizationMenuNode, child: OrganizationMenuNode) => {\n if (!parent.children.some((existing) => existing.id === child.id)) parent.children.push(child)\n }\n\n for (const node of hierarchy.ordered) {\n if (!includeSet.has(node.id)) continue\n const menuNode = menuNodes.get(node.id)!\n const parentId = node.parentId\n if (parentId && menuNodes.has(parentId)) {\n ensureChild(menuNodes.get(parentId)!, menuNode)\n } else if (!roots.some((existing) => existing.id === menuNode.id)) {\n roots.push(menuNode)\n }\n }\n\n return { nodes: roots, selectableIds }\n}\n\nexport const metadata = {\n GET: { requireAuth: true },\n}\n\nexport async function GET(req: NextRequest) {\n const auth = await getAuthFromRequest(req)\n if (!auth || !auth.sub) {\n return NextResponse.json({ items: [], selectedId: null, canManage: false, tenantId: null, tenants: [], isSuperAdmin: false }, { status: auth ? 200 : 401 })\n }\n\n const url = new URL(req.url)\n\n try {\n const container = await createRequestContainer()\n const em = container.resolve<EntityManager>('em')\n const rbac = container.resolve<RbacService>('rbacService')\n\n const rawTenantParam = url.searchParams.get('tenantId')\n const cookieTenant = getSelectedTenantFromRequest(req)\n const actorTenantId = typeof auth.tenantId === 'string' && auth.tenantId.trim().length > 0 ? auth.tenantId.trim() : null\n const actorIsSuperAdmin = auth.isSuperAdmin === true\n\n let requestedTenantId = rawTenantParam ?? (cookieTenant ?? undefined)\n if (requestedTenantId === '') requestedTenantId = undefined\n let tenantId = typeof requestedTenantId === 'string' && requestedTenantId.trim().length > 0 ? requestedTenantId.trim() : null\n\n let tenantRecords: { id: string; name: string; isActive: boolean }[] = []\n if (actorIsSuperAdmin) {\n const tenants = await em.find(Tenant, { deletedAt: null }, { orderBy: { name: 'ASC' } })\n tenantRecords = tenants.map((tenant: Tenant) => ({\n id: String(tenant.id),\n name: typeof tenant.name === 'string' && tenant.name.length > 0 ? tenant.name : String(tenant.id),\n isActive: tenant.isActive !== false,\n }))\n if (!tenantId) tenantId = actorTenantId ?? (tenantRecords[0]?.id ?? null)\n if (tenantId && tenantRecords.length && !tenantRecords.some((record) => record.id === tenantId)) {\n tenantId = tenantRecords[0]?.id ?? tenantId\n }\n } else {\n tenantId = actorTenantId\n }\n\n if (!tenantId) {\n return NextResponse.json({\n items: [],\n selectedId: null,\n canManage: false,\n tenantId: null,\n tenants: tenantRecords,\n isSuperAdmin: actorIsSuperAdmin,\n })\n }\n\n const rawSelected = getSelectedOrganizationFromRequest(req)\n const requestedAll = isAllOrganizationsSelection(rawSelected)\n const selectedKeyPart = buildSelectionCacheKeyPart(rawSelected)\n const cacheKey = selectedKeyPart\n ? `org-switcher:v1:${auth.sub}:${tenantId}:${selectedKeyPart}`\n : null\n let cache: CacheStrategy | null = null\n if (!actorIsSuperAdmin && cacheKey) {\n try {\n cache = container.resolve<CacheStrategy>('cache')\n } catch {\n cache = null\n }\n }\n\n const logAccess = async (payload: { items: unknown[]; selectedId: string | null }) => {\n await logCrudAccess({\n container,\n auth,\n request: req,\n items: payload.items,\n idField: 'id',\n resourceKind: 'directory.organization_switcher',\n organizationId: payload.selectedId,\n tenantId,\n query: Object.fromEntries(url.searchParams.entries()),\n })\n }\n\n if (cache && cacheKey) {\n const activeCache = cache\n try {\n const cached = await runWithCacheTenant(tenantId, () => activeCache.get(cacheKey))\n const parsedCached = organizationSwitcherCachePayloadSchema.safeParse(cached)\n if (parsedCached.success) {\n await logAccess(parsedCached.data)\n return NextResponse.json(parsedCached.data)\n }\n } catch (err) {\n logger.warn('Failed to read organization switcher cache', { err })\n }\n }\n\n const scopedOrgId = actorTenantId && actorTenantId === tenantId ? auth.orgId ?? null : null\n const acl = await rbac.loadAcl(auth.sub, { tenantId, organizationId: scopedOrgId })\n const aclIsSuperAdmin = acl?.isSuperAdmin === true\n const effectiveIsSuperAdmin = actorIsSuperAdmin || aclIsSuperAdmin\n const hasManageFeature =\n aclIsSuperAdmin ||\n await rbac.userHasAllFeatures(auth.sub, ['directory.organizations.manage'], {\n tenantId,\n organizationId: scopedOrgId,\n }) ||\n actorIsSuperAdmin\n\n const orgFilter: FilterQuery<Organization> = {\n tenant: tenantId,\n deletedAt: null,\n }\n const orgEntities: Organization[] = await em.find(\n Organization,\n orgFilter,\n { orderBy: { name: 'ASC' } },\n )\n const hierarchy = computeHierarchyForOrganizations(orgEntities, tenantId)\n let hasSelectionCookie = rawSelected !== null\n const scope = await resolveOrganizationScope({\n em,\n rbac,\n auth: {\n ...auth,\n tenantId,\n orgId: scopedOrgId,\n },\n selectedId: rawSelected,\n tenantId,\n })\n const accessible = scope.allowedIds\n const menuData = buildOrganizationMenu(hierarchy, accessible)\n\n let selectedId = requestedAll ? null : (scope.selectedId ?? null)\n if (selectedId && !menuData.selectableIds.has(selectedId)) {\n selectedId = null\n if (!requestedAll) {\n hasSelectionCookie = false\n }\n }\n if (!selectedId && !hasSelectionCookie && auth.orgId) {\n if (accessible === null || menuData.selectableIds.has(auth.orgId)) {\n selectedId = auth.orgId\n }\n }\n\n const showMenu = menuData.nodes.length > 0 || hasManageFeature || effectiveIsSuperAdmin\n const response = showMenu\n ? {\n items: menuData.nodes,\n selectedId,\n canManage: !!hasManageFeature,\n canViewAllOrganizations: accessible === null,\n tenantId,\n tenants: tenantRecords,\n isSuperAdmin: effectiveIsSuperAdmin,\n }\n : {\n items: [],\n selectedId: null,\n canManage: false,\n }\n\n if (cache && cacheKey) {\n const activeCache = cache\n try {\n await runWithCacheTenant(tenantId, () =>\n activeCache.set(cacheKey, response, {\n ttl: ORGANIZATION_SWITCHER_CACHE_TTL_MS,\n tags: [\n buildOrgScopeTenantCacheTag(tenantId),\n `rbac:user:${auth.sub}`,\n ],\n }),\n )\n } catch (err) {\n logger.warn('Failed to write organization switcher cache', { err })\n }\n }\n\n await logAccess(response)\n\n return NextResponse.json(response)\n } catch (err) {\n logger.error('Failed to build organization switcher payload', { err })\n return NextResponse.json({ items: [], selectedId: null, canManage: false, tenantId: null, tenants: [], isSuperAdmin: false }, { status: 500 })\n }\n}\n\nconst organizationSwitcherGetDoc: OpenApiMethodDoc = {\n summary: 'Load organization switcher menu',\n description: 'Returns the hierarchical menu of organizations the current user may switch to within the active tenant.',\n tags: [directoryTag],\n responses: [\n { status: 200, description: 'Organization switcher payload.', schema: organizationSwitcherResponseSchema },\n ],\n errors: [\n { status: 401, description: 'Authentication required', schema: directoryErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: directoryTag,\n summary: 'Organization switcher menu',\n methods: {\n GET: organizationSwitcherGetDoc,\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildOrgScopeTenantCacheTag } from "@open-mercato/core/modules/directory/utils/organizationScope";
|
|
2
|
+
import { runWithCacheTenant } from "@open-mercato/cache";
|
|
2
3
|
const metadata = {
|
|
3
4
|
event: "directory.organization.*",
|
|
4
5
|
persistent: false,
|
|
@@ -16,7 +17,10 @@ async function handle(payload, ctx) {
|
|
|
16
17
|
}
|
|
17
18
|
if (!cache) return;
|
|
18
19
|
try {
|
|
19
|
-
await
|
|
20
|
+
await runWithCacheTenant(
|
|
21
|
+
tenantId,
|
|
22
|
+
() => cache.deleteByTags([buildOrgScopeTenantCacheTag(tenantId)])
|
|
23
|
+
);
|
|
20
24
|
} catch {
|
|
21
25
|
}
|
|
22
26
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/directory/subscribers/invalidateOrgScopeCache.ts"],
|
|
4
|
-
"sourcesContent": ["// Invalidate the OrganizationScope cache when an organization mutates.\n//\n// resolveOrganizationScopeForRequest caches its result with a short TTL\n// (default 60s, OM_ORG_SCOPE_CACHE_TTL_MS). When an organization is\n// created/updated/deleted, the cached scope for users of the affected\n// tenant may be stale (visibility set or descendant tree changed). We\n// drop every cache entry tagged for that tenant; the TTL is the backstop\n// for races where the event fires after a request reads the cache.\n\nimport { buildOrgScopeTenantCacheTag } from '@open-mercato/core/modules/directory/utils/organizationScope'\n\ntype CacheService = {\n deleteByTags(tags: string[]): Promise<number>\n}\n\nexport const metadata = {\n event: 'directory.organization.*',\n persistent: false,\n id: 'directory:invalidate-org-scope-cache',\n}\n\nexport default async function handle(\n payload: unknown,\n ctx: { resolve: <T = unknown>(name: string) => T },\n): Promise<void> {\n const data = (payload ?? {}) as Record<string, unknown>\n const tenantId = typeof data.tenantId === 'string' ? data.tenantId : null\n if (!tenantId) return\n let cache: CacheService | null = null\n try {\n cache = ctx.resolve<CacheService>('cache')\n } catch {\n return\n }\n if (!cache) return\n try {\n await cache.deleteByTags([buildOrgScopeTenantCacheTag(tenantId)])\n } catch {\n // best-effort; TTL is the backstop.\n }\n}\n"],
|
|
5
|
-
"mappings": "AASA,SAAS,mCAAmC;
|
|
4
|
+
"sourcesContent": ["// Invalidate the OrganizationScope cache when an organization mutates.\n//\n// resolveOrganizationScopeForRequest caches its result with a short TTL\n// (default 60s, OM_ORG_SCOPE_CACHE_TTL_MS). When an organization is\n// created/updated/deleted, the cached scope for users of the affected\n// tenant may be stale (visibility set or descendant tree changed). We\n// drop every cache entry tagged for that tenant; the TTL is the backstop\n// for races where the event fires after a request reads the cache.\n\nimport { buildOrgScopeTenantCacheTag } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { runWithCacheTenant } from '@open-mercato/cache'\n\ntype CacheService = {\n deleteByTags(tags: string[]): Promise<number>\n}\n\nexport const metadata = {\n event: 'directory.organization.*',\n persistent: false,\n id: 'directory:invalidate-org-scope-cache',\n}\n\nexport default async function handle(\n payload: unknown,\n ctx: { resolve: <T = unknown>(name: string) => T },\n): Promise<void> {\n const data = (payload ?? {}) as Record<string, unknown>\n const tenantId = typeof data.tenantId === 'string' ? data.tenantId : null\n if (!tenantId) return\n let cache: CacheService | null = null\n try {\n cache = ctx.resolve<CacheService>('cache')\n } catch {\n return\n }\n if (!cache) return\n try {\n await runWithCacheTenant(tenantId, () =>\n cache.deleteByTags([buildOrgScopeTenantCacheTag(tenantId)]),\n )\n } catch {\n // best-effort; TTL is the backstop.\n }\n}\n"],
|
|
5
|
+
"mappings": "AASA,SAAS,mCAAmC;AAC5C,SAAS,0BAA0B;AAM5B,MAAM,WAAW;AAAA,EACtB,OAAO;AAAA,EACP,YAAY;AAAA,EACZ,IAAI;AACN;AAEA,eAAO,OACL,SACA,KACe;AACf,QAAM,OAAQ,WAAW,CAAC;AAC1B,QAAM,WAAW,OAAO,KAAK,aAAa,WAAW,KAAK,WAAW;AACrE,MAAI,CAAC,SAAU;AACf,MAAI,QAA6B;AACjC,MAAI;AACF,YAAQ,IAAI,QAAsB,OAAO;AAAA,EAC3C,QAAQ;AACN;AAAA,EACF;AACA,MAAI,CAAC,MAAO;AACZ,MAAI;AACF,UAAM;AAAA,MAAmB;AAAA,MAAU,MACjC,MAAM,aAAa,CAAC,4BAA4B,QAAQ,CAAC,CAAC;AAAA,IAC5D;AAAA,EACF,QAAQ;AAAA,EAER;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Organization } from "@open-mercato/core/modules/directory/data/entities";
|
|
2
2
|
import { isAllOrganizationsSelection } from "@open-mercato/core/modules/directory/constants";
|
|
3
|
+
import { getCurrentCacheTenant, runWithCacheTenant } from "@open-mercato/cache";
|
|
3
4
|
import { parseSelectedOrganizationCookie, parseSelectedTenantCookie } from "./scopeCookies.js";
|
|
4
5
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
5
6
|
const logger = createLogger("directory").child({ component: "org-scope-cache" });
|
|
@@ -47,11 +48,15 @@ function resolveCacheFromContainer(container) {
|
|
|
47
48
|
}
|
|
48
49
|
return null;
|
|
49
50
|
}
|
|
50
|
-
async function invalidateOrganizationScopeCacheForUser(container, userId) {
|
|
51
|
+
async function invalidateOrganizationScopeCacheForUser(container, userId, tenantId) {
|
|
51
52
|
const cache = resolveCacheFromContainer(container);
|
|
52
53
|
if (!cache?.deleteByTags) return;
|
|
53
54
|
try {
|
|
54
|
-
|
|
55
|
+
const cacheTenantId = tenantId === void 0 ? getCurrentCacheTenant() : tenantId;
|
|
56
|
+
await runWithCacheTenant(
|
|
57
|
+
cacheTenantId,
|
|
58
|
+
() => cache.deleteByTags([buildOrgScopeUserCacheTag(userId)])
|
|
59
|
+
);
|
|
55
60
|
} catch (err) {
|
|
56
61
|
logger.warn("Cache invalidate user failed", { err });
|
|
57
62
|
}
|
|
@@ -60,7 +65,10 @@ async function invalidateOrganizationScopeCacheForTenant(container, tenantId) {
|
|
|
60
65
|
const cache = resolveCacheFromContainer(container);
|
|
61
66
|
if (!cache?.deleteByTags) return;
|
|
62
67
|
try {
|
|
63
|
-
await
|
|
68
|
+
await runWithCacheTenant(
|
|
69
|
+
tenantId,
|
|
70
|
+
() => cache.deleteByTags([buildOrgScopeTenantCacheTag(tenantId)])
|
|
71
|
+
);
|
|
64
72
|
} catch (err) {
|
|
65
73
|
logger.warn("Cache invalidate tenant failed", { err });
|
|
66
74
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/directory/utils/organizationScope.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { FilterQuery } from '@mikro-orm/core'\nimport type { AwilixContainer } from 'awilix'\nimport { Organization } from '@open-mercato/core/modules/directory/data/entities'\nimport { isAllOrganizationsSelection } from '@open-mercato/core/modules/directory/constants'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport { parseSelectedOrganizationCookie, parseSelectedTenantCookie } from './scopeCookies'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('directory').child({ component: 'org-scope-cache' })\n\nexport { parseSelectedOrganizationCookie, parseSelectedTenantCookie }\n\nexport type OrganizationScope = {\n selectedId: string | null\n filterIds: string[] | null\n allowedIds: string[] | null\n tenantId: string | null\n // True when the caller explicitly selected a concrete organization (cookie /\n // selectedId param) that could not be honored \u2014 it does not exist for the\n // tenant or is not accessible. Reads degrade gracefully (filterIds/selectedId\n // fall back to the caller's accessible orgs), but writes MUST fail loudly on\n // this so a record never lands under an org the caller did not intend. Absent\n // (undefined) means the selection \u2014 if any \u2014 was honored.\n selectionRejected?: boolean\n}\n\n// Phase 4 \u2014 short-TTL cache for resolveOrganizationScopeForRequest.\n// OrganizationScope is a pure function of (userId, tenantId, selectedOrgId,\n// requestedTenant) between membership changes; caching it bypasses 1\n// SELECT on `organizations` per CRUD request. TTL is short (60s default)\n// to keep staleness bounded as a backstop. Tag-based invalidation also fires\n// eagerly: per-user entries are dropped by RbacService.invalidateUserCache\n// (every ACL/role grant change goes through it \u2014 see buildOrgScopeUserCacheTag)\n// and per-tenant entries by the directory.organization.* subscriber plus\n// RbacService.invalidateTenantCache (role-ACL changes).\nconst ORG_SCOPE_CACHE_KEY_PREFIX = 'org-scope'\n// Phase 4 default-off until the same readiness probe (`GET /api/customers/people`)\n// stays green with the cache layer engaged. Set `OM_ORG_SCOPE_CACHE_TTL_MS=60000`\n// (or any positive integer) to opt in once cross-request safety is re-verified.\nconst ORG_SCOPE_DEFAULT_TTL_MS = 0\n\nfunction resolveOrgScopeTtlMs(): number {\n const raw = process.env.OM_ORG_SCOPE_CACHE_TTL_MS\n if (raw === undefined) return ORG_SCOPE_DEFAULT_TTL_MS\n const parsed = Number(raw)\n if (!Number.isFinite(parsed) || parsed < 0) return ORG_SCOPE_DEFAULT_TTL_MS\n return parsed\n}\n\nfunction buildOrgScopeCacheKey(parts: {\n userId: string\n effectiveTenantId: string\n selectedOrgId: string | null\n requestedTenantId: string | null\n}): string {\n const selected = parts.selectedOrgId ?? 'none'\n const requested = parts.requestedTenantId ?? 'none'\n return `${ORG_SCOPE_CACHE_KEY_PREFIX}:${parts.userId}:${parts.effectiveTenantId}:${selected}:${requested}`\n}\n\n// Tag builders are exported so the modules that own the \"this user's scope\n// changed\" / \"this tenant's org tree changed\" signals (auth RBAC invalidation,\n// the directory.organization.* subscriber) can drop the matching cross-request\n// cache entries without re-deriving the tag format. Keeping the format in one\n// place is what lets the TTL be enabled safely (issue #2259).\nexport function buildOrgScopeUserCacheTag(userId: string): string {\n return `${ORG_SCOPE_CACHE_KEY_PREFIX}:user:${userId}`\n}\n\nexport function buildOrgScopeTenantCacheTag(tenantId: string): string {\n return `${ORG_SCOPE_CACHE_KEY_PREFIX}:tenant:${tenantId}`\n}\n\nfunction buildOrgScopeCacheTags(parts: { userId: string; effectiveTenantId: string }): string[] {\n return [\n buildOrgScopeUserCacheTag(parts.userId),\n buildOrgScopeTenantCacheTag(parts.effectiveTenantId),\n ]\n}\n\nfunction isValidCachedScope(value: unknown): value is OrganizationScope {\n if (typeof value !== 'object' || value === null) return false\n const record = value as Partial<OrganizationScope>\n const idOk = (v: unknown) => v === null || typeof v === 'string'\n const arrOk = (v: unknown) => v === null || (Array.isArray(v) && v.every((entry) => typeof entry === 'string'))\n const flagOk = record.selectionRejected === undefined || typeof record.selectionRejected === 'boolean'\n return idOk(record.selectedId) && idOk(record.tenantId) && arrOk(record.filterIds) && arrOk(record.allowedIds) && flagOk\n}\n\nfunction resolveCacheFromContainer(container: AwilixContainer | null | undefined): CacheStrategy | null {\n if (!container) return null\n try {\n const c = container.resolve('cache') as CacheStrategy | undefined\n if (c && typeof c.get === 'function' && typeof c.set === 'function') return c\n } catch {\n return null\n }\n return null\n}\n\nexport async function invalidateOrganizationScopeCacheForUser(\n container: AwilixContainer,\n userId: string,\n): Promise<void> {\n const cache = resolveCacheFromContainer(container)\n if (!cache?.deleteByTags) return\n try {\n await cache.deleteByTags([buildOrgScopeUserCacheTag(userId)])\n } catch (err) {\n logger.warn('Cache invalidate user failed', { err })\n }\n}\n\nexport async function invalidateOrganizationScopeCacheForTenant(\n container: AwilixContainer,\n tenantId: string,\n): Promise<void> {\n const cache = resolveCacheFromContainer(container)\n if (!cache?.deleteByTags) return\n try {\n await cache.deleteByTags([buildOrgScopeTenantCacheTag(tenantId)])\n } catch (err) {\n logger.warn('Cache invalidate tenant failed', { err })\n }\n}\n\n// Issue #2259 \u2014 per-request memoization. resolveOrganizationScopeForRequest\n// runs at least twice per CRUD request: once for the route-level feature check\n// (resolveFeatureCheckContext) and once inside the shared factory's withCtx.\n// Those two call sites use different request-scoped DI containers but are handed\n// the SAME Request instance, so memoizing the resolved scope on a WeakMap keyed\n// by that request collapses the duplicate work \u2014 and the duplicate\n// `organizations` SELECT \u2014 into a single resolution. The inner map is keyed by\n// the same identity tuple as the cross-request cache key, so distinct explicit\n// selectedId/tenant overrides on one request stay independent. There is no\n// staleness risk: the memo lives only for the lifetime of one request and is\n// dropped with the request object by the GC.\nconst orgScopeRequestMemo = new WeakMap<object, Map<string, Promise<OrganizationScope>>>()\n\nfunction getRequestScopeMemo(request: unknown): Map<string, Promise<OrganizationScope>> | null {\n if (!request || (typeof request !== 'object' && typeof request !== 'function')) return null\n const key = request as object\n let memo = orgScopeRequestMemo.get(key)\n if (!memo) {\n memo = new Map<string, Promise<OrganizationScope>>()\n orgScopeRequestMemo.set(key, memo)\n }\n return memo\n}\n\nfunction normalizeOrganizationId(value: unknown): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : null\n}\n\nexport function getSelectedOrganizationFromRequest(req: Request | { cookies?: { get: (name: string) => { value: string } | undefined }; headers?: { get(name: string): string | null } }): string | null {\n const cookieContainer = (req as { cookies?: { get: (name: string) => { value: string } | undefined } }).cookies\n if (cookieContainer && typeof cookieContainer.get === 'function') {\n const val = cookieContainer.get('om_selected_org')?.value\n return val ?? null\n }\n const headerContainer = (req as { headers?: { get(name: string): string | null } }).headers\n const header = typeof headerContainer?.get === 'function' ? headerContainer.get('cookie') : null\n return parseSelectedOrganizationCookie(header)\n}\n\nexport function getSelectedTenantFromRequest(\n req: Request | { cookies?: { get: (name: string) => { value: string } | undefined }; headers?: { get(name: string): string | null } },\n): string | null {\n const cookieContainer = (req as { cookies?: { get: (name: string) => { value: string } | undefined } }).cookies\n if (cookieContainer && typeof cookieContainer.get === 'function') {\n const val = cookieContainer.get('om_selected_tenant')?.value\n return val ?? null\n }\n const headerContainer = (req as { headers?: { get(name: string): string | null } }).headers\n const header = typeof headerContainer?.get === 'function' ? headerContainer.get('cookie') : null\n return parseSelectedTenantCookie(header)\n}\n\nfunction normalizeOrganizationIds(ids: string[]): string[] {\n return Array.from(new Set(\n ids.map((value) => normalizeOrganizationId(value)).filter((value): value is string => {\n if (!value) return false\n if (isAllOrganizationsSelection(value)) return false\n return true\n })\n ))\n}\n\n// Map each organization id to itself plus its persisted descendant ids. Only\n// orgs that exist for the tenant and are not soft-deleted are included, so an\n// unknown/inaccessible id simply has no entry (matching the per-id query that\n// returned an empty set for it).\ntype OrgDescendantMap = Map<string, string[]>\n\n// Issue #2228 \u2014 single round-trip for org-scope resolution. Instead of issuing\n// one `organizations` SELECT per `collectWithDescendants` call (up to 3-4\n// sequential queries per request: accessible set, fallback set, selected set),\n// gather every candidate id up front and fetch their descendant expansions in\n// one `em.find(Organization, { id: $in })`. Expansion then happens in-memory.\nasync function loadOrgDescendantMap(em: EntityManager, tenantId: string, ids: string[]): Promise<OrgDescendantMap> {\n const unique = normalizeOrganizationIds(ids)\n if (!unique.length) return new Map()\n const filter: FilterQuery<Organization> = {\n tenant: tenantId,\n id: { $in: unique },\n deletedAt: null,\n }\n const orgs = await em.find(Organization, filter)\n const map: OrgDescendantMap = new Map()\n for (const org of orgs) {\n const id = String(org.id)\n const expansion = [id]\n if (Array.isArray(org.descendantIds)) {\n for (const desc of org.descendantIds) expansion.push(String(desc))\n }\n map.set(id, expansion)\n }\n return map\n}\n\nfunction expandWithDescendants(map: OrgDescendantMap, ids: string[]): Set<string> {\n const set = new Set<string>()\n for (const value of ids) {\n const id = normalizeOrganizationId(value)\n if (!id || isAllOrganizationsSelection(id)) continue\n const expansion = map.get(id)\n if (!expansion) continue\n for (const entry of expansion) set.add(entry)\n }\n return set\n}\n\nexport async function resolveOrganizationScope({\n em,\n rbac,\n auth,\n selectedId,\n tenantId: tenantIdOverride,\n}: {\n em: EntityManager\n rbac: RbacService\n auth: AuthContext\n selectedId?: string | null\n tenantId?: string | null\n}): Promise<OrganizationScope> {\n if (!auth || !auth.sub) {\n return { selectedId: null, filterIds: null, allowedIds: null, tenantId: null }\n }\n const actorTenantId = typeof auth.tenantId === 'string' && auth.tenantId.trim().length > 0 ? auth.tenantId.trim() : null\n const candidateTenantId = typeof tenantIdOverride === 'string' && tenantIdOverride.trim().length > 0\n ? tenantIdOverride.trim()\n : tenantIdOverride === null\n ? null\n : actorTenantId\n if (!candidateTenantId) {\n return { selectedId: null, filterIds: null, allowedIds: null, tenantId: null }\n }\n const usingOverride = candidateTenantId !== actorTenantId\n const isSuperAdminActor = auth.isSuperAdmin === true\n const tenantId = usingOverride && actorTenantId && !isSuperAdminActor ? actorTenantId : candidateTenantId\n if (!tenantId) {\n return { selectedId: null, filterIds: null, allowedIds: null, tenantId: null }\n }\n const normalizedRequestedSelection = normalizeOrganizationId(selectedId)\n const explicitAllOrgsChoice =\n normalizedRequestedSelection !== null && isAllOrganizationsSelection(normalizedRequestedSelection)\n const normalizedSelectedId = explicitAllOrgsChoice\n ? null\n : normalizedRequestedSelection\n const contextOrgId = actorTenantId && actorTenantId === tenantId ? normalizeOrganizationId(auth.orgId) : null\n const acl = await rbac.loadAcl(auth.sub, { tenantId, organizationId: contextOrgId })\n const aclIsSuperAdmin = acl?.isSuperAdmin === true\n const effectiveSuperAdmin = aclIsSuperAdmin || isSuperAdminActor\n const normalizedAccessible = effectiveSuperAdmin\n ? null\n : Array.isArray(acl?.organizations)\n ? acl.organizations\n .map((value) => normalizeOrganizationId(value))\n .filter((value): value is string => value !== null)\n : null\n const accessibleList = effectiveSuperAdmin\n ? null\n : normalizedAccessible && normalizedAccessible.some((value) => isAllOrganizationsSelection(value))\n ? null\n : normalizedAccessible?.filter((value) => !isAllOrganizationsSelection(value)) ?? null\n\n const accountOrgId = actorTenantId && actorTenantId === tenantId ? normalizeOrganizationId(auth.orgId) : null\n const fallbackOrgId = accountOrgId ?? null\n\n // Every id that could be expanded below \u2014 accessible set, fallback (account)\n // org, and the requested selection \u2014 is known up front, so fetch them all in\n // a single `organizations` query and expand from the in-memory map.\n const candidateIds = [\n ...(accessibleList ?? []),\n ...(fallbackOrgId ? [fallbackOrgId] : []),\n ...(normalizedSelectedId ? [normalizedSelectedId] : []),\n ]\n const orgDescendants = await loadOrgDescendantMap(em, tenantId, candidateIds)\n const loadFallbackSet = (): Set<string> | null =>\n fallbackOrgId ? expandWithDescendants(orgDescendants, [fallbackOrgId]) : null\n\n let allowedSet: Set<string> | null = null\n if (accessibleList === null) {\n allowedSet = null\n } else if (accessibleList.length === 0) {\n allowedSet = new Set()\n } else {\n allowedSet = expandWithDescendants(orgDescendants, accessibleList)\n }\n\n if (allowedSet && allowedSet.size === 0 && fallbackOrgId) {\n const computed = loadFallbackSet()\n if (computed && computed.size > 0) {\n allowedSet = computed\n }\n }\n\n const hasUnrestrictedAccess = effectiveSuperAdmin || (accessibleList === null)\n const noOrgSelection = normalizedSelectedId === null && !explicitAllOrgsChoice\n const widenToAllOrgs =\n (explicitAllOrgsChoice && hasUnrestrictedAccess)\n || (effectiveSuperAdmin && noOrgSelection)\n const initialSelected =\n normalizedSelectedId\n ?? (widenToAllOrgs ? null : accountOrgId ?? null)\n // A selection is only honored when it resolves to a real, non-deleted org for\n // this tenant. `orgDescendants` holds exactly the existing orgs among the\n // candidate ids, so a stale/dead id (e.g. a selected-org cookie or JWT org\n // that no longer resolves after a DB reset) has no entry and is dropped here.\n // Without the existence guard an unrestricted (all-orgs) principal \u2014 whose\n // `allowedSet` is null \u2014 would accept the dead id as `effectiveSelected`, and\n // writes derived from `selectedId` would land under an org the read scope\n // (`filterIds`) never filters to, silently orphaning the record.\n const selectionResolvesToOrg = (id: string): boolean => orgDescendants.has(id)\n let effectiveSelected: string | null = null\n if (initialSelected) {\n if ((allowedSet === null || allowedSet.has(initialSelected)) && selectionResolvesToOrg(initialSelected)) {\n effectiveSelected = initialSelected\n }\n }\n\n let filterSet: Set<string> | null = null\n if (effectiveSelected) {\n filterSet = expandWithDescendants(orgDescendants, [effectiveSelected])\n } else if (allowedSet !== null) {\n filterSet = allowedSet\n } else if (widenToAllOrgs) {\n filterSet = null\n } else if (auth.orgId) {\n filterSet = loadFallbackSet()\n // Keep the write target (`selectedId`) aligned with the read scope\n // (`filterIds`): when an unrestricted principal's requested selection was\n // dropped above, fall the selection back to the account org too so a record\n // created here is always readable back by the same caller.\n if (!effectiveSelected && fallbackOrgId && filterSet && filterSet.size > 0) {\n effectiveSelected = fallbackOrgId\n }\n }\n\n if ((!filterSet || filterSet.size === 0) && fallbackOrgId && !widenToAllOrgs) {\n const computed = loadFallbackSet()\n if (computed && computed.size > 0) {\n filterSet = computed\n if (!effectiveSelected) {\n effectiveSelected = fallbackOrgId\n }\n }\n }\n\n // A concrete organization was explicitly requested (`normalizedSelectedId` is\n // null for both \"no selection\" and the \"all organizations\" token) but the\n // resolver could not honor it \u2014 it was dropped as non-existent/inaccessible\n // and the effective selection fell back to something else. Surface this so the\n // write layer can reject the request instead of silently creating the record\n // under the fallback org. Only set the field when true to keep the scope shape\n // unchanged for the common (honored) case.\n const selectionRejected = normalizedSelectedId !== null && effectiveSelected !== normalizedSelectedId\n\n return {\n selectedId: effectiveSelected,\n filterIds: filterSet ? Array.from(filterSet) : null,\n allowedIds: allowedSet ? Array.from(allowedSet) : null,\n tenantId,\n ...(selectionRejected ? { selectionRejected: true } : {}),\n }\n}\n\nexport async function resolveOrganizationScopeForRequest({\n container,\n auth,\n request,\n selectedId,\n tenantId: tenantOverride,\n}: {\n container: AwilixContainer\n auth: AuthContext | null | undefined\n request?: Request | { cookies?: { get: (name: string) => { value: string } | undefined }; headers?: { get(name: string): string | null } }\n selectedId?: string | null\n tenantId?: string | null\n}): Promise<OrganizationScope> {\n if (!auth || !auth.sub) {\n return { selectedId: null, filterIds: null, allowedIds: null, tenantId: null }\n }\n\n let em: EntityManager | null = null\n let rbac: RbacService | null = null\n try { em = container.resolve<EntityManager>('em') } catch { em = null }\n try { rbac = container.resolve<RbacService>('rbacService') } catch { rbac = null }\n const normalizeString = (value: unknown): string | null => {\n if (typeof value === 'string' && value.trim().length > 0) return value.trim()\n return null\n }\n if (!em || !rbac) {\n const fallbackSelected = normalizeOrganizationId(selectedId ?? auth.orgId ?? null)\n return {\n selectedId: fallbackSelected,\n filterIds: fallbackSelected ? [fallbackSelected] : null,\n allowedIds: fallbackSelected ? [fallbackSelected] : null,\n tenantId: normalizeString(auth.tenantId),\n }\n }\n\n const actorTenantField = (auth as { actorTenantId?: string | null }).actorTenantId\n const actorTenant = actorTenantField === undefined\n ? normalizeString(auth.tenantId)\n : actorTenantField === null\n ? null\n : normalizeString(actorTenantField)\n const actorOrgField = (auth as { actorOrgId?: string | null }).actorOrgId\n const actorOrgId = actorOrgField === undefined\n ? normalizeString(auth.orgId)\n : actorOrgField === null\n ? null\n : normalizeString(actorOrgField)\n\n const cookieTenant = request ? getSelectedTenantFromRequest(request) : null\n const requestedTenant =\n tenantOverride !== undefined\n ? tenantOverride\n : cookieTenant !== undefined\n ? cookieTenant\n : undefined\n const requestedTenantId = typeof requestedTenant === 'string' && requestedTenant.trim().length > 0 ? requestedTenant.trim() : null\n const isSuperAdminActor = auth.isSuperAdmin === true\n let effectiveTenantId = requestedTenantId ?? actorTenant ?? null\n if (actorTenant && effectiveTenantId && effectiveTenantId !== actorTenant && !isSuperAdminActor) {\n effectiveTenantId = actorTenant\n }\n if (!effectiveTenantId) {\n return { selectedId: null, filterIds: null, allowedIds: null, tenantId: null }\n }\n\n const scopedAuth = {\n ...auth,\n tenantId: effectiveTenantId,\n orgId: actorTenant && actorTenant === effectiveTenantId ? actorOrgId ?? null : null,\n }\n\n const rawSelected = selectedId !== undefined ? selectedId : (request ? getSelectedOrganizationFromRequest(request) : null)\n const normalizedSelectedId = typeof rawSelected === 'string' && rawSelected.trim().length > 0\n ? rawSelected.trim()\n : null\n\n const userId = typeof auth.sub === 'string' && auth.sub.length > 0 ? auth.sub : null\n const ttlMs = resolveOrgScopeTtlMs()\n const cache = ttlMs > 0 ? resolveCacheFromContainer(container) : null\n const cacheKey = userId\n ? buildOrgScopeCacheKey({\n userId,\n effectiveTenantId,\n selectedOrgId: normalizedSelectedId,\n requestedTenantId: requestedTenantId ?? null,\n })\n : null\n\n const requestMemo = getRequestScopeMemo(request)\n if (requestMemo && cacheKey) {\n const memoized = requestMemo.get(cacheKey)\n if (memoized) return memoized\n }\n\n const resolveScope = async (): Promise<OrganizationScope> => {\n if (cache && cacheKey && typeof cache.get === 'function') {\n try {\n const cached = await cache.get(cacheKey)\n if (isValidCachedScope(cached)) return cached\n } catch (err) {\n logger.warn('Cache read failed', { err })\n }\n }\n\n const baseScope = await resolveOrganizationScope({\n em,\n rbac,\n auth: scopedAuth,\n selectedId: rawSelected,\n tenantId: effectiveTenantId,\n })\n\n if (cache && cacheKey && userId && typeof cache.set === 'function') {\n try {\n await cache.set(cacheKey, baseScope, {\n ttl: ttlMs,\n tags: buildOrgScopeCacheTags({ userId, effectiveTenantId }),\n })\n } catch (err) {\n logger.warn('Cache write failed', { err })\n }\n }\n\n return baseScope\n }\n\n if (requestMemo && cacheKey) {\n const pending = resolveScope()\n requestMemo.set(cacheKey, pending)\n return pending\n }\n\n return resolveScope()\n}\n\nexport type FeatureCheckContext = {\n organizationId: string | null\n scope: OrganizationScope\n allowedOrganizationIds: string[] | null\n}\n\nexport async function resolveFeatureCheckContext({\n container,\n auth,\n request,\n selectedId,\n tenantId,\n}: {\n container: AwilixContainer\n auth: AuthContext | null | undefined\n request?: Request | { cookies?: { get: (name: string) => { value: string } | undefined } }\n selectedId?: string | null\n tenantId?: string | null\n}): Promise<FeatureCheckContext> {\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request, selectedId, tenantId })\n const allowedOrganizationIds = scope.allowedIds ?? null\n const authOrgId = auth?.orgId ?? null\n const organizationId =\n scope.selectedId\n ?? (authOrgId && (!Array.isArray(allowedOrganizationIds) || allowedOrganizationIds.includes(authOrgId)) ? authOrgId : null)\n ?? (Array.isArray(allowedOrganizationIds) && allowedOrganizationIds.length ? allowedOrganizationIds[0] : null)\n\n return { organizationId, scope, allowedOrganizationIds }\n}\n"],
|
|
5
|
-
"mappings": "AAGA,SAAS,oBAAoB;AAC7B,SAAS,mCAAmC;
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { FilterQuery } from '@mikro-orm/core'\nimport type { AwilixContainer } from 'awilix'\nimport { Organization } from '@open-mercato/core/modules/directory/data/entities'\nimport { isAllOrganizationsSelection } from '@open-mercato/core/modules/directory/constants'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport { getCurrentCacheTenant, runWithCacheTenant, type CacheStrategy } from '@open-mercato/cache'\nimport { parseSelectedOrganizationCookie, parseSelectedTenantCookie } from './scopeCookies'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('directory').child({ component: 'org-scope-cache' })\n\nexport { parseSelectedOrganizationCookie, parseSelectedTenantCookie }\n\nexport type OrganizationScope = {\n selectedId: string | null\n filterIds: string[] | null\n allowedIds: string[] | null\n tenantId: string | null\n // True when the caller explicitly selected a concrete organization (cookie /\n // selectedId param) that could not be honored \u2014 it does not exist for the\n // tenant or is not accessible. Reads degrade gracefully (filterIds/selectedId\n // fall back to the caller's accessible orgs), but writes MUST fail loudly on\n // this so a record never lands under an org the caller did not intend. Absent\n // (undefined) means the selection \u2014 if any \u2014 was honored.\n selectionRejected?: boolean\n}\n\n// Phase 4 \u2014 short-TTL cache for resolveOrganizationScopeForRequest.\n// OrganizationScope is a pure function of (userId, tenantId, selectedOrgId,\n// requestedTenant) between membership changes; caching it bypasses 1\n// SELECT on `organizations` per CRUD request. TTL is short (60s default)\n// to keep staleness bounded as a backstop. Tag-based invalidation also fires\n// eagerly: per-user entries are dropped by RbacService.invalidateUserCache\n// (every ACL/role grant change goes through it \u2014 see buildOrgScopeUserCacheTag)\n// and per-tenant entries by the directory.organization.* subscriber plus\n// RbacService.invalidateTenantCache (role-ACL changes).\nconst ORG_SCOPE_CACHE_KEY_PREFIX = 'org-scope'\n// Phase 4 default-off until the same readiness probe (`GET /api/customers/people`)\n// stays green with the cache layer engaged. Set `OM_ORG_SCOPE_CACHE_TTL_MS=60000`\n// (or any positive integer) to opt in once cross-request safety is re-verified.\nconst ORG_SCOPE_DEFAULT_TTL_MS = 0\n\nfunction resolveOrgScopeTtlMs(): number {\n const raw = process.env.OM_ORG_SCOPE_CACHE_TTL_MS\n if (raw === undefined) return ORG_SCOPE_DEFAULT_TTL_MS\n const parsed = Number(raw)\n if (!Number.isFinite(parsed) || parsed < 0) return ORG_SCOPE_DEFAULT_TTL_MS\n return parsed\n}\n\nfunction buildOrgScopeCacheKey(parts: {\n userId: string\n effectiveTenantId: string\n selectedOrgId: string | null\n requestedTenantId: string | null\n}): string {\n const selected = parts.selectedOrgId ?? 'none'\n const requested = parts.requestedTenantId ?? 'none'\n return `${ORG_SCOPE_CACHE_KEY_PREFIX}:${parts.userId}:${parts.effectiveTenantId}:${selected}:${requested}`\n}\n\n// Tag builders are exported so the modules that own the \"this user's scope\n// changed\" / \"this tenant's org tree changed\" signals (auth RBAC invalidation,\n// the directory.organization.* subscriber) can drop the matching cross-request\n// cache entries without re-deriving the tag format. Keeping the format in one\n// place is what lets the TTL be enabled safely (issue #2259).\nexport function buildOrgScopeUserCacheTag(userId: string): string {\n return `${ORG_SCOPE_CACHE_KEY_PREFIX}:user:${userId}`\n}\n\nexport function buildOrgScopeTenantCacheTag(tenantId: string): string {\n return `${ORG_SCOPE_CACHE_KEY_PREFIX}:tenant:${tenantId}`\n}\n\nfunction buildOrgScopeCacheTags(parts: { userId: string; effectiveTenantId: string }): string[] {\n return [\n buildOrgScopeUserCacheTag(parts.userId),\n buildOrgScopeTenantCacheTag(parts.effectiveTenantId),\n ]\n}\n\nfunction isValidCachedScope(value: unknown): value is OrganizationScope {\n if (typeof value !== 'object' || value === null) return false\n const record = value as Partial<OrganizationScope>\n const idOk = (v: unknown) => v === null || typeof v === 'string'\n const arrOk = (v: unknown) => v === null || (Array.isArray(v) && v.every((entry) => typeof entry === 'string'))\n const flagOk = record.selectionRejected === undefined || typeof record.selectionRejected === 'boolean'\n return idOk(record.selectedId) && idOk(record.tenantId) && arrOk(record.filterIds) && arrOk(record.allowedIds) && flagOk\n}\n\nfunction resolveCacheFromContainer(container: AwilixContainer | null | undefined): CacheStrategy | null {\n if (!container) return null\n try {\n const c = container.resolve('cache') as CacheStrategy | undefined\n if (c && typeof c.get === 'function' && typeof c.set === 'function') return c\n } catch {\n return null\n }\n return null\n}\n\nexport async function invalidateOrganizationScopeCacheForUser(\n container: AwilixContainer,\n userId: string,\n tenantId?: string | null,\n): Promise<void> {\n const cache = resolveCacheFromContainer(container)\n if (!cache?.deleteByTags) return\n try {\n const cacheTenantId = tenantId === undefined ? getCurrentCacheTenant() : tenantId\n await runWithCacheTenant(cacheTenantId, () =>\n cache.deleteByTags([buildOrgScopeUserCacheTag(userId)]),\n )\n } catch (err) {\n logger.warn('Cache invalidate user failed', { err })\n }\n}\n\nexport async function invalidateOrganizationScopeCacheForTenant(\n container: AwilixContainer,\n tenantId: string,\n): Promise<void> {\n const cache = resolveCacheFromContainer(container)\n if (!cache?.deleteByTags) return\n try {\n await runWithCacheTenant(tenantId, () =>\n cache.deleteByTags([buildOrgScopeTenantCacheTag(tenantId)]),\n )\n } catch (err) {\n logger.warn('Cache invalidate tenant failed', { err })\n }\n}\n\n// Issue #2259 \u2014 per-request memoization. resolveOrganizationScopeForRequest\n// runs at least twice per CRUD request: once for the route-level feature check\n// (resolveFeatureCheckContext) and once inside the shared factory's withCtx.\n// Those two call sites use different request-scoped DI containers but are handed\n// the SAME Request instance, so memoizing the resolved scope on a WeakMap keyed\n// by that request collapses the duplicate work \u2014 and the duplicate\n// `organizations` SELECT \u2014 into a single resolution. The inner map is keyed by\n// the same identity tuple as the cross-request cache key, so distinct explicit\n// selectedId/tenant overrides on one request stay independent. There is no\n// staleness risk: the memo lives only for the lifetime of one request and is\n// dropped with the request object by the GC.\nconst orgScopeRequestMemo = new WeakMap<object, Map<string, Promise<OrganizationScope>>>()\n\nfunction getRequestScopeMemo(request: unknown): Map<string, Promise<OrganizationScope>> | null {\n if (!request || (typeof request !== 'object' && typeof request !== 'function')) return null\n const key = request as object\n let memo = orgScopeRequestMemo.get(key)\n if (!memo) {\n memo = new Map<string, Promise<OrganizationScope>>()\n orgScopeRequestMemo.set(key, memo)\n }\n return memo\n}\n\nfunction normalizeOrganizationId(value: unknown): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : null\n}\n\nexport function getSelectedOrganizationFromRequest(req: Request | { cookies?: { get: (name: string) => { value: string } | undefined }; headers?: { get(name: string): string | null } }): string | null {\n const cookieContainer = (req as { cookies?: { get: (name: string) => { value: string } | undefined } }).cookies\n if (cookieContainer && typeof cookieContainer.get === 'function') {\n const val = cookieContainer.get('om_selected_org')?.value\n return val ?? null\n }\n const headerContainer = (req as { headers?: { get(name: string): string | null } }).headers\n const header = typeof headerContainer?.get === 'function' ? headerContainer.get('cookie') : null\n return parseSelectedOrganizationCookie(header)\n}\n\nexport function getSelectedTenantFromRequest(\n req: Request | { cookies?: { get: (name: string) => { value: string } | undefined }; headers?: { get(name: string): string | null } },\n): string | null {\n const cookieContainer = (req as { cookies?: { get: (name: string) => { value: string } | undefined } }).cookies\n if (cookieContainer && typeof cookieContainer.get === 'function') {\n const val = cookieContainer.get('om_selected_tenant')?.value\n return val ?? null\n }\n const headerContainer = (req as { headers?: { get(name: string): string | null } }).headers\n const header = typeof headerContainer?.get === 'function' ? headerContainer.get('cookie') : null\n return parseSelectedTenantCookie(header)\n}\n\nfunction normalizeOrganizationIds(ids: string[]): string[] {\n return Array.from(new Set(\n ids.map((value) => normalizeOrganizationId(value)).filter((value): value is string => {\n if (!value) return false\n if (isAllOrganizationsSelection(value)) return false\n return true\n })\n ))\n}\n\n// Map each organization id to itself plus its persisted descendant ids. Only\n// orgs that exist for the tenant and are not soft-deleted are included, so an\n// unknown/inaccessible id simply has no entry (matching the per-id query that\n// returned an empty set for it).\ntype OrgDescendantMap = Map<string, string[]>\n\n// Issue #2228 \u2014 single round-trip for org-scope resolution. Instead of issuing\n// one `organizations` SELECT per `collectWithDescendants` call (up to 3-4\n// sequential queries per request: accessible set, fallback set, selected set),\n// gather every candidate id up front and fetch their descendant expansions in\n// one `em.find(Organization, { id: $in })`. Expansion then happens in-memory.\nasync function loadOrgDescendantMap(em: EntityManager, tenantId: string, ids: string[]): Promise<OrgDescendantMap> {\n const unique = normalizeOrganizationIds(ids)\n if (!unique.length) return new Map()\n const filter: FilterQuery<Organization> = {\n tenant: tenantId,\n id: { $in: unique },\n deletedAt: null,\n }\n const orgs = await em.find(Organization, filter)\n const map: OrgDescendantMap = new Map()\n for (const org of orgs) {\n const id = String(org.id)\n const expansion = [id]\n if (Array.isArray(org.descendantIds)) {\n for (const desc of org.descendantIds) expansion.push(String(desc))\n }\n map.set(id, expansion)\n }\n return map\n}\n\nfunction expandWithDescendants(map: OrgDescendantMap, ids: string[]): Set<string> {\n const set = new Set<string>()\n for (const value of ids) {\n const id = normalizeOrganizationId(value)\n if (!id || isAllOrganizationsSelection(id)) continue\n const expansion = map.get(id)\n if (!expansion) continue\n for (const entry of expansion) set.add(entry)\n }\n return set\n}\n\nexport async function resolveOrganizationScope({\n em,\n rbac,\n auth,\n selectedId,\n tenantId: tenantIdOverride,\n}: {\n em: EntityManager\n rbac: RbacService\n auth: AuthContext\n selectedId?: string | null\n tenantId?: string | null\n}): Promise<OrganizationScope> {\n if (!auth || !auth.sub) {\n return { selectedId: null, filterIds: null, allowedIds: null, tenantId: null }\n }\n const actorTenantId = typeof auth.tenantId === 'string' && auth.tenantId.trim().length > 0 ? auth.tenantId.trim() : null\n const candidateTenantId = typeof tenantIdOverride === 'string' && tenantIdOverride.trim().length > 0\n ? tenantIdOverride.trim()\n : tenantIdOverride === null\n ? null\n : actorTenantId\n if (!candidateTenantId) {\n return { selectedId: null, filterIds: null, allowedIds: null, tenantId: null }\n }\n const usingOverride = candidateTenantId !== actorTenantId\n const isSuperAdminActor = auth.isSuperAdmin === true\n const tenantId = usingOverride && actorTenantId && !isSuperAdminActor ? actorTenantId : candidateTenantId\n if (!tenantId) {\n return { selectedId: null, filterIds: null, allowedIds: null, tenantId: null }\n }\n const normalizedRequestedSelection = normalizeOrganizationId(selectedId)\n const explicitAllOrgsChoice =\n normalizedRequestedSelection !== null && isAllOrganizationsSelection(normalizedRequestedSelection)\n const normalizedSelectedId = explicitAllOrgsChoice\n ? null\n : normalizedRequestedSelection\n const contextOrgId = actorTenantId && actorTenantId === tenantId ? normalizeOrganizationId(auth.orgId) : null\n const acl = await rbac.loadAcl(auth.sub, { tenantId, organizationId: contextOrgId })\n const aclIsSuperAdmin = acl?.isSuperAdmin === true\n const effectiveSuperAdmin = aclIsSuperAdmin || isSuperAdminActor\n const normalizedAccessible = effectiveSuperAdmin\n ? null\n : Array.isArray(acl?.organizations)\n ? acl.organizations\n .map((value) => normalizeOrganizationId(value))\n .filter((value): value is string => value !== null)\n : null\n const accessibleList = effectiveSuperAdmin\n ? null\n : normalizedAccessible && normalizedAccessible.some((value) => isAllOrganizationsSelection(value))\n ? null\n : normalizedAccessible?.filter((value) => !isAllOrganizationsSelection(value)) ?? null\n\n const accountOrgId = actorTenantId && actorTenantId === tenantId ? normalizeOrganizationId(auth.orgId) : null\n const fallbackOrgId = accountOrgId ?? null\n\n // Every id that could be expanded below \u2014 accessible set, fallback (account)\n // org, and the requested selection \u2014 is known up front, so fetch them all in\n // a single `organizations` query and expand from the in-memory map.\n const candidateIds = [\n ...(accessibleList ?? []),\n ...(fallbackOrgId ? [fallbackOrgId] : []),\n ...(normalizedSelectedId ? [normalizedSelectedId] : []),\n ]\n const orgDescendants = await loadOrgDescendantMap(em, tenantId, candidateIds)\n const loadFallbackSet = (): Set<string> | null =>\n fallbackOrgId ? expandWithDescendants(orgDescendants, [fallbackOrgId]) : null\n\n let allowedSet: Set<string> | null = null\n if (accessibleList === null) {\n allowedSet = null\n } else if (accessibleList.length === 0) {\n allowedSet = new Set()\n } else {\n allowedSet = expandWithDescendants(orgDescendants, accessibleList)\n }\n\n if (allowedSet && allowedSet.size === 0 && fallbackOrgId) {\n const computed = loadFallbackSet()\n if (computed && computed.size > 0) {\n allowedSet = computed\n }\n }\n\n const hasUnrestrictedAccess = effectiveSuperAdmin || (accessibleList === null)\n const noOrgSelection = normalizedSelectedId === null && !explicitAllOrgsChoice\n const widenToAllOrgs =\n (explicitAllOrgsChoice && hasUnrestrictedAccess)\n || (effectiveSuperAdmin && noOrgSelection)\n const initialSelected =\n normalizedSelectedId\n ?? (widenToAllOrgs ? null : accountOrgId ?? null)\n // A selection is only honored when it resolves to a real, non-deleted org for\n // this tenant. `orgDescendants` holds exactly the existing orgs among the\n // candidate ids, so a stale/dead id (e.g. a selected-org cookie or JWT org\n // that no longer resolves after a DB reset) has no entry and is dropped here.\n // Without the existence guard an unrestricted (all-orgs) principal \u2014 whose\n // `allowedSet` is null \u2014 would accept the dead id as `effectiveSelected`, and\n // writes derived from `selectedId` would land under an org the read scope\n // (`filterIds`) never filters to, silently orphaning the record.\n const selectionResolvesToOrg = (id: string): boolean => orgDescendants.has(id)\n let effectiveSelected: string | null = null\n if (initialSelected) {\n if ((allowedSet === null || allowedSet.has(initialSelected)) && selectionResolvesToOrg(initialSelected)) {\n effectiveSelected = initialSelected\n }\n }\n\n let filterSet: Set<string> | null = null\n if (effectiveSelected) {\n filterSet = expandWithDescendants(orgDescendants, [effectiveSelected])\n } else if (allowedSet !== null) {\n filterSet = allowedSet\n } else if (widenToAllOrgs) {\n filterSet = null\n } else if (auth.orgId) {\n filterSet = loadFallbackSet()\n // Keep the write target (`selectedId`) aligned with the read scope\n // (`filterIds`): when an unrestricted principal's requested selection was\n // dropped above, fall the selection back to the account org too so a record\n // created here is always readable back by the same caller.\n if (!effectiveSelected && fallbackOrgId && filterSet && filterSet.size > 0) {\n effectiveSelected = fallbackOrgId\n }\n }\n\n if ((!filterSet || filterSet.size === 0) && fallbackOrgId && !widenToAllOrgs) {\n const computed = loadFallbackSet()\n if (computed && computed.size > 0) {\n filterSet = computed\n if (!effectiveSelected) {\n effectiveSelected = fallbackOrgId\n }\n }\n }\n\n // A concrete organization was explicitly requested (`normalizedSelectedId` is\n // null for both \"no selection\" and the \"all organizations\" token) but the\n // resolver could not honor it \u2014 it was dropped as non-existent/inaccessible\n // and the effective selection fell back to something else. Surface this so the\n // write layer can reject the request instead of silently creating the record\n // under the fallback org. Only set the field when true to keep the scope shape\n // unchanged for the common (honored) case.\n const selectionRejected = normalizedSelectedId !== null && effectiveSelected !== normalizedSelectedId\n\n return {\n selectedId: effectiveSelected,\n filterIds: filterSet ? Array.from(filterSet) : null,\n allowedIds: allowedSet ? Array.from(allowedSet) : null,\n tenantId,\n ...(selectionRejected ? { selectionRejected: true } : {}),\n }\n}\n\nexport async function resolveOrganizationScopeForRequest({\n container,\n auth,\n request,\n selectedId,\n tenantId: tenantOverride,\n}: {\n container: AwilixContainer\n auth: AuthContext | null | undefined\n request?: Request | { cookies?: { get: (name: string) => { value: string } | undefined }; headers?: { get(name: string): string | null } }\n selectedId?: string | null\n tenantId?: string | null\n}): Promise<OrganizationScope> {\n if (!auth || !auth.sub) {\n return { selectedId: null, filterIds: null, allowedIds: null, tenantId: null }\n }\n\n let em: EntityManager | null = null\n let rbac: RbacService | null = null\n try { em = container.resolve<EntityManager>('em') } catch { em = null }\n try { rbac = container.resolve<RbacService>('rbacService') } catch { rbac = null }\n const normalizeString = (value: unknown): string | null => {\n if (typeof value === 'string' && value.trim().length > 0) return value.trim()\n return null\n }\n if (!em || !rbac) {\n const fallbackSelected = normalizeOrganizationId(selectedId ?? auth.orgId ?? null)\n return {\n selectedId: fallbackSelected,\n filterIds: fallbackSelected ? [fallbackSelected] : null,\n allowedIds: fallbackSelected ? [fallbackSelected] : null,\n tenantId: normalizeString(auth.tenantId),\n }\n }\n\n const actorTenantField = (auth as { actorTenantId?: string | null }).actorTenantId\n const actorTenant = actorTenantField === undefined\n ? normalizeString(auth.tenantId)\n : actorTenantField === null\n ? null\n : normalizeString(actorTenantField)\n const actorOrgField = (auth as { actorOrgId?: string | null }).actorOrgId\n const actorOrgId = actorOrgField === undefined\n ? normalizeString(auth.orgId)\n : actorOrgField === null\n ? null\n : normalizeString(actorOrgField)\n\n const cookieTenant = request ? getSelectedTenantFromRequest(request) : null\n const requestedTenant =\n tenantOverride !== undefined\n ? tenantOverride\n : cookieTenant !== undefined\n ? cookieTenant\n : undefined\n const requestedTenantId = typeof requestedTenant === 'string' && requestedTenant.trim().length > 0 ? requestedTenant.trim() : null\n const isSuperAdminActor = auth.isSuperAdmin === true\n let effectiveTenantId = requestedTenantId ?? actorTenant ?? null\n if (actorTenant && effectiveTenantId && effectiveTenantId !== actorTenant && !isSuperAdminActor) {\n effectiveTenantId = actorTenant\n }\n if (!effectiveTenantId) {\n return { selectedId: null, filterIds: null, allowedIds: null, tenantId: null }\n }\n\n const scopedAuth = {\n ...auth,\n tenantId: effectiveTenantId,\n orgId: actorTenant && actorTenant === effectiveTenantId ? actorOrgId ?? null : null,\n }\n\n const rawSelected = selectedId !== undefined ? selectedId : (request ? getSelectedOrganizationFromRequest(request) : null)\n const normalizedSelectedId = typeof rawSelected === 'string' && rawSelected.trim().length > 0\n ? rawSelected.trim()\n : null\n\n const userId = typeof auth.sub === 'string' && auth.sub.length > 0 ? auth.sub : null\n const ttlMs = resolveOrgScopeTtlMs()\n const cache = ttlMs > 0 ? resolveCacheFromContainer(container) : null\n const cacheKey = userId\n ? buildOrgScopeCacheKey({\n userId,\n effectiveTenantId,\n selectedOrgId: normalizedSelectedId,\n requestedTenantId: requestedTenantId ?? null,\n })\n : null\n\n const requestMemo = getRequestScopeMemo(request)\n if (requestMemo && cacheKey) {\n const memoized = requestMemo.get(cacheKey)\n if (memoized) return memoized\n }\n\n const resolveScope = async (): Promise<OrganizationScope> => {\n if (cache && cacheKey && typeof cache.get === 'function') {\n try {\n const cached = await cache.get(cacheKey)\n if (isValidCachedScope(cached)) return cached\n } catch (err) {\n logger.warn('Cache read failed', { err })\n }\n }\n\n const baseScope = await resolveOrganizationScope({\n em,\n rbac,\n auth: scopedAuth,\n selectedId: rawSelected,\n tenantId: effectiveTenantId,\n })\n\n if (cache && cacheKey && userId && typeof cache.set === 'function') {\n try {\n await cache.set(cacheKey, baseScope, {\n ttl: ttlMs,\n tags: buildOrgScopeCacheTags({ userId, effectiveTenantId }),\n })\n } catch (err) {\n logger.warn('Cache write failed', { err })\n }\n }\n\n return baseScope\n }\n\n if (requestMemo && cacheKey) {\n const pending = resolveScope()\n requestMemo.set(cacheKey, pending)\n return pending\n }\n\n return resolveScope()\n}\n\nexport type FeatureCheckContext = {\n organizationId: string | null\n scope: OrganizationScope\n allowedOrganizationIds: string[] | null\n}\n\nexport async function resolveFeatureCheckContext({\n container,\n auth,\n request,\n selectedId,\n tenantId,\n}: {\n container: AwilixContainer\n auth: AuthContext | null | undefined\n request?: Request | { cookies?: { get: (name: string) => { value: string } | undefined } }\n selectedId?: string | null\n tenantId?: string | null\n}): Promise<FeatureCheckContext> {\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request, selectedId, tenantId })\n const allowedOrganizationIds = scope.allowedIds ?? null\n const authOrgId = auth?.orgId ?? null\n const organizationId =\n scope.selectedId\n ?? (authOrgId && (!Array.isArray(allowedOrganizationIds) || allowedOrganizationIds.includes(authOrgId)) ? authOrgId : null)\n ?? (Array.isArray(allowedOrganizationIds) && allowedOrganizationIds.length ? allowedOrganizationIds[0] : null)\n\n return { organizationId, scope, allowedOrganizationIds }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6676.1.7dad6df292",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -254,16 +254,16 @@
|
|
|
254
254
|
"zod": "^4.4.3"
|
|
255
255
|
},
|
|
256
256
|
"peerDependencies": {
|
|
257
|
-
"@open-mercato/ai-assistant": "0.6.7-develop.
|
|
258
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
259
|
-
"@open-mercato/ui": "0.6.7-develop.
|
|
257
|
+
"@open-mercato/ai-assistant": "0.6.7-develop.6676.1.7dad6df292",
|
|
258
|
+
"@open-mercato/shared": "0.6.7-develop.6676.1.7dad6df292",
|
|
259
|
+
"@open-mercato/ui": "0.6.7-develop.6676.1.7dad6df292",
|
|
260
260
|
"react": "^19.0.0",
|
|
261
261
|
"react-dom": "^19.0.0"
|
|
262
262
|
},
|
|
263
263
|
"devDependencies": {
|
|
264
|
-
"@open-mercato/ai-assistant": "0.6.7-develop.
|
|
265
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
266
|
-
"@open-mercato/ui": "0.6.7-develop.
|
|
264
|
+
"@open-mercato/ai-assistant": "0.6.7-develop.6676.1.7dad6df292",
|
|
265
|
+
"@open-mercato/shared": "0.6.7-develop.6676.1.7dad6df292",
|
|
266
|
+
"@open-mercato/ui": "0.6.7-develop.6676.1.7dad6df292",
|
|
267
267
|
"@testing-library/dom": "^10.4.1",
|
|
268
268
|
"@testing-library/jest-dom": "^6.9.1",
|
|
269
269
|
"@testing-library/react": "^16.3.1",
|
|
@@ -6,6 +6,7 @@ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacS
|
|
|
6
6
|
import { AccessLogService } from '@open-mercato/core/modules/audit_logs/services/accessLogService'
|
|
7
7
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
8
8
|
import { loadAuditLogDisplayMaps } from '../display'
|
|
9
|
+
import { requireResolvedTenantScope } from '../readScope'
|
|
9
10
|
import { z } from 'zod'
|
|
10
11
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
11
12
|
|
|
@@ -74,6 +75,9 @@ export async function GET(req: Request) {
|
|
|
74
75
|
const auth = await getAuthFromRequest(req)
|
|
75
76
|
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
76
77
|
|
|
78
|
+
const tenantScopeGuard = requireResolvedTenantScope(auth)
|
|
79
|
+
if (tenantScopeGuard) return tenantScopeGuard
|
|
80
|
+
|
|
77
81
|
const container = await createRequestContainer()
|
|
78
82
|
const { organizationId: defaultOrganizationId, scope } = await resolveFeatureCheckContext({ container, auth, request: req })
|
|
79
83
|
|
|
@@ -177,6 +181,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
177
181
|
errors: [
|
|
178
182
|
{ status: 400, description: 'Invalid filters supplied', schema: errorSchema },
|
|
179
183
|
{ status: 401, description: 'Authentication required', schema: errorSchema },
|
|
184
|
+
{ status: 403, description: 'Caller has no resolved tenant scope and is not a superadmin', schema: errorSchema },
|
|
180
185
|
],
|
|
181
186
|
},
|
|
182
187
|
},
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from '@open-mercato/core/modules/audit_logs/lib/projections'
|
|
17
17
|
import { ActionLogService } from '@open-mercato/core/modules/audit_logs/services/actionLogService'
|
|
18
18
|
import { loadAuditLogDisplayMaps } from '../../display'
|
|
19
|
+
import { requireResolvedTenantScope } from '../../readScope'
|
|
19
20
|
|
|
20
21
|
export const metadata = {
|
|
21
22
|
GET: { requireAuth: true, requireFeatures: ['audit_logs.view_self'] },
|
|
@@ -112,6 +113,9 @@ export async function GET(req: Request) {
|
|
|
112
113
|
const auth = await getAuthFromRequest(req)
|
|
113
114
|
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
114
115
|
|
|
116
|
+
const tenantScopeGuard = requireResolvedTenantScope(auth)
|
|
117
|
+
if (tenantScopeGuard) return tenantScopeGuard
|
|
118
|
+
|
|
115
119
|
const container = await createRequestContainer()
|
|
116
120
|
const { organizationId: defaultOrganizationId, scope } = await resolveFeatureCheckContext({ container, auth, request: req })
|
|
117
121
|
const rbac = container.resolve('rbacService') as RbacService
|
|
@@ -262,6 +266,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
262
266
|
errors: [
|
|
263
267
|
{ status: 400, description: 'Invalid filter values', schema: errorSchema },
|
|
264
268
|
{ status: 401, description: 'Authentication required', schema: errorSchema },
|
|
269
|
+
{ status: 403, description: 'Caller has no resolved tenant scope and is not a superadmin', schema: errorSchema },
|
|
265
270
|
],
|
|
266
271
|
},
|
|
267
272
|
},
|
|
@@ -6,6 +6,7 @@ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacS
|
|
|
6
6
|
import { ActionLogService } from '@open-mercato/core/modules/audit_logs/services/actionLogService'
|
|
7
7
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
8
8
|
import { loadAuditLogDisplayMaps } from '../display'
|
|
9
|
+
import { requireResolvedTenantScope } from '../readScope'
|
|
9
10
|
import { z } from 'zod'
|
|
10
11
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
11
12
|
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
@@ -151,6 +152,9 @@ export async function GET(req: Request) {
|
|
|
151
152
|
const auth = await getAuthFromRequest(req)
|
|
152
153
|
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
153
154
|
|
|
155
|
+
const tenantScopeGuard = requireResolvedTenantScope(auth)
|
|
156
|
+
if (tenantScopeGuard) return tenantScopeGuard
|
|
157
|
+
|
|
154
158
|
const container = await createRequestContainer()
|
|
155
159
|
const { organizationId: defaultOrganizationId, scope } = await resolveFeatureCheckContext({ container, auth, request: req })
|
|
156
160
|
|
|
@@ -291,6 +295,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
291
295
|
errors: [
|
|
292
296
|
{ status: 400, description: 'Invalid filter values', schema: errorSchema },
|
|
293
297
|
{ status: 401, description: 'Authentication required', schema: errorSchema },
|
|
298
|
+
{ status: 403, description: 'Caller has no resolved tenant scope and is not a superadmin', schema: errorSchema },
|
|
294
299
|
],
|
|
295
300
|
},
|
|
296
301
|
},
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
|
|
3
|
+
|
|
4
|
+
type ResolvedAuth = NonNullable<AuthContext>
|
|
5
|
+
|
|
6
|
+
// `isSuperAdmin` is not part of the declared `AuthContext` shape — it reaches callers
|
|
7
|
+
// through the type's index signature — so read it exactly the way
|
|
8
|
+
// `shared/lib/auth/server.ts` does instead of widening the shared contract here.
|
|
9
|
+
function isSuperAdmin(auth: ResolvedAuth): boolean {
|
|
10
|
+
return (auth as Record<string, unknown>).isSuperAdmin === true
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fail closed on tenant scope for audit-log reads.
|
|
15
|
+
*
|
|
16
|
+
* `audit_logs.view_tenant` only widens visibility WITHIN a tenant, so cross-tenant reads
|
|
17
|
+
* must be gated on `isSuperAdmin`, never on the caller's tenant being empty: a tenant-less
|
|
18
|
+
* principal (unscoped API key or global account) holding `view_tenant` otherwise drops the
|
|
19
|
+
* tenant, organization and actor predicates at once and reads decrypted action-log rows for
|
|
20
|
+
* every tenant in the instance (issues #3817, #3818).
|
|
21
|
+
*
|
|
22
|
+
* Plain truthiness is deliberate — it matches the `if (parsed.tenantId)` predicate guard in
|
|
23
|
+
* ActionLogService/AccessLogService, so every value that would skip it is rejected here.
|
|
24
|
+
*/
|
|
25
|
+
export function requireResolvedTenantScope(auth: ResolvedAuth): NextResponse | null {
|
|
26
|
+
if (auth.tenantId || isSuperAdmin(auth)) return null
|
|
27
|
+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
28
|
+
}
|