@open-mercato/core 0.10.1-develop.7767.2.440f7c2b4c → 0.10.1-develop.7768.1.90fa052c11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/modules/api_keys/data/entities.js.map +1 -1
- package/dist/modules/api_keys/services/apiKeyService.js +1 -0
- package/dist/modules/api_keys/services/apiKeyService.js.map +2 -2
- package/dist/modules/auth/lib/authorizationStateLocks.js +13 -5
- package/dist/modules/auth/lib/authorizationStateLocks.js.map +2 -2
- package/dist/modules/auth/services/rbacService.js +68 -11
- package/dist/modules/auth/services/rbacService.js.map +2 -2
- package/dist/modules/directory/utils/organizationScope.js +6 -3
- package/dist/modules/directory/utils/organizationScope.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/api_keys/data/entities.ts +1 -1
- package/src/modules/api_keys/services/apiKeyService.ts +2 -0
- package/src/modules/auth/lib/authorizationStateLocks.ts +13 -7
- package/src/modules/auth/services/rbacService.ts +86 -9
- package/src/modules/directory/utils/organizationScope.ts +6 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/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 { ApiKey } from '@open-mercato/core/modules/api_keys/data/entities'\nimport { isSessionBoundApiKey } from '@open-mercato/core/modules/api_keys/services/sessionKeyRoles'\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 { OrganizationScope } from '@open-mercato/shared/lib/auth/principal-service'\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\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:v2'\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\ntype BoundFeatureScope = {\n userId: string\n scope: OrganizationScope\n organizationId: string | null\n}\n\nconst featureScopeRequestMemo = new WeakMap<object, BoundFeatureScope>()\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 bindFeatureScopeToRequest(request: unknown, binding: BoundFeatureScope): void {\n if (!request || (typeof request !== 'object' && typeof request !== 'function')) return\n featureScopeRequestMemo.set(request as object, binding)\n}\n\nfunction getFeatureScopeForRequest(\n request: unknown,\n userId: string,\n tenantOverride: string | null | undefined,\n): BoundFeatureScope | null {\n if (!request || (typeof request !== 'object' && typeof request !== 'function')) return null\n const entry = featureScopeRequestMemo.get(request as object)\n if (!entry || entry.userId !== userId) return null\n if (tenantOverride === undefined) return entry\n const requestedTenantId = typeof tenantOverride === 'string' ? tenantOverride.trim() : ''\n return requestedTenantId.length > 0 && requestedTenantId === entry.scope.tenantId ? entry : null\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: Pick<RbacService, 'loadAcl'>\n auth: AuthContext | null | undefined\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 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 (allowedSet === null && (!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 if (selectedId === undefined) {\n const boundFeatureScope = getFeatureScopeForRequest(request, auth.sub, tenantOverride)\n if (boundFeatureScope) return boundFeatureScope.scope\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 let crossRequestCache = cache\n if (crossRequestCache && userId?.startsWith('api_key:')) {\n const key = await em.findOne(ApiKey, { id: userId.slice('api_key:'.length), deletedAt: null })\n if (!key || isSessionBoundApiKey(key)) crossRequestCache = null\n }\n if (crossRequestCache && cacheKey && typeof crossRequestCache.get === 'function') {\n try {\n const cached = await crossRequestCache.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 (crossRequestCache && cacheKey && userId && typeof crossRequestCache.set === 'function') {\n try {\n await crossRequestCache.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 requiredFeatures,\n}: {\n container: AwilixContainer\n auth: AuthContext | null | undefined\n request?: Request | {\n cookies?: { get: (name: string) => { value: string } | undefined }\n headers?: { get(name: string): string | null }\n }\n selectedId?: string | null\n tenantId?: string | null\n requiredFeatures?: readonly string[]\n}): Promise<FeatureCheckContext> {\n const boundFeatureScope = auth?.sub && selectedId === undefined\n ? getFeatureScopeForRequest(request, auth.sub, tenantId)\n : null\n let scope = boundFeatureScope\n ? boundFeatureScope.scope\n : await resolveOrganizationScopeForRequest({ container, auth, request, selectedId, tenantId })\n const organizationId = boundFeatureScope\n ? boundFeatureScope.organizationId\n : resolveFeatureCheckOrganizationId(scope, auth)\n if (\n auth?.sub\n && request\n && scope.selectedId === null\n && scope.tenantId\n && Array.isArray(requiredFeatures)\n && requiredFeatures.length > 0\n ) {\n const em = container.resolve<EntityManager>('em')\n const rbac = container.resolve<RbacService>('rbacService')\n const featureAccess = await rbac.resolveFeatureOrganizationAccess(\n auth.sub,\n requiredFeatures,\n { tenantId: scope.tenantId },\n )\n if (!featureAccess.unrestricted) {\n const organizationFilter: FilterQuery<Organization> = {\n tenant: scope.tenantId,\n deletedAt: null,\n ...(Array.isArray(scope.filterIds) ? { id: { $in: scope.filterIds } } : {}),\n }\n const organizations = await em.find(Organization, organizationFilter, {\n fields: ['id', 'ancestorIds'],\n })\n const candidateOrganizationIds = new Set(\n organizations.map((organization) => String(organization.id)),\n )\n const featureOrganizationIds = featureAccess.filterOrganizationIds(\n organizations.map((organization) => ({\n id: String(organization.id),\n ancestorIds: Array.isArray(organization.ancestorIds) ? organization.ancestorIds : [],\n })),\n )\n const approvedOrganizationIds = Array.from(new Set(\n featureOrganizationIds.filter((featureOrganizationId) => candidateOrganizationIds.has(featureOrganizationId)),\n ))\n scope = {\n selectedId: null,\n filterIds: approvedOrganizationIds,\n allowedIds: approvedOrganizationIds,\n tenantId: scope.tenantId,\n }\n }\n bindFeatureScopeToRequest(request, { userId: auth.sub, scope, organizationId })\n }\n\n return { organizationId, scope, allowedOrganizationIds: scope.allowedIds ?? null }\n}\n\nfunction resolveFeatureCheckOrganizationId(\n scope: OrganizationScope,\n auth: AuthContext | null | undefined,\n): string | null {\n const allowedOrganizationIds = scope.allowedIds ?? null\n const authOrgId = auth?.orgId ?? null\n return scope.selectedId\n ?? (authOrgId && (!Array.isArray(allowedOrganizationIds) || allowedOrganizationIds.includes(authOrgId)) ? authOrgId : null)\n ?? (Array.isArray(allowedOrganizationIds) && allowedOrganizationIds.length ? allowedOrganizationIds[0] : null)\n}\n"],
|
|
5
|
-
"mappings": "AAGA,SAAS,oBAAoB;AAC7B,SAAS,cAAc;AACvB,SAAS,4BAA4B;AACrC,SAAS,mCAAmC;AAI5C,SAAS,uBAAuB,0BAA8C;AAC9E,SAAS,iCAAiC,iCAAiC;AAC3E,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,WAAW,EAAE,MAAM,EAAE,WAAW,kBAAkB,CAAC;AAe/E,MAAM,6BAA6B;AAInC,MAAM,2BAA2B;AAEjC,SAAS,uBAA+B;AACtC,QAAM,MAAM,QAAQ,IAAI;AACxB,MAAI,QAAQ,OAAW,QAAO;AAC9B,QAAM,SAAS,OAAO,GAAG;AACzB,MAAI,CAAC,OAAO,SAAS,MAAM,KAAK,SAAS,EAAG,QAAO;AACnD,SAAO;AACT;AAEA,SAAS,sBAAsB,OAKpB;AACT,QAAM,WAAW,MAAM,iBAAiB;AACxC,QAAM,YAAY,MAAM,qBAAqB;AAC7C,SAAO,GAAG,0BAA0B,IAAI,MAAM,MAAM,IAAI,MAAM,iBAAiB,IAAI,QAAQ,IAAI,SAAS;AAC1G;AAOO,SAAS,0BAA0B,QAAwB;AAChE,SAAO,GAAG,0BAA0B,SAAS,MAAM;AACrD;AAEO,SAAS,4BAA4B,UAA0B;AACpE,SAAO,GAAG,0BAA0B,WAAW,QAAQ;AACzD;AAEA,SAAS,uBAAuB,OAAgE;AAC9F,SAAO;AAAA,IACL,0BAA0B,MAAM,MAAM;AAAA,IACtC,4BAA4B,MAAM,iBAAiB;AAAA,EACrD;AACF;AAEA,SAAS,mBAAmB,OAA4C;AACtE,MAAI,OAAO,UAAU,YAAY,UAAU,KAAM,QAAO;AACxD,QAAM,SAAS;AACf,QAAM,OAAO,CAAC,MAAe,MAAM,QAAQ,OAAO,MAAM;AACxD,QAAM,QAAQ,CAAC,MAAe,MAAM,QAAS,MAAM,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,UAAU,OAAO,UAAU,QAAQ;AAC7G,QAAM,SAAS,OAAO,sBAAsB,UAAa,OAAO,OAAO,sBAAsB;AAC7F,SAAO,KAAK,OAAO,UAAU,KAAK,KAAK,OAAO,QAAQ,KAAK,MAAM,OAAO,SAAS,KAAK,MAAM,OAAO,UAAU,KAAK;AACpH;AAEA,SAAS,0BAA0B,WAAqE;AACtG,MAAI,CAAC,UAAW,QAAO;AACvB,MAAI;AACF,UAAM,IAAI,UAAU,QAAQ,OAAO;AACnC,QAAI,KAAK,OAAO,EAAE,QAAQ,cAAc,OAAO,EAAE,QAAQ,WAAY,QAAO;AAAA,EAC9E,QAAQ;AACN,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAEA,eAAsB,wCACpB,WACA,QACA,UACe;AACf,QAAM,QAAQ,0BAA0B,SAAS;AACjD,MAAI,CAAC,OAAO,aAAc;AAC1B,MAAI;AACF,UAAM,gBAAgB,aAAa,SAAY,sBAAsB,IAAI;AACzE,UAAM;AAAA,MAAmB;AAAA,MAAe,MACtC,MAAM,aAAa,CAAC,0BAA0B,MAAM,CAAC,CAAC;AAAA,IACxD;AAAA,EACF,SAAS,KAAK;AACZ,WAAO,KAAK,gCAAgC,EAAE,IAAI,CAAC;AAAA,EACrD;AACF;AAEA,eAAsB,0CACpB,WACA,UACe;AACf,QAAM,QAAQ,0BAA0B,SAAS;AACjD,MAAI,CAAC,OAAO,aAAc;AAC1B,MAAI;AACF,UAAM;AAAA,MAAmB;AAAA,MAAU,MACjC,MAAM,aAAa,CAAC,4BAA4B,QAAQ,CAAC,CAAC;AAAA,IAC5D;AAAA,EACF,SAAS,KAAK;AACZ,WAAO,KAAK,kCAAkC,EAAE,IAAI,CAAC;AAAA,EACvD;AACF;AAaA,MAAM,sBAAsB,oBAAI,QAAyD;AAQzF,MAAM,0BAA0B,oBAAI,QAAmC;AAEvE,SAAS,oBAAoB,SAAkE;AAC7F,MAAI,CAAC,WAAY,OAAO,YAAY,YAAY,OAAO,YAAY,WAAa,QAAO;AACvF,QAAM,MAAM;AACZ,MAAI,OAAO,oBAAoB,IAAI,GAAG;AACtC,MAAI,CAAC,MAAM;AACT,WAAO,oBAAI,IAAwC;AACnD,wBAAoB,IAAI,KAAK,IAAI;AAAA,EACnC;AACA,SAAO;AACT;AAEA,SAAS,0BAA0B,SAAkB,SAAkC;AACrF,MAAI,CAAC,WAAY,OAAO,YAAY,YAAY,OAAO,YAAY,WAAa;AAChF,0BAAwB,IAAI,SAAmB,OAAO;AACxD;AAEA,SAAS,0BACP,SACA,QACA,gBAC0B;AAC1B,MAAI,CAAC,WAAY,OAAO,YAAY,YAAY,OAAO,YAAY,WAAa,QAAO;AACvF,QAAM,QAAQ,wBAAwB,IAAI,OAAiB;AAC3D,MAAI,CAAC,SAAS,MAAM,WAAW,OAAQ,QAAO;AAC9C,MAAI,mBAAmB,OAAW,QAAO;AACzC,QAAM,oBAAoB,OAAO,mBAAmB,WAAW,eAAe,KAAK,IAAI;AACvF,SAAO,kBAAkB,SAAS,KAAK,sBAAsB,MAAM,MAAM,WAAW,QAAQ;AAC9F;AAEA,SAAS,wBAAwB,OAA+B;AAC9D,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,UAAU,MAAM,KAAK;AAC3B,SAAO,QAAQ,SAAS,IAAI,UAAU;AACxC;AAEO,SAAS,mCAAmC,KAAsJ;AACvM,QAAM,kBAAmB,IAA+E;AACxG,MAAI,mBAAmB,OAAO,gBAAgB,QAAQ,YAAY;AAChE,UAAM,MAAM,gBAAgB,IAAI,iBAAiB,GAAG;AACpD,WAAO,OAAO;AAAA,EAChB;AACA,QAAM,kBAAmB,IAA2D;AACpF,QAAM,SAAS,OAAO,iBAAiB,QAAQ,aAAa,gBAAgB,IAAI,QAAQ,IAAI;AAC5F,SAAO,gCAAgC,MAAM;AAC/C;AAEO,SAAS,6BACd,KACe;AACf,QAAM,kBAAmB,IAA+E;AACxG,MAAI,mBAAmB,OAAO,gBAAgB,QAAQ,YAAY;AAChE,UAAM,MAAM,gBAAgB,IAAI,oBAAoB,GAAG;AACvD,WAAO,OAAO;AAAA,EAChB;AACA,QAAM,kBAAmB,IAA2D;AACpF,QAAM,SAAS,OAAO,iBAAiB,QAAQ,aAAa,gBAAgB,IAAI,QAAQ,IAAI;AAC5F,SAAO,0BAA0B,MAAM;AACzC;AAEA,SAAS,yBAAyB,KAAyB;AACzD,SAAO,MAAM,KAAK,IAAI;AAAA,IACpB,IAAI,IAAI,CAAC,UAAU,wBAAwB,KAAK,CAAC,EAAE,OAAO,CAAC,UAA2B;AACpF,UAAI,CAAC,MAAO,QAAO;AACnB,UAAI,4BAA4B,KAAK,EAAG,QAAO;AAC/C,aAAO;AAAA,IACT,CAAC;AAAA,EACH,CAAC;AACH;AAaA,eAAe,qBAAqB,IAAmB,UAAkB,KAA0C;AACjH,QAAM,SAAS,yBAAyB,GAAG;AAC3C,MAAI,CAAC,OAAO,OAAQ,QAAO,oBAAI,IAAI;AACnC,QAAM,SAAoC;AAAA,IACxC,QAAQ;AAAA,IACR,IAAI,EAAE,KAAK,OAAO;AAAA,IAClB,WAAW;AAAA,EACb;AACA,QAAM,OAAO,MAAM,GAAG,KAAK,cAAc,MAAM;AAC/C,QAAM,MAAwB,oBAAI,IAAI;AACtC,aAAW,OAAO,MAAM;AACtB,UAAM,KAAK,OAAO,IAAI,EAAE;AACxB,UAAM,YAAY,CAAC,EAAE;AACrB,QAAI,MAAM,QAAQ,IAAI,aAAa,GAAG;AACpC,iBAAW,QAAQ,IAAI,cAAe,WAAU,KAAK,OAAO,IAAI,CAAC;AAAA,IACnE;AACA,QAAI,IAAI,IAAI,SAAS;AAAA,EACvB;AACA,SAAO;AACT;AAEA,SAAS,sBAAsB,KAAuB,KAA4B;AAChF,QAAM,MAAM,oBAAI,IAAY;AAC5B,aAAW,SAAS,KAAK;AACvB,UAAM,KAAK,wBAAwB,KAAK;AACxC,QAAI,CAAC,MAAM,4BAA4B,EAAE,EAAG;AAC5C,UAAM,YAAY,IAAI,IAAI,EAAE;AAC5B,QAAI,CAAC,UAAW;AAChB,eAAW,SAAS,UAAW,KAAI,IAAI,KAAK;AAAA,EAC9C;AACA,SAAO;AACT;AAEA,eAAsB,yBAAyB;AAAA,EAC7C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AACZ,GAM+B;AAC7B,MAAI,CAAC,QAAQ,CAAC,KAAK,KAAK;AACtB,WAAO,EAAE,YAAY,MAAM,WAAW,MAAM,YAAY,MAAM,UAAU,KAAK;AAAA,EAC/E;AACA,QAAM,gBAAgB,OAAO,KAAK,aAAa,YAAY,KAAK,SAAS,KAAK,EAAE,SAAS,IAAI,KAAK,SAAS,KAAK,IAAI;AACpH,QAAM,oBAAoB,OAAO,qBAAqB,YAAY,iBAAiB,KAAK,EAAE,SAAS,IAC/F,iBAAiB,KAAK,IACtB,qBAAqB,OACnB,OACA;AACN,MAAI,CAAC,mBAAmB;AACtB,WAAO,EAAE,YAAY,MAAM,WAAW,MAAM,YAAY,MAAM,UAAU,KAAK;AAAA,EAC/E;AACA,QAAM,gBAAgB,sBAAsB;AAC5C,QAAM,oBAAoB,KAAK,iBAAiB;AAChD,QAAM,WAAW,iBAAiB,iBAAiB,CAAC,oBAAoB,gBAAgB;AACxF,MAAI,CAAC,UAAU;AACb,WAAO,EAAE,YAAY,MAAM,WAAW,MAAM,YAAY,MAAM,UAAU,KAAK;AAAA,EAC/E;AACA,QAAM,+BAA+B,wBAAwB,UAAU;AACvE,QAAM,wBACJ,iCAAiC,QAAQ,4BAA4B,4BAA4B;AACnG,QAAM,uBAAuB,wBACzB,OACA;AACJ,QAAM,eAAe,iBAAiB,kBAAkB,WAAW,wBAAwB,KAAK,KAAK,IAAI;AACzG,QAAM,MAAM,MAAM,KAAK,QAAQ,KAAK,KAAK,EAAE,UAAU,gBAAgB,aAAa,CAAC;AACnF,QAAM,kBAAkB,KAAK,iBAAiB;AAC9C,QAAM,sBAAsB,mBAAmB;AAC/C,QAAM,uBAAuB,sBACzB,OACA,MAAM,QAAQ,KAAK,aAAa,IAC9B,IAAI,cACH,IAAI,CAAC,UAAU,wBAAwB,KAAK,CAAC,EAC7C,OAAO,CAAC,UAA2B,UAAU,IAAI,IAClD;AACN,QAAM,iBAAiB,sBACnB,OACA,wBAAwB,qBAAqB,KAAK,CAAC,UAAU,4BAA4B,KAAK,CAAC,IAC7F,OACA,sBAAsB,OAAO,CAAC,UAAU,CAAC,4BAA4B,KAAK,CAAC,KAAK;AAEtF,QAAM,eAAe,iBAAiB,kBAAkB,WAAW,wBAAwB,KAAK,KAAK,IAAI;AACzG,QAAM,gBAAgB,gBAAgB;AAKtC,QAAM,eAAe;AAAA,IACnB,GAAI,kBAAkB,CAAC;AAAA,IACvB,GAAI,gBAAgB,CAAC,aAAa,IAAI,CAAC;AAAA,IACvC,GAAI,uBAAuB,CAAC,oBAAoB,IAAI,CAAC;AAAA,EACvD;AACA,QAAM,iBAAiB,MAAM,qBAAqB,IAAI,UAAU,YAAY;AAC5E,QAAM,kBAAkB,MACtB,gBAAgB,sBAAsB,gBAAgB,CAAC,aAAa,CAAC,IAAI;AAE3E,MAAI,aAAiC;AACrC,MAAI,mBAAmB,MAAM;AAC3B,iBAAa;AAAA,EACf,WAAW,eAAe,WAAW,GAAG;AACtC,iBAAa,oBAAI,IAAI;AAAA,EACvB,OAAO;AACL,iBAAa,sBAAsB,gBAAgB,cAAc;AAAA,EACnE;AAEA,QAAM,wBAAwB,uBAAwB,mBAAmB;AACzE,QAAM,iBAAiB,yBAAyB,QAAQ,CAAC;AACzD,QAAM,iBACH,yBAAyB,yBACtB,uBAAuB;AAC7B,QAAM,kBACJ,yBACI,iBAAiB,OAAO,gBAAgB;AAS9C,QAAM,yBAAyB,CAAC,OAAwB,eAAe,IAAI,EAAE;AAC7E,MAAI,oBAAmC;AACvC,MAAI,iBAAiB;AACnB,SAAK,eAAe,QAAQ,WAAW,IAAI,eAAe,MAAM,uBAAuB,eAAe,GAAG;AACvG,0BAAoB;AAAA,IACtB;AAAA,EACF;AAEA,MAAI,YAAgC;AACpC,MAAI,mBAAmB;AACrB,gBAAY,sBAAsB,gBAAgB,CAAC,iBAAiB,CAAC;AAAA,EACvE,WAAW,eAAe,MAAM;AAC9B,gBAAY;AAAA,EACd,WAAW,gBAAgB;AACzB,gBAAY;AAAA,EACd,WAAW,KAAK,OAAO;AACrB,gBAAY,gBAAgB;AAK5B,QAAI,CAAC,qBAAqB,iBAAiB,aAAa,UAAU,OAAO,GAAG;AAC1E,0BAAoB;AAAA,IACtB;AAAA,EACF;AAEA,MAAI,eAAe,SAAS,CAAC,aAAa,UAAU,SAAS,MAAM,iBAAiB,CAAC,gBAAgB;AACnG,UAAM,WAAW,gBAAgB;AACjC,QAAI,YAAY,SAAS,OAAO,GAAG;AACjC,kBAAY;AACZ,UAAI,CAAC,mBAAmB;AACtB,4BAAoB;AAAA,MACtB;AAAA,IACF;AAAA,EACF;AASA,QAAM,oBAAoB,yBAAyB,QAAQ,sBAAsB;AAEjF,SAAO;AAAA,IACL,YAAY;AAAA,IACZ,WAAW,YAAY,MAAM,KAAK,SAAS,IAAI;AAAA,IAC/C,YAAY,aAAa,MAAM,KAAK,UAAU,IAAI;AAAA,IAClD;AAAA,IACA,GAAI,oBAAoB,EAAE,mBAAmB,KAAK,IAAI,CAAC;AAAA,EACzD;AACF;AAEA,eAAsB,mCAAmC;AAAA,EACvD;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AACZ,GAM+B;AAC7B,MAAI,CAAC,QAAQ,CAAC,KAAK,KAAK;AACtB,WAAO,EAAE,YAAY,MAAM,WAAW,MAAM,YAAY,MAAM,UAAU,KAAK;AAAA,EAC/E;AAEA,MAAI,eAAe,QAAW;AAC5B,UAAM,oBAAoB,0BAA0B,SAAS,KAAK,KAAK,cAAc;AACrF,QAAI,kBAAmB,QAAO,kBAAkB;AAAA,EAClD;AAEA,MAAI,KAA2B;AAC/B,MAAI,OAA2B;AAC/B,MAAI;AAAE,SAAK,UAAU,QAAuB,IAAI;AAAA,EAAE,QAAQ;AAAE,SAAK;AAAA,EAAK;AACtE,MAAI;AAAE,WAAO,UAAU,QAAqB,aAAa;AAAA,EAAE,QAAQ;AAAE,WAAO;AAAA,EAAK;AACjF,QAAM,kBAAkB,CAAC,UAAkC;AACzD,QAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS,EAAG,QAAO,MAAM,KAAK;AAC5E,WAAO;AAAA,EACT;AACA,MAAI,CAAC,MAAM,CAAC,MAAM;AAChB,UAAM,mBAAmB,wBAAwB,cAAc,KAAK,SAAS,IAAI;AACjF,WAAO;AAAA,MACL,YAAY;AAAA,MACZ,WAAW,mBAAmB,CAAC,gBAAgB,IAAI;AAAA,MACnD,YAAY,mBAAmB,CAAC,gBAAgB,IAAI;AAAA,MACpD,UAAU,gBAAgB,KAAK,QAAQ;AAAA,IACzC;AAAA,EACF;AAEA,QAAM,mBAAoB,KAA2C;AACrE,QAAM,cAAc,qBAAqB,SACrC,gBAAgB,KAAK,QAAQ,IAC7B,qBAAqB,OACnB,OACA,gBAAgB,gBAAgB;AACtC,QAAM,gBAAiB,KAAwC;AAC/D,QAAM,aAAa,kBAAkB,SACjC,gBAAgB,KAAK,KAAK,IAC1B,kBAAkB,OAChB,OACA,gBAAgB,aAAa;AAEnC,QAAM,eAAe,UAAU,6BAA6B,OAAO,IAAI;AACvE,QAAM,kBACJ,mBAAmB,SACf,iBACA,iBAAiB,SACf,eACA;AACR,QAAM,oBAAoB,OAAO,oBAAoB,YAAY,gBAAgB,KAAK,EAAE,SAAS,IAAI,gBAAgB,KAAK,IAAI;AAC9H,QAAM,oBAAoB,KAAK,iBAAiB;AAChD,MAAI,oBAAoB,qBAAqB,eAAe;AAC5D,MAAI,eAAe,qBAAqB,sBAAsB,eAAe,CAAC,mBAAmB;AAC/F,wBAAoB;AAAA,EACtB;AACA,MAAI,CAAC,mBAAmB;AACtB,WAAO,EAAE,YAAY,MAAM,WAAW,MAAM,YAAY,MAAM,UAAU,KAAK;AAAA,EAC/E;AAEA,QAAM,aAAa;AAAA,IACjB,GAAG;AAAA,IACH,UAAU;AAAA,IACV,OAAO,eAAe,gBAAgB,oBAAoB,cAAc,OAAO;AAAA,EACjF;AAEA,QAAM,cAAc,eAAe,SAAY,aAAc,UAAU,mCAAmC,OAAO,IAAI;AACrH,QAAM,uBAAuB,OAAO,gBAAgB,YAAY,YAAY,KAAK,EAAE,SAAS,IACxF,YAAY,KAAK,IACjB;AAEJ,QAAM,SAAS,OAAO,KAAK,QAAQ,YAAY,KAAK,IAAI,SAAS,IAAI,KAAK,MAAM;AAChF,QAAM,QAAQ,qBAAqB;AACnC,QAAM,QAAQ,QAAQ,IAAI,0BAA0B,SAAS,IAAI;AACjE,QAAM,WAAW,SACb,sBAAsB;AAAA,IACpB;AAAA,IACA;AAAA,IACA,eAAe;AAAA,IACf,mBAAmB,qBAAqB;AAAA,EAC1C,CAAC,IACD;AAEJ,QAAM,cAAc,oBAAoB,OAAO;AAC/C,MAAI,eAAe,UAAU;AAC3B,UAAM,WAAW,YAAY,IAAI,QAAQ;AACzC,QAAI,SAAU,QAAO;AAAA,EACvB;AAEA,QAAM,eAAe,YAAwC;AAC3D,QAAI,oBAAoB;AACxB,QAAI,qBAAqB,QAAQ,WAAW,UAAU,GAAG;AACvD,YAAM,MAAM,MAAM,GAAG,QAAQ,QAAQ,EAAE,IAAI,OAAO,MAAM,WAAW,MAAM,GAAG,WAAW,KAAK,CAAC;AAC7F,UAAI,CAAC,OAAO,qBAAqB,GAAG,EAAG,qBAAoB;AAAA,IAC7D;AACA,QAAI,qBAAqB,YAAY,OAAO,kBAAkB,QAAQ,YAAY;AAChF,UAAI;AACF,cAAM,SAAS,MAAM,kBAAkB,IAAI,QAAQ;AACnD,YAAI,mBAAmB,MAAM,EAAG,QAAO;AAAA,MACzC,SAAS,KAAK;AACZ,eAAO,KAAK,qBAAqB,EAAE,IAAI,CAAC;AAAA,MAC1C;AAAA,IACF;AAEA,UAAM,YAAY,MAAM,yBAAyB;AAAA,MAC/C;AAAA,MACA;AAAA,MACA,MAAM;AAAA,MACN,YAAY;AAAA,MACZ,UAAU;AAAA,IACZ,CAAC;AAED,QAAI,qBAAqB,YAAY,UAAU,OAAO,kBAAkB,QAAQ,YAAY;AAC1F,UAAI;AACF,cAAM,kBAAkB,IAAI,UAAU,WAAW;AAAA,UAC/C,KAAK;AAAA,UACL,MAAM,uBAAuB,EAAE,QAAQ,kBAAkB,CAAC;AAAA,QAC5D,CAAC;AAAA,MACH,SAAS,KAAK;AACZ,eAAO,KAAK,sBAAsB,EAAE,IAAI,CAAC;AAAA,MAC3C;AAAA,IACF;AAEA,WAAO;AAAA,EACT;AAEA,MAAI,eAAe,UAAU;AAC3B,UAAM,UAAU,aAAa;AAC7B,gBAAY,IAAI,UAAU,OAAO;AACjC,WAAO;AAAA,EACT;AAEA,SAAO,aAAa;AACtB;AAQA,eAAsB,2BAA2B;AAAA,EAC/C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAUiC;AAC/B,QAAM,oBAAoB,MAAM,OAAO,eAAe,SAClD,0BAA0B,SAAS,KAAK,KAAK,QAAQ,IACrD;AACJ,MAAI,QAAQ,oBACR,kBAAkB,QAClB,MAAM,mCAAmC,EAAE,WAAW,MAAM,SAAS,YAAY,SAAS,CAAC;AAC/F,QAAM,iBAAiB,oBACnB,kBAAkB,iBAClB,kCAAkC,OAAO,IAAI;AACjD,MACE,MAAM,OACH,WACA,MAAM,eAAe,QACrB,MAAM,YACN,MAAM,QAAQ,gBAAgB,KAC9B,iBAAiB,SAAS,GAC7B;AACA,UAAM,KAAK,UAAU,QAAuB,IAAI;AAChD,UAAM,OAAO,UAAU,QAAqB,aAAa;AACzD,UAAM,gBAAgB,MAAM,KAAK;AAAA,MAC/B,KAAK;AAAA,MACL;AAAA,MACA,EAAE,UAAU,MAAM,SAAS;AAAA,IAC7B;AACA,QAAI,CAAC,cAAc,cAAc;AAC/B,YAAM,qBAAgD;AAAA,QACpD,QAAQ,MAAM;AAAA,QACd,WAAW;AAAA,QACX,GAAI,MAAM,QAAQ,MAAM,SAAS,IAAI,EAAE,IAAI,EAAE,KAAK,MAAM,UAAU,EAAE,IAAI,CAAC;AAAA,MAC3E;AACA,YAAM,gBAAgB,MAAM,GAAG,KAAK,cAAc,oBAAoB;AAAA,QACpE,QAAQ,CAAC,MAAM,aAAa;AAAA,MAC9B,CAAC;AACD,YAAM,2BAA2B,IAAI;AAAA,QACnC,cAAc,IAAI,CAAC,iBAAiB,OAAO,aAAa,EAAE,CAAC;AAAA,MAC7D;AACA,YAAM,yBAAyB,cAAc;AAAA,QAC3C,cAAc,IAAI,CAAC,kBAAkB;AAAA,UACnC,IAAI,OAAO,aAAa,EAAE;AAAA,UAC1B,aAAa,MAAM,QAAQ,aAAa,WAAW,IAAI,aAAa,cAAc,CAAC;AAAA,QACrF,EAAE;AAAA,MACJ;AACA,YAAM,0BAA0B,MAAM,KAAK,IAAI;AAAA,QAC7C,uBAAuB,OAAO,CAAC,0BAA0B,yBAAyB,IAAI,qBAAqB,CAAC;AAAA,MAC9G,CAAC;AACD,cAAQ;AAAA,QACN,YAAY;AAAA,QACZ,WAAW;AAAA,QACX,YAAY;AAAA,QACZ,UAAU,MAAM;AAAA,MAClB;AAAA,IACF;AACA,8BAA0B,SAAS,EAAE,QAAQ,KAAK,KAAK,OAAO,eAAe,CAAC;AAAA,EAChF;AAEA,SAAO,EAAE,gBAAgB,OAAO,wBAAwB,MAAM,cAAc,KAAK;AACnF;AAEA,SAAS,kCACP,OACA,MACe;AACf,QAAM,yBAAyB,MAAM,cAAc;AACnD,QAAM,YAAY,MAAM,SAAS;AACjC,SAAO,MAAM,eACP,cAAc,CAAC,MAAM,QAAQ,sBAAsB,KAAK,uBAAuB,SAAS,SAAS,KAAK,YAAY,UAClH,MAAM,QAAQ,sBAAsB,KAAK,uBAAuB,SAAS,uBAAuB,CAAC,IAAI;AAC7G;",
|
|
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 { ApiKey } from '@open-mercato/core/modules/api_keys/data/entities'\nimport { isSessionBoundApiKey } from '@open-mercato/core/modules/api_keys/services/sessionKeyRoles'\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 { OrganizationScope } from '@open-mercato/shared/lib/auth/principal-service'\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\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:v2'\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 const namespace = parts.userId.startsWith('api_key:') ? `${ORG_SCOPE_CACHE_KEY_PREFIX}:delegated-v1` : ORG_SCOPE_CACHE_KEY_PREFIX\n return `${namespace}:${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; ownerUserId: string | null }): string[] {\n return [\n buildOrgScopeUserCacheTag(parts.userId),\n buildOrgScopeTenantCacheTag(parts.effectiveTenantId),\n ...(parts.ownerUserId ? [buildOrgScopeUserCacheTag(parts.ownerUserId)] : []),\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\ntype BoundFeatureScope = {\n userId: string\n scope: OrganizationScope\n organizationId: string | null\n}\n\nconst featureScopeRequestMemo = new WeakMap<object, BoundFeatureScope>()\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 bindFeatureScopeToRequest(request: unknown, binding: BoundFeatureScope): void {\n if (!request || (typeof request !== 'object' && typeof request !== 'function')) return\n featureScopeRequestMemo.set(request as object, binding)\n}\n\nfunction getFeatureScopeForRequest(\n request: unknown,\n userId: string,\n tenantOverride: string | null | undefined,\n): BoundFeatureScope | null {\n if (!request || (typeof request !== 'object' && typeof request !== 'function')) return null\n const entry = featureScopeRequestMemo.get(request as object)\n if (!entry || entry.userId !== userId) return null\n if (tenantOverride === undefined) return entry\n const requestedTenantId = typeof tenantOverride === 'string' ? tenantOverride.trim() : ''\n return requestedTenantId.length > 0 && requestedTenantId === entry.scope.tenantId ? entry : null\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: Pick<RbacService, 'loadAcl'>\n auth: AuthContext | null | undefined\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 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 (allowedSet === null && (!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 if (selectedId === undefined) {\n const boundFeatureScope = getFeatureScopeForRequest(request, auth.sub, tenantOverride)\n if (boundFeatureScope) return boundFeatureScope.scope\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 ownerUserId = auth.isApiKey && typeof auth.userId === 'string' && auth.userId.length > 0 ? auth.userId : 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 let crossRequestCache = cache\n if (crossRequestCache && userId?.startsWith('api_key:')) {\n const key = await em.findOne(ApiKey, { id: userId.slice('api_key:'.length), deletedAt: null })\n if (!key || isSessionBoundApiKey(key)) crossRequestCache = null\n }\n if (crossRequestCache && cacheKey && typeof crossRequestCache.get === 'function') {\n try {\n const cached = await crossRequestCache.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 (crossRequestCache && cacheKey && userId && typeof crossRequestCache.set === 'function') {\n try {\n await crossRequestCache.set(cacheKey, baseScope, {\n ttl: ttlMs,\n tags: buildOrgScopeCacheTags({ userId, effectiveTenantId, ownerUserId }),\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 requiredFeatures,\n}: {\n container: AwilixContainer\n auth: AuthContext | null | undefined\n request?: Request | {\n cookies?: { get: (name: string) => { value: string } | undefined }\n headers?: { get(name: string): string | null }\n }\n selectedId?: string | null\n tenantId?: string | null\n requiredFeatures?: readonly string[]\n}): Promise<FeatureCheckContext> {\n const boundFeatureScope = auth?.sub && selectedId === undefined\n ? getFeatureScopeForRequest(request, auth.sub, tenantId)\n : null\n let scope = boundFeatureScope\n ? boundFeatureScope.scope\n : await resolveOrganizationScopeForRequest({ container, auth, request, selectedId, tenantId })\n const organizationId = boundFeatureScope\n ? boundFeatureScope.organizationId\n : resolveFeatureCheckOrganizationId(scope, auth)\n if (\n auth?.sub\n && request\n && scope.selectedId === null\n && scope.tenantId\n && Array.isArray(requiredFeatures)\n && requiredFeatures.length > 0\n ) {\n const em = container.resolve<EntityManager>('em')\n const rbac = container.resolve<RbacService>('rbacService')\n const featureAccess = await rbac.resolveFeatureOrganizationAccess(\n auth.sub,\n requiredFeatures,\n { tenantId: scope.tenantId },\n )\n if (!featureAccess.unrestricted) {\n const organizationFilter: FilterQuery<Organization> = {\n tenant: scope.tenantId,\n deletedAt: null,\n ...(Array.isArray(scope.filterIds) ? { id: { $in: scope.filterIds } } : {}),\n }\n const organizations = await em.find(Organization, organizationFilter, {\n fields: ['id', 'ancestorIds'],\n })\n const candidateOrganizationIds = new Set(\n organizations.map((organization) => String(organization.id)),\n )\n const featureOrganizationIds = featureAccess.filterOrganizationIds(\n organizations.map((organization) => ({\n id: String(organization.id),\n ancestorIds: Array.isArray(organization.ancestorIds) ? organization.ancestorIds : [],\n })),\n )\n const approvedOrganizationIds = Array.from(new Set(\n featureOrganizationIds.filter((featureOrganizationId) => candidateOrganizationIds.has(featureOrganizationId)),\n ))\n scope = {\n selectedId: null,\n filterIds: approvedOrganizationIds,\n allowedIds: approvedOrganizationIds,\n tenantId: scope.tenantId,\n }\n }\n bindFeatureScopeToRequest(request, { userId: auth.sub, scope, organizationId })\n }\n\n return { organizationId, scope, allowedOrganizationIds: scope.allowedIds ?? null }\n}\n\nfunction resolveFeatureCheckOrganizationId(\n scope: OrganizationScope,\n auth: AuthContext | null | undefined,\n): string | null {\n const allowedOrganizationIds = scope.allowedIds ?? null\n const authOrgId = auth?.orgId ?? null\n return scope.selectedId\n ?? (authOrgId && (!Array.isArray(allowedOrganizationIds) || allowedOrganizationIds.includes(authOrgId)) ? authOrgId : null)\n ?? (Array.isArray(allowedOrganizationIds) && allowedOrganizationIds.length ? allowedOrganizationIds[0] : null)\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.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7768.1.90fa052c11",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -266,16 +266,16 @@
|
|
|
266
266
|
"zod": "^4.4.3"
|
|
267
267
|
},
|
|
268
268
|
"peerDependencies": {
|
|
269
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
270
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
271
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
269
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7768.1.90fa052c11",
|
|
270
|
+
"@open-mercato/shared": "0.10.1-develop.7768.1.90fa052c11",
|
|
271
|
+
"@open-mercato/ui": "0.10.1-develop.7768.1.90fa052c11",
|
|
272
272
|
"react": "^19.0.0",
|
|
273
273
|
"react-dom": "^19.0.0"
|
|
274
274
|
},
|
|
275
275
|
"devDependencies": {
|
|
276
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
277
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
278
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
276
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7768.1.90fa052c11",
|
|
277
|
+
"@open-mercato/shared": "0.10.1-develop.7768.1.90fa052c11",
|
|
278
|
+
"@open-mercato/ui": "0.10.1-develop.7768.1.90fa052c11",
|
|
279
279
|
"@testing-library/dom": "^10.4.1",
|
|
280
280
|
"@testing-library/jest-dom": "^7.0.1",
|
|
281
281
|
"@testing-library/react": "^16.3.3",
|
|
@@ -66,7 +66,7 @@ export class ApiKey {
|
|
|
66
66
|
@Property({ name: 'session_token', type: 'text', nullable: true })
|
|
67
67
|
sessionToken?: string | null
|
|
68
68
|
|
|
69
|
-
/**
|
|
69
|
+
/** Owner whose effective ACL bounds a delegated MCP or session key. */
|
|
70
70
|
@Property({ name: 'session_user_id', type: 'uuid', nullable: true })
|
|
71
71
|
sessionUserId?: string | null
|
|
72
72
|
|
|
@@ -107,6 +107,7 @@ export type CreateApiKeyInput = {
|
|
|
107
107
|
roles?: string[]
|
|
108
108
|
expiresAt?: Date | null
|
|
109
109
|
createdBy?: string | null
|
|
110
|
+
sessionUserId?: string | null
|
|
110
111
|
}
|
|
111
112
|
|
|
112
113
|
export type ApiKeyWithSecret = {
|
|
@@ -154,6 +155,7 @@ export async function createApiKey(
|
|
|
154
155
|
keyPrefix: prefix,
|
|
155
156
|
rolesJson: roleIds,
|
|
156
157
|
createdBy: input.createdBy ?? null,
|
|
158
|
+
sessionUserId: input.sessionUserId ?? null,
|
|
157
159
|
expiresAt: input.expiresAt ?? null,
|
|
158
160
|
createdAt: new Date(),
|
|
159
161
|
})
|
|
@@ -195,7 +195,6 @@ export async function lockAuthorizationState(
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
const apiKeyIds = uniqueSortedIds(targets.apiKeyIds ?? [])
|
|
198
|
-
const userIds = uniqueSortedIds(targets.userIds ?? [])
|
|
199
198
|
const explicitRoleIds = uniqueSortedIds(targets.roleIds ?? [])
|
|
200
199
|
const tenantIds = uniqueSortedIds(targets.tenantIds ?? [])
|
|
201
200
|
const explicitApiKeys = apiKeyIds.length
|
|
@@ -207,9 +206,16 @@ export async function lockAuthorizationState(
|
|
|
207
206
|
{ tenantId: null, organizationId: null },
|
|
208
207
|
)
|
|
209
208
|
: []
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
209
|
+
const explicitApiKeySnapshots = new Map(explicitApiKeys.map((apiKey) => [String(apiKey.id), {
|
|
210
|
+
sessionUserId: apiKey.sessionUserId ?? null,
|
|
211
|
+
roles: Array.isArray(apiKey.rolesJson) ? [...apiKey.rolesJson] : [],
|
|
212
|
+
}]))
|
|
213
|
+
const apiKeySnapshots = [...explicitApiKeySnapshots.values()]
|
|
214
|
+
const userIds = uniqueSortedIds([
|
|
215
|
+
...(targets.userIds ?? []),
|
|
216
|
+
...apiKeySnapshots.flatMap((apiKey) => apiKey.sessionUserId ? [apiKey.sessionUserId] : []),
|
|
217
|
+
])
|
|
218
|
+
const explicitApiKeyRoleIds = uniqueSortedIds(apiKeySnapshots.flatMap((apiKey) => apiKey.roles))
|
|
213
219
|
const discoveredBeforeLocks = userIds.length
|
|
214
220
|
? await findWithDecryption(
|
|
215
221
|
em,
|
|
@@ -236,10 +242,10 @@ export async function lockAuthorizationState(
|
|
|
236
242
|
for (const apiKey of lockedApiKeys) {
|
|
237
243
|
const id = String(apiKey.id)
|
|
238
244
|
if (!apiKeyIds.includes(id)) continue
|
|
239
|
-
const before =
|
|
240
|
-
const beforeRoles =
|
|
245
|
+
const before = explicitApiKeySnapshots.get(id)
|
|
246
|
+
const beforeRoles = before?.roles ?? []
|
|
241
247
|
const lockedRoles = Array.isArray(apiKey.rolesJson) ? apiKey.rolesJson : []
|
|
242
|
-
if (!sameIds(beforeRoles, lockedRoles)) {
|
|
248
|
+
if (!sameIds(beforeRoles, lockedRoles) || (before?.sessionUserId ?? null) !== (apiKey.sessionUserId ?? null)) {
|
|
243
249
|
throw authorizationStateDrift()
|
|
244
250
|
}
|
|
245
251
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
resolveEffectiveFeatures,
|
|
14
14
|
} from '@open-mercato/shared/security/featurePolicy'
|
|
15
15
|
import { filterGrantsByEnabledModules } from '@open-mercato/shared/security/enabledModulesRegistry'
|
|
16
|
+
import { hasFeature } from '@open-mercato/shared/security/features'
|
|
16
17
|
import { resolveRoleOrganizationScope, roleAclAllowsOrganization } from './roleOrganizationScope'
|
|
17
18
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
18
19
|
|
|
@@ -24,6 +25,27 @@ interface AclData {
|
|
|
24
25
|
organizations: string[] | null
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
function intersectDelegatedAcl(keyAcl: AclData, ownerAcl: AclData): AclData {
|
|
29
|
+
const keyFeatures = keyAcl.isSuperAdmin ? ['*'] : keyAcl.features
|
|
30
|
+
const ownerFeatures = ownerAcl.isSuperAdmin ? ['*'] : ownerAcl.features
|
|
31
|
+
const features = Array.from(new Set([
|
|
32
|
+
...keyFeatures.filter((feature) => hasFeature(ownerFeatures, feature)),
|
|
33
|
+
...ownerFeatures.filter((feature) => hasFeature(keyFeatures, feature)),
|
|
34
|
+
]))
|
|
35
|
+
const keyOrganizations = keyAcl.isSuperAdmin || keyAcl.organizations?.includes('__all__')
|
|
36
|
+
? null : keyAcl.organizations
|
|
37
|
+
const ownerOrganizations = ownerAcl.isSuperAdmin || ownerAcl.organizations?.includes('__all__')
|
|
38
|
+
? null : ownerAcl.organizations
|
|
39
|
+
const organizations = keyOrganizations === null ? ownerOrganizations
|
|
40
|
+
: ownerOrganizations === null ? keyOrganizations
|
|
41
|
+
: keyOrganizations.filter((organizationId) => ownerOrganizations.includes(organizationId))
|
|
42
|
+
return {
|
|
43
|
+
isSuperAdmin: keyAcl.isSuperAdmin && ownerAcl.isSuperAdmin,
|
|
44
|
+
features: organizations?.length === 0 ? [] : features,
|
|
45
|
+
organizations,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
27
49
|
async function resolveRoleOrganizationScopeWithEntityManager(
|
|
28
50
|
em: EntityManager,
|
|
29
51
|
tenantId: string | null,
|
|
@@ -81,7 +103,7 @@ type FeatureOrganizationRoleGrant = {
|
|
|
81
103
|
|
|
82
104
|
type FeatureOrganizationGrants =
|
|
83
105
|
| { kind: 'none' }
|
|
84
|
-
| { kind: 'api_key'; organizationId: string | null; roleGrants: FeatureOrganizationRoleGrant[] }
|
|
106
|
+
| { kind: 'api_key'; organizationId: string | null; ownerId: string | null; roleGrants: FeatureOrganizationRoleGrant[] }
|
|
85
107
|
| { kind: 'user_acl'; isSuperAdmin: boolean; features: string[]; organizations: string[] | null }
|
|
86
108
|
| { kind: 'roles'; roleGrants: FeatureOrganizationRoleGrant[] }
|
|
87
109
|
|
|
@@ -105,6 +127,7 @@ function isFeatureOrganizationGrants(value: unknown): value is FeatureOrganizati
|
|
|
105
127
|
return true
|
|
106
128
|
case 'api_key':
|
|
107
129
|
return (record.organizationId === null || typeof record.organizationId === 'string')
|
|
130
|
+
&& (record.ownerId === null || typeof record.ownerId === 'string')
|
|
108
131
|
&& Array.isArray(record.roleGrants)
|
|
109
132
|
&& record.roleGrants.every(isFeatureOrganizationRoleGrant)
|
|
110
133
|
case 'user_acl':
|
|
@@ -260,7 +283,8 @@ export class RbacService {
|
|
|
260
283
|
}
|
|
261
284
|
|
|
262
285
|
private getCacheKey(userId: string, scope: { tenantId: string | null; organizationId: string | null }): string {
|
|
263
|
-
|
|
286
|
+
const namespace = userId.startsWith('api_key:') ? 'rbac:delegated-v1' : 'rbac'
|
|
287
|
+
return `${namespace}:${userId}:${scope.tenantId || 'null'}:${scope.organizationId || 'null'}`
|
|
264
288
|
}
|
|
265
289
|
|
|
266
290
|
private getUserTag(userId: string): string {
|
|
@@ -282,12 +306,12 @@ export class RbacService {
|
|
|
282
306
|
return isAclData(cached) ? cached : null
|
|
283
307
|
}
|
|
284
308
|
|
|
285
|
-
private async setCache(cacheKey: string, data: AclData, userId: string, scope: { tenantId: string | null; organizationId: string | null }): Promise<void> {
|
|
309
|
+
private async setCache(cacheKey: string, data: AclData, userId: string, scope: { tenantId: string | null; organizationId: string | null }, ownerId?: string | null): Promise<void> {
|
|
286
310
|
if (!this.cache) return
|
|
287
311
|
|
|
288
312
|
await this.cache.set(cacheKey, data, {
|
|
289
313
|
ttl: this.cacheTtlMs,
|
|
290
|
-
tags: this.buildCacheTags(userId, scope),
|
|
314
|
+
tags: [...this.buildCacheTags(userId, scope), ...(ownerId ? [this.getUserTag(ownerId)] : [])],
|
|
291
315
|
})
|
|
292
316
|
}
|
|
293
317
|
|
|
@@ -584,8 +608,9 @@ export class RbacService {
|
|
|
584
608
|
// the key is restricted so downstream super-admin shortcuts cannot
|
|
585
609
|
// bypass the key or role organization bounds.
|
|
586
610
|
if (isSuper && organizations !== null && !features.includes('*')) features.push('*')
|
|
587
|
-
const
|
|
588
|
-
|
|
611
|
+
const keyAcl = { isSuperAdmin: isSuper && organizations === null, features, organizations }
|
|
612
|
+
const result = await this.limitDelegatedKeyAcl(em, key, { tenantId, organizationId: evaluatedOrganizationId }, keyAcl)
|
|
613
|
+
if (cacheable) await this.setCache(cacheKey, result, userId, { tenantId, organizationId: evaluatedOrganizationId }, key.sessionUserId)
|
|
589
614
|
return result
|
|
590
615
|
}
|
|
591
616
|
|
|
@@ -795,7 +820,13 @@ export class RbacService {
|
|
|
795
820
|
: []
|
|
796
821
|
}
|
|
797
822
|
if (isSuperAdmin && organizations !== null && !features.includes('*')) features.push('*')
|
|
798
|
-
return
|
|
823
|
+
return this.limitDelegatedKeyAcl(
|
|
824
|
+
em,
|
|
825
|
+
key,
|
|
826
|
+
{ tenantId, organizationId: evaluatedOrganizationId },
|
|
827
|
+
{ isSuperAdmin: isSuperAdmin && organizations === null, features, organizations },
|
|
828
|
+
true,
|
|
829
|
+
)
|
|
799
830
|
}
|
|
800
831
|
|
|
801
832
|
const user = await findOneWithDecryption(
|
|
@@ -997,6 +1028,33 @@ export class RbacService {
|
|
|
997
1028
|
})
|
|
998
1029
|
}
|
|
999
1030
|
|
|
1031
|
+
private async limitDelegatedKeyAcl(
|
|
1032
|
+
em: EntityManager,
|
|
1033
|
+
key: ApiKey,
|
|
1034
|
+
scope: { tenantId: string | null; organizationId: string | null },
|
|
1035
|
+
keyAcl: AclData,
|
|
1036
|
+
uncached = false,
|
|
1037
|
+
): Promise<AclData> {
|
|
1038
|
+
if (!key.sessionUserId) return keyAcl
|
|
1039
|
+
if (!key.tenantId || key.tenantId !== scope.tenantId) {
|
|
1040
|
+
return { isSuperAdmin: false, features: [], organizations: [] }
|
|
1041
|
+
}
|
|
1042
|
+
const owner = await findOneWithDecryption(em, User, {
|
|
1043
|
+
id: key.sessionUserId,
|
|
1044
|
+
tenantId: key.tenantId,
|
|
1045
|
+
deletedAt: null,
|
|
1046
|
+
}, undefined, scope)
|
|
1047
|
+
if (!owner) return { isSuperAdmin: false, features: [], organizations: [] }
|
|
1048
|
+
const ownerAcl = uncached
|
|
1049
|
+
? await this.loadAclWithEntityManager(em, key.sessionUserId, scope)
|
|
1050
|
+
: await this.loadAcl(key.sessionUserId, scope)
|
|
1051
|
+
const result = intersectDelegatedAcl(keyAcl, ownerAcl)
|
|
1052
|
+
if (scope.organizationId && result.organizations && !result.organizations.includes(scope.organizationId)) {
|
|
1053
|
+
return { ...result, isSuperAdmin: false, features: [] }
|
|
1054
|
+
}
|
|
1055
|
+
return result
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1000
1058
|
async resolveFeatureOrganizationAccess(
|
|
1001
1059
|
userId: string,
|
|
1002
1060
|
required: readonly string[],
|
|
@@ -1029,13 +1087,18 @@ export class RbacService {
|
|
|
1029
1087
|
|
|
1030
1088
|
if (grants.kind === 'api_key') {
|
|
1031
1089
|
const keyOrganizationId = grants.organizationId
|
|
1090
|
+
const ownerAccess = grants.ownerId
|
|
1091
|
+
? await this.resolveFeatureOrganizationAccess(grants.ownerId, required, input)
|
|
1092
|
+
: { unrestricted: true, filterOrganizationIds: allowAll }
|
|
1032
1093
|
const unrestricted = keyOrganizationId === null
|
|
1094
|
+
&& ownerAccess.unrestricted
|
|
1033
1095
|
&& roleAclsAuthorizeFeaturesGlobally(grants.roleGrants, required, true)
|
|
1034
1096
|
if (unrestricted) return { unrestricted: true, filterOrganizationIds: allowAll }
|
|
1035
1097
|
|
|
1036
1098
|
return {
|
|
1037
1099
|
unrestricted: false,
|
|
1038
1100
|
filterOrganizationIds: (candidates) => normalizeFeatureOrganizationCandidates(candidates)
|
|
1101
|
+
.filter((organization) => ownerAccess.filterOrganizationIds([organization]).length > 0)
|
|
1039
1102
|
.filter((organization) => (
|
|
1040
1103
|
(!keyOrganizationId || organization.id === keyOrganizationId)
|
|
1041
1104
|
&& roleAclsAuthorizeFeatures(grants.roleGrants, required, organization, true)
|
|
@@ -1091,7 +1154,7 @@ export class RbacService {
|
|
|
1091
1154
|
userId: string,
|
|
1092
1155
|
tenantId: string,
|
|
1093
1156
|
): Promise<FeatureOrganizationGrants> {
|
|
1094
|
-
const cacheKey = `rbac:feature-organizations:${userId}:${tenantId}`
|
|
1157
|
+
const cacheKey = `rbac:feature-organizations:delegated-v1:${userId}:${tenantId}`
|
|
1095
1158
|
const key = userId.startsWith('api_key:')
|
|
1096
1159
|
? await this.em.fork().findOne(ApiKey, { id: userId.slice('api_key:'.length), deletedAt: null })
|
|
1097
1160
|
: undefined
|
|
@@ -1104,7 +1167,10 @@ export class RbacService {
|
|
|
1104
1167
|
if (this.cache && cacheable) {
|
|
1105
1168
|
await this.cache.set(cacheKey, grants, {
|
|
1106
1169
|
ttl: this.cacheTtlMs,
|
|
1107
|
-
tags:
|
|
1170
|
+
tags: [
|
|
1171
|
+
...this.buildCacheTags(userId, { tenantId, organizationId: null }),
|
|
1172
|
+
...(grants.kind === 'api_key' && grants.ownerId ? [this.getUserTag(grants.ownerId)] : []),
|
|
1173
|
+
],
|
|
1108
1174
|
})
|
|
1109
1175
|
}
|
|
1110
1176
|
return grants
|
|
@@ -1129,6 +1195,16 @@ export class RbacService {
|
|
|
1129
1195
|
return { kind: 'none' }
|
|
1130
1196
|
}
|
|
1131
1197
|
|
|
1198
|
+
if (key.sessionUserId) {
|
|
1199
|
+
if (key.tenantId !== tenantId) return { kind: 'none' }
|
|
1200
|
+
const owner = await findOneWithDecryption(em, User, {
|
|
1201
|
+
id: key.sessionUserId,
|
|
1202
|
+
tenantId,
|
|
1203
|
+
deletedAt: null,
|
|
1204
|
+
}, undefined, { tenantId, organizationId: null })
|
|
1205
|
+
if (!owner) return { kind: 'none' }
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1132
1208
|
const roleIds = await resolveApiKeyRoleIds(em, key, tenantId)
|
|
1133
1209
|
if (!roleIds.length) return { kind: 'none' }
|
|
1134
1210
|
const roleAcls = await em.find(RoleAcl, {
|
|
@@ -1142,6 +1218,7 @@ export class RbacService {
|
|
|
1142
1218
|
return {
|
|
1143
1219
|
kind: 'api_key',
|
|
1144
1220
|
organizationId: keyOrganizationId,
|
|
1221
|
+
ownerId: key.sessionUserId ?? null,
|
|
1145
1222
|
roleGrants: roleAcls.map(toFeatureOrganizationRoleGrant),
|
|
1146
1223
|
}
|
|
1147
1224
|
}
|
|
@@ -49,7 +49,8 @@ function buildOrgScopeCacheKey(parts: {
|
|
|
49
49
|
}): string {
|
|
50
50
|
const selected = parts.selectedOrgId ?? 'none'
|
|
51
51
|
const requested = parts.requestedTenantId ?? 'none'
|
|
52
|
-
|
|
52
|
+
const namespace = parts.userId.startsWith('api_key:') ? `${ORG_SCOPE_CACHE_KEY_PREFIX}:delegated-v1` : ORG_SCOPE_CACHE_KEY_PREFIX
|
|
53
|
+
return `${namespace}:${parts.userId}:${parts.effectiveTenantId}:${selected}:${requested}`
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
// Tag builders are exported so the modules that own the "this user's scope
|
|
@@ -65,10 +66,11 @@ export function buildOrgScopeTenantCacheTag(tenantId: string): string {
|
|
|
65
66
|
return `${ORG_SCOPE_CACHE_KEY_PREFIX}:tenant:${tenantId}`
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
function buildOrgScopeCacheTags(parts: { userId: string; effectiveTenantId: string }): string[] {
|
|
69
|
+
function buildOrgScopeCacheTags(parts: { userId: string; effectiveTenantId: string; ownerUserId: string | null }): string[] {
|
|
69
70
|
return [
|
|
70
71
|
buildOrgScopeUserCacheTag(parts.userId),
|
|
71
72
|
buildOrgScopeTenantCacheTag(parts.effectiveTenantId),
|
|
73
|
+
...(parts.ownerUserId ? [buildOrgScopeUserCacheTag(parts.ownerUserId)] : []),
|
|
72
74
|
]
|
|
73
75
|
}
|
|
74
76
|
|
|
@@ -488,6 +490,7 @@ export async function resolveOrganizationScopeForRequest({
|
|
|
488
490
|
: null
|
|
489
491
|
|
|
490
492
|
const userId = typeof auth.sub === 'string' && auth.sub.length > 0 ? auth.sub : null
|
|
493
|
+
const ownerUserId = auth.isApiKey && typeof auth.userId === 'string' && auth.userId.length > 0 ? auth.userId : null
|
|
491
494
|
const ttlMs = resolveOrgScopeTtlMs()
|
|
492
495
|
const cache = ttlMs > 0 ? resolveCacheFromContainer(container) : null
|
|
493
496
|
const cacheKey = userId
|
|
@@ -532,7 +535,7 @@ export async function resolveOrganizationScopeForRequest({
|
|
|
532
535
|
try {
|
|
533
536
|
await crossRequestCache.set(cacheKey, baseScope, {
|
|
534
537
|
ttl: ttlMs,
|
|
535
|
-
tags: buildOrgScopeCacheTags({ userId, effectiveTenantId }),
|
|
538
|
+
tags: buildOrgScopeCacheTags({ userId, effectiveTenantId, ownerUserId }),
|
|
536
539
|
})
|
|
537
540
|
} catch (err) {
|
|
538
541
|
logger.warn('Cache write failed', { err })
|