@open-mercato/shared 0.6.4-develop.4239.1.4a264a5828 → 0.6.4-develop.4254.1.7a123d970c
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/lib/auth/organizationAccess.js +10 -0
- package/dist/lib/auth/organizationAccess.js.map +7 -0
- package/dist/lib/commands/scope.js +19 -5
- package/dist/lib/commands/scope.js.map +3 -3
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/search.js.map +2 -2
- package/package.json +2 -2
- package/src/lib/auth/__tests__/organizationAccess.test.ts +63 -0
- package/src/lib/auth/organizationAccess.ts +22 -0
- package/src/lib/commands/__tests__/scope.test.ts +86 -0
- package/src/lib/commands/scope.ts +27 -6
- package/src/modules/search.ts +11 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:shared] found
|
|
1
|
+
[build:shared] found 216 entry points
|
|
2
2
|
[build:shared] built successfully
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
function isOrganizationAccessAllowed(input) {
|
|
2
|
+
if (input.isSuperAdmin) return true;
|
|
3
|
+
if (input.allowedOrganizationIds === null) return true;
|
|
4
|
+
if (!input.targetOrganizationId) return false;
|
|
5
|
+
return input.allowedOrganizationIds.includes(input.targetOrganizationId);
|
|
6
|
+
}
|
|
7
|
+
export {
|
|
8
|
+
isOrganizationAccessAllowed
|
|
9
|
+
};
|
|
10
|
+
//# sourceMappingURL=organizationAccess.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/lib/auth/organizationAccess.ts"],
|
|
4
|
+
"sourcesContent": ["export type OrganizationAccessDecisionInput = {\n isSuperAdmin: boolean\n allowedOrganizationIds: readonly string[] | null\n targetOrganizationId: string | null\n}\n\n/**\n * Fail-closed organization-access predicate. The single source of truth for\n * \"may this principal act on `targetOrganizationId`\".\n *\n * Decision table:\n * - `isSuperAdmin === true` -> allow (global access)\n * - `allowedOrganizationIds === null` -> allow (truly unrestricted)\n * - restricted + no target org -> deny (empty/unknown scope is not a bypass)\n * - restricted + target org -> allow iff the target is a member of the allowed set\n */\nexport function isOrganizationAccessAllowed(input: OrganizationAccessDecisionInput): boolean {\n if (input.isSuperAdmin) return true\n if (input.allowedOrganizationIds === null) return true\n if (!input.targetOrganizationId) return false\n return input.allowedOrganizationIds.includes(input.targetOrganizationId)\n}\n"],
|
|
5
|
+
"mappings": "AAgBO,SAAS,4BAA4B,OAAiD;AAC3F,MAAI,MAAM,aAAc,QAAO;AAC/B,MAAI,MAAM,2BAA2B,KAAM,QAAO;AAClD,MAAI,CAAC,MAAM,qBAAsB,QAAO;AACxC,SAAO,MAAM,uBAAuB,SAAS,MAAM,oBAAoB;AACzE;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
|
|
2
|
+
import { isOrganizationAccessAllowed } from "@open-mercato/shared/lib/auth/organizationAccess";
|
|
2
3
|
import { env } from "process";
|
|
3
4
|
function logScopeViolation(ctx, expected, actual) {
|
|
4
5
|
try {
|
|
@@ -57,14 +58,27 @@ function logTenantScopeViolation(ctx, expectedTenantId, actualTenantId) {
|
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
function ensureOrganizationScope(ctx, organizationId) {
|
|
60
|
-
|
|
61
|
+
const isSuperAdmin = ctx.auth?.isSuperAdmin === true;
|
|
62
|
+
const scope = ctx.organizationScope;
|
|
63
|
+
if (!scope) {
|
|
64
|
+
if (isSuperAdmin) return;
|
|
65
|
+
const currentOrg2 = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null;
|
|
66
|
+
if (currentOrg2 && currentOrg2 !== organizationId) {
|
|
67
|
+
logScopeViolation(ctx, organizationId, currentOrg2);
|
|
68
|
+
throw new CrudHttpError(403, { error: "Forbidden" });
|
|
69
|
+
}
|
|
61
70
|
return;
|
|
62
71
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
if (isOrganizationAccessAllowed({
|
|
73
|
+
isSuperAdmin,
|
|
74
|
+
allowedOrganizationIds: scope.allowedIds,
|
|
75
|
+
targetOrganizationId: organizationId
|
|
76
|
+
})) {
|
|
77
|
+
return;
|
|
67
78
|
}
|
|
79
|
+
const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null;
|
|
80
|
+
logScopeViolation(ctx, organizationId, currentOrg);
|
|
81
|
+
throw new CrudHttpError(403, { error: "Forbidden" });
|
|
68
82
|
}
|
|
69
83
|
function ensureTenantScope(ctx, tenantId) {
|
|
70
84
|
const currentTenant = ctx.auth?.tenantId ?? null;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/commands/scope.ts"],
|
|
4
|
-
"sourcesContent": ["import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { env } from 'process'\n\nfunction logScopeViolation(\n ctx: CommandRuntimeContext,\n expected: string,\n actual: string | null\n): void {\n try {\n const requestInfo =\n ctx.request && typeof ctx.request === 'object'\n ? {\n method: (ctx.request as Request).method ?? undefined,\n url: (ctx.request as Request).url ?? undefined,\n }\n : null\n const scope = ctx.organizationScope\n ? {\n selectedId: ctx.organizationScope.selectedId ?? null,\n tenantId: ctx.organizationScope.tenantId ?? null,\n allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)\n ? ctx.organizationScope.allowedIds.length\n : null,\n filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)\n ? ctx.organizationScope.filterIds.length\n : null,\n }\n : null\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden organization scope mismatch detected', {\n expectedId: expected,\n actualId: actual,\n userId: ctx.auth?.sub ?? null,\n actorTenantId: ctx.auth?.tenantId ?? null,\n actorOrganizationId: ctx.auth?.orgId ?? null,\n selectedOrganizationId: ctx.selectedOrganizationId ?? null,\n organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,\n scope,\n request: requestInfo,\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nfunction logTenantScopeViolation(\n ctx: CommandRuntimeContext,\n expectedTenantId: string,\n actualTenantId: string | null\n): void {\n try {\n const requestInfo =\n ctx.request && typeof ctx.request === 'object'\n ? {\n method: (ctx.request as Request).method ?? undefined,\n url: (ctx.request as Request).url ?? undefined,\n }\n : null\n const scope = ctx.organizationScope\n ? {\n selectedId: ctx.organizationScope.selectedId ?? null,\n tenantId: ctx.organizationScope.tenantId ?? null,\n allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)\n ? ctx.organizationScope.allowedIds.length\n : null,\n filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)\n ? ctx.organizationScope.filterIds.length\n : null,\n }\n : null\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden tenant scope mismatch detected', {\n expectedTenantId,\n actualTenantId,\n userId: ctx.auth?.sub ?? null,\n actorTenantId: ctx.auth?.tenantId ?? null,\n actorOrganizationId: ctx.auth?.orgId ?? null,\n selectedOrganizationId: ctx.selectedOrganizationId ?? null,\n organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,\n scope,\n request: requestInfo,\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nexport function ensureOrganizationScope(ctx: CommandRuntimeContext, organizationId: string): void {\n
|
|
5
|
-
"mappings": "AAAA,SAAS,qBAAqB;AAE9B,SAAS,WAAW;AAEpB,SAAS,kBACP,KACA,UACA,QACM;AACN,MAAI;AACF,UAAM,cACJ,IAAI,WAAW,OAAO,IAAI,YAAY,WAClC;AAAA,MACE,QAAS,IAAI,QAAoB,UAAU;AAAA,MAC3C,KAAM,IAAI,QAAoB,OAAO;AAAA,IACvC,IACA;AACN,UAAM,QAAQ,IAAI,oBACd;AAAA,MACE,YAAY,IAAI,kBAAkB,cAAc;AAAA,MAChD,UAAU,IAAI,kBAAkB,YAAY;AAAA,MAC5C,iBAAiB,MAAM,QAAQ,IAAI,kBAAkB,UAAU,IAC3D,IAAI,kBAAkB,WAAW,SACjC;AAAA,MACJ,gBAAgB,MAAM,QAAQ,IAAI,kBAAkB,SAAS,IACzD,IAAI,kBAAkB,UAAU,SAChC;AAAA,IACN,IACA;AACJ,QAAI,IAAI,aAAa,QAAQ;AAC3B,cAAQ,KAAK,0DAA0D;AAAA,QACrE,YAAY;AAAA,QACZ,UAAU;AAAA,QACV,QAAQ,IAAI,MAAM,OAAO;AAAA,QACzB,eAAe,IAAI,MAAM,YAAY;AAAA,QACrC,qBAAqB,IAAI,MAAM,SAAS;AAAA,QACxC,wBAAwB,IAAI,0BAA0B;AAAA,QACtD,sBAAsB,MAAM,QAAQ,IAAI,eAAe,IAAI,IAAI,gBAAgB,SAAS;AAAA,QACxF;AAAA,QACA,SAAS;AAAA,MACX,CAAC;AAAA,IACH;AAAA,EACF,QAAQ;AAAA,EAER;AACF;AAEA,SAAS,wBACP,KACA,kBACA,gBACM;AACN,MAAI;AACF,UAAM,cACJ,IAAI,WAAW,OAAO,IAAI,YAAY,WAClC;AAAA,MACE,QAAS,IAAI,QAAoB,UAAU;AAAA,MAC3C,KAAM,IAAI,QAAoB,OAAO;AAAA,IACvC,IACA;AACN,UAAM,QAAQ,IAAI,oBACd;AAAA,MACE,YAAY,IAAI,kBAAkB,cAAc;AAAA,MAChD,UAAU,IAAI,kBAAkB,YAAY;AAAA,MAC5C,iBAAiB,MAAM,QAAQ,IAAI,kBAAkB,UAAU,IAC3D,IAAI,kBAAkB,WAAW,SACjC;AAAA,MACJ,gBAAgB,MAAM,QAAQ,IAAI,kBAAkB,SAAS,IACzD,IAAI,kBAAkB,UAAU,SAChC;AAAA,IACN,IACA;AACJ,QAAI,IAAI,aAAa,QAAQ;AAC3B,cAAQ,KAAK,oDAAoD;AAAA,QAC/D;AAAA,QACA;AAAA,QACA,QAAQ,IAAI,MAAM,OAAO;AAAA,QACzB,eAAe,IAAI,MAAM,YAAY;AAAA,QACrC,qBAAqB,IAAI,MAAM,SAAS;AAAA,QACxC,wBAAwB,IAAI,0BAA0B;AAAA,QACtD,sBAAsB,MAAM,QAAQ,IAAI,eAAe,IAAI,IAAI,gBAAgB,SAAS;AAAA,QACxF;AAAA,QACA,SAAS;AAAA,MACX,CAAC;AAAA,IACH;AAAA,EACF,QAAQ;AAAA,EAER;AACF;AAEO,SAAS,wBAAwB,KAA4B,gBAA8B;
|
|
6
|
-
"names": []
|
|
4
|
+
"sourcesContent": ["import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { isOrganizationAccessAllowed } from '@open-mercato/shared/lib/auth/organizationAccess'\nimport { env } from 'process'\n\nfunction logScopeViolation(\n ctx: CommandRuntimeContext,\n expected: string,\n actual: string | null\n): void {\n try {\n const requestInfo =\n ctx.request && typeof ctx.request === 'object'\n ? {\n method: (ctx.request as Request).method ?? undefined,\n url: (ctx.request as Request).url ?? undefined,\n }\n : null\n const scope = ctx.organizationScope\n ? {\n selectedId: ctx.organizationScope.selectedId ?? null,\n tenantId: ctx.organizationScope.tenantId ?? null,\n allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)\n ? ctx.organizationScope.allowedIds.length\n : null,\n filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)\n ? ctx.organizationScope.filterIds.length\n : null,\n }\n : null\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden organization scope mismatch detected', {\n expectedId: expected,\n actualId: actual,\n userId: ctx.auth?.sub ?? null,\n actorTenantId: ctx.auth?.tenantId ?? null,\n actorOrganizationId: ctx.auth?.orgId ?? null,\n selectedOrganizationId: ctx.selectedOrganizationId ?? null,\n organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,\n scope,\n request: requestInfo,\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nfunction logTenantScopeViolation(\n ctx: CommandRuntimeContext,\n expectedTenantId: string,\n actualTenantId: string | null\n): void {\n try {\n const requestInfo =\n ctx.request && typeof ctx.request === 'object'\n ? {\n method: (ctx.request as Request).method ?? undefined,\n url: (ctx.request as Request).url ?? undefined,\n }\n : null\n const scope = ctx.organizationScope\n ? {\n selectedId: ctx.organizationScope.selectedId ?? null,\n tenantId: ctx.organizationScope.tenantId ?? null,\n allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)\n ? ctx.organizationScope.allowedIds.length\n : null,\n filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)\n ? ctx.organizationScope.filterIds.length\n : null,\n }\n : null\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden tenant scope mismatch detected', {\n expectedTenantId,\n actualTenantId,\n userId: ctx.auth?.sub ?? null,\n actorTenantId: ctx.auth?.tenantId ?? null,\n actorOrganizationId: ctx.auth?.orgId ?? null,\n selectedOrganizationId: ctx.selectedOrganizationId ?? null,\n organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,\n scope,\n request: requestInfo,\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nexport function ensureOrganizationScope(ctx: CommandRuntimeContext, organizationId: string): void {\n const isSuperAdmin = ctx.auth?.isSuperAdmin === true\n const scope = ctx.organizationScope\n\n // Pattern C: when no organization scope was resolved (system/worker/non-user\n // command contexts that build ctx with `organizationScope: null`), preserve\n // the legacy currentOrg fallback. This branch is load-bearing \u2014 switching it\n // to deny would break payment, scheduled-command, and other scope-less flows.\n if (!scope) {\n if (isSuperAdmin) return\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n if (currentOrg && currentOrg !== organizationId) {\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n return\n }\n\n if (\n isOrganizationAccessAllowed({\n isSuperAdmin,\n allowedOrganizationIds: scope.allowedIds,\n targetOrganizationId: organizationId,\n })\n ) {\n return\n }\n\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n}\n\nexport function ensureTenantScope(ctx: CommandRuntimeContext, tenantId: string): void {\n const currentTenant = ctx.auth?.tenantId ?? null\n if (currentTenant && currentTenant !== tenantId) {\n logTenantScopeViolation(ctx, tenantId, currentTenant)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n}\n\nexport function ensureSameScope(\n entity: Pick<{ organizationId: string; tenantId: string }, 'organizationId' | 'tenantId'>,\n organizationId: string,\n tenantId: string\n): void {\n if (entity.organizationId !== organizationId || entity.tenantId !== tenantId) {\n throw new CrudHttpError(403, { error: 'Cross-tenant relation forbidden' })\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["currentOrg"]
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.4-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.4-develop.4254.1.7a123d970c'\nexport const appVersion = APP_VERSION\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/modules/search.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityId } from './entities'\n\n// =============================================================================\n// Strategy Identifiers\n// =============================================================================\n\n/**\n * Built-in strategy identifiers plus extensible string for third-party strategies.\n */\nexport type SearchStrategyId = 'tokens' | 'vector' | 'fulltext' | (string & Record<string, never>)\n\n// =============================================================================\n// Result Types\n// =============================================================================\n\n/**\n * Presenter metadata for displaying search results in UI (Cmd+K, global search).\n */\nexport type SearchResultPresenter = {\n title: string\n subtitle?: string\n icon?: string\n badge?: string\n}\n\n/**\n * Deep link rendered next to a search result.\n */\nexport type SearchResultLink = {\n href: string\n label: string\n kind?: 'primary' | 'secondary'\n}\n\n/**\n * A single search result returned by a strategy.\n */\nexport type SearchResult = {\n /** Entity type identifier, e.g., 'customers:customer_person_profile' */\n entityId: EntityId\n /** Record primary key */\n recordId: string\n /** Relevance score (normalized 0-1 range preferred, but RRF scores may exceed 1) */\n score: number\n /** Which strategy produced this result */\n source: SearchStrategyId\n /** Optional presenter for quick display */\n presenter?: SearchResultPresenter\n /** Primary URL when result is clicked */\n url?: string\n /** Additional action links */\n links?: SearchResultLink[]\n /** Extra metadata from the strategy */\n metadata?: Record<string, unknown>\n}\n\n// =============================================================================\n// Search Options\n// =============================================================================\n\n/**\n * Options passed to SearchService.search()\n */\nexport type SearchOptions = {\n /** Tenant isolation - required */\n tenantId: string\n /**\n * Optional organization filter.\n * - `string` restricts results to that organization only.\n * - `undefined` or `null` means no organization filter (tenant-wide).\n */\n organizationId?: string | null\n /** Filter to specific entity types */\n entityTypes?: EntityId[]\n /** Use only specific strategies (defaults to all available) */\n strategies?: SearchStrategyId[]\n /** Maximum results per strategy before merging */\n limit?: number\n /** Offset for pagination */\n offset?: number\n /** How to combine results: 'or' merges all, 'and' requires match in all strategies */\n combineMode?: 'or' | 'and'\n}\n\n// =============================================================================\n// Indexable Record\n// =============================================================================\n\n/**\n * A record prepared for indexing across all strategies.\n */\nexport type IndexableRecord = {\n /** Entity type identifier */\n entityId: EntityId\n /** Record primary key */\n recordId: string\n /** Tenant for isolation */\n tenantId: string\n /** Optional organization for additional filtering */\n organizationId?: string | null\n /** All fields from the record (strategies will filter based on their needs) */\n fields: Record<string, unknown>\n /** Optional presenter for result display */\n presenter?: SearchResultPresenter\n /** Primary URL for the record */\n url?: string\n /** Additional action links */\n links?: SearchResultLink[]\n /** Text content for embedding (from buildSource, used by vector strategy) */\n text?: string | string[]\n /** Source object for checksum calculation (change detection) */\n checksumSource?: unknown\n}\n\n// =============================================================================\n// Strategy Interface\n// =============================================================================\n\n/**\n * Interface that all search strategies must implement.\n * Following the cache module's strategy pattern.\n */\nexport interface SearchStrategy {\n /** Unique strategy identifier */\n readonly id: SearchStrategyId\n\n /** Human-readable name for debugging/logging */\n readonly name: string\n\n /** Priority for result merging (higher = more prominent in results) */\n readonly priority: number\n\n /** Check if strategy is available and configured */\n isAvailable(): Promise<boolean>\n\n /** Initialize strategy resources (lazy, called on first use) */\n ensureReady(): Promise<void>\n\n /** Execute a search query */\n search(query: string, options: SearchOptions): Promise<SearchResult[]>\n\n /** Index a record */\n index(record: IndexableRecord): Promise<void>\n\n /** Delete a record from the index */\n delete(entityId: EntityId, recordId: string, tenantId: string): Promise<void>\n\n /** Bulk index multiple records (optional optimization) */\n bulkIndex?(records: IndexableRecord[]): Promise<void>\n\n /** Purge all records for an entity type (optional) */\n purge?(entityId: EntityId, tenantId: string): Promise<void>\n}\n\n// =============================================================================\n// Service Configuration\n// =============================================================================\n\n/**\n * Configuration for result merging across strategies.\n */\nexport type ResultMergeConfig = {\n /** How to handle duplicate results: 'highest_score' | 'first' | 'merge_scores' */\n duplicateHandling: 'highest_score' | 'first' | 'merge_scores'\n /** Weight multipliers per strategy (e.g., { meilisearch: 1.2, tokens: 0.8 }) */\n strategyWeights?: Record<SearchStrategyId, number>\n /** Minimum score threshold to include in results */\n minScore?: number\n}\n\n/**\n * Callback function to enrich search results with presenter data.\n * Used to load presenter from database when not available from search strategy.\n */\nexport type PresenterEnricherFn = (\n results: SearchResult[],\n tenantId: string,\n organizationId?: string | null,\n) => Promise<SearchResult[]>\n\n/**\n * Options for creating a SearchService instance.\n */\nexport type SearchServiceOptions = {\n /** Array of strategy instances */\n strategies?: SearchStrategy[]\n /** Default strategies to use when not specified in search options */\n defaultStrategies?: SearchStrategyId[]\n /** Fallback strategy when others fail */\n fallbackStrategy?: SearchStrategyId\n /** Configuration for merging results from multiple strategies */\n mergeConfig?: ResultMergeConfig\n /** Callback to enrich results with presenter data from database */\n presenterEnricher?: PresenterEnricherFn\n /** TTL (ms) for the per-strategy availability cache. Defaults to 2_000. */\n availabilityCacheTtlMs?: number\n}\n\n// =============================================================================\n// Module Configuration (for modules defining searchable entities)\n// =============================================================================\n\n/**\n * Context passed to buildSource, formatResult, resolveUrl, and resolveLinks.\n */\nexport type SearchBuildContext = {\n /** The record being indexed */\n record: Record<string, unknown>\n /** Custom fields for the record */\n customFields: Record<string, unknown>\n /** Organization ID if applicable */\n organizationId?: string | null\n /** Tenant ID */\n tenantId?: string | null\n /** DI container for resolving dependencies */\n container?: unknown\n /** Query engine for loading related records (optional, used by buildSource for entity hydration) */\n queryEngine?: unknown\n}\n\n/**\n * Source data for indexing a record.\n */\nexport type SearchIndexSource = {\n /** Text content for keyword/fuzzy search (single string or array of chunks) */\n text: string | string[]\n /** Optional structured fields for filtering */\n fields?: Record<string, unknown>\n /** Presenter for quick display in search results */\n presenter?: SearchResultPresenter\n /** Deep links for the result */\n links?: SearchResultLink[]\n /** Source object used for checksum calculation (change detection) */\n checksumSource?: unknown\n}\n\n/**\n * Policy defining how fields should be handled for search indexing.\n */\nexport type SearchFieldPolicy = {\n /** Fields safe to send to external providers (fuzzy searchable) */\n searchable?: string[]\n /** Fields for hash-based search only (encrypted/sensitive) */\n hashOnly?: string[]\n /** Fields to exclude from all search */\n excluded?: string[]\n}\n\n/**\n * Configuration for a single searchable entity within a module.\n */\nexport type SearchEntityConfig = {\n /** Entity identifier, e.g., 'customers:customer_person_profile' */\n entityId: EntityId\n /** Enable/disable search for this entity (default: true) */\n enabled?: boolean\n /** Override strategies for this specific entity */\n strategies?: SearchStrategyId[]\n /** Priority for result ordering (higher = more prominent) */\n priority?: number\n /** Build searchable content from record */\n buildSource?: (ctx: SearchBuildContext) => Promise<SearchIndexSource | null> | SearchIndexSource | null\n /** Format result for display in Cmd+K */\n formatResult?: (ctx: SearchBuildContext) => Promise<SearchResultPresenter | null> | SearchResultPresenter | null\n /** Resolve primary URL when result is clicked */\n resolveUrl?: (ctx: SearchBuildContext) => Promise<string | null> | string | null\n /** Resolve additional action links */\n resolveLinks?: (ctx: SearchBuildContext) => Promise<SearchResultLink[] | null> | SearchResultLink[] | null\n /** Define which fields are searchable vs hash-only */\n fieldPolicy?: SearchFieldPolicy\n}\n\n/**\n * Module-level search configuration (defined in search.ts files).\n */\nexport type SearchModuleConfig = {\n /** Default strategies for all entities in this module */\n defaultStrategies?: SearchStrategyId[]\n /** Entity configurations */\n entities: SearchEntityConfig[]\n}\n\n// =============================================================================\n// Event Payloads (for indexer events)\n// =============================================================================\n\n/**\n * Payload for search.index_record events.\n */\nexport type SearchIndexPayload = {\n entityId: EntityId\n recordId: string\n tenantId: string\n organizationId?: string | null\n record: Record<string, unknown>\n customFields?: Record<string, unknown>\n}\n\n/**\n * Payload for search.delete_record events.\n */\nexport type SearchDeletePayload = {\n entityId: EntityId\n recordId: string\n tenantId: string\n}\n\n// =============================================================================\n// Global Registry for Search Module Configs\n// =============================================================================\n\nlet _searchModuleConfigs: SearchModuleConfig[] | null = null\n\n/**\n * Register search module configurations globally.\n * Called during app bootstrap with configs from search.generated.ts.\n */\nexport function registerSearchModuleConfigs(configs: SearchModuleConfig[]): void {\n if (_searchModuleConfigs !== null && process.env.NODE_ENV === 'development') {\n console.debug('[Bootstrap] Search module configs re-registered (this may occur during HMR)')\n }\n _searchModuleConfigs = configs\n}\n\n/**\n * Get registered search module configurations.\n * Returns empty array if not registered (search module may not be enabled).\n */\nexport function getSearchModuleConfigs(): SearchModuleConfig[] {\n return _searchModuleConfigs ?? []\n}\n"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import type { EntityId } from './entities'\n\n// =============================================================================\n// Strategy Identifiers\n// =============================================================================\n\n/**\n * Built-in strategy identifiers plus extensible string for third-party strategies.\n */\nexport type SearchStrategyId = 'tokens' | 'vector' | 'fulltext' | (string & Record<string, never>)\n\n// =============================================================================\n// Result Types\n// =============================================================================\n\n/**\n * Presenter metadata for displaying search results in UI (Cmd+K, global search).\n */\nexport type SearchResultPresenter = {\n title: string\n subtitle?: string\n icon?: string\n badge?: string\n}\n\n/**\n * Deep link rendered next to a search result.\n */\nexport type SearchResultLink = {\n href: string\n label: string\n kind?: 'primary' | 'secondary'\n}\n\n/**\n * A single search result returned by a strategy.\n */\nexport type SearchResult = {\n /** Entity type identifier, e.g., 'customers:customer_person_profile' */\n entityId: EntityId\n /** Record primary key */\n recordId: string\n /** Relevance score (normalized 0-1 range preferred, but RRF scores may exceed 1) */\n score: number\n /** Which strategy produced this result */\n source: SearchStrategyId\n /** Optional presenter for quick display */\n presenter?: SearchResultPresenter\n /** Primary URL when result is clicked */\n url?: string\n /** Additional action links */\n links?: SearchResultLink[]\n /** Extra metadata from the strategy */\n metadata?: Record<string, unknown>\n /** Organization scope of the result, when known by the strategy. */\n organizationId?: string | null\n}\n\n// =============================================================================\n// Search Options\n// =============================================================================\n\n/**\n * Options passed to SearchService.search()\n */\nexport type SearchOptions = {\n /** Tenant isolation - required */\n tenantId: string\n /**\n * Optional organization filter.\n * - `string` restricts results to that organization only.\n * - `undefined` or `null` means no organization filter (tenant-wide).\n */\n organizationId?: string | null\n /**\n * Optional organization allowlist.\n * - Non-empty array restricts results to one of those organizations.\n * - Empty array means no organizations are visible and should return no results.\n * - `undefined` or `null` means no organization filter (tenant-wide).\n *\n * `organizationId` takes precedence when both are provided.\n */\n organizationIds?: string[] | null\n /** Filter to specific entity types */\n entityTypes?: EntityId[]\n /** Use only specific strategies (defaults to all available) */\n strategies?: SearchStrategyId[]\n /** Maximum results per strategy before merging */\n limit?: number\n /** Offset for pagination */\n offset?: number\n /** How to combine results: 'or' merges all, 'and' requires match in all strategies */\n combineMode?: 'or' | 'and'\n}\n\n// =============================================================================\n// Indexable Record\n// =============================================================================\n\n/**\n * A record prepared for indexing across all strategies.\n */\nexport type IndexableRecord = {\n /** Entity type identifier */\n entityId: EntityId\n /** Record primary key */\n recordId: string\n /** Tenant for isolation */\n tenantId: string\n /** Optional organization for additional filtering */\n organizationId?: string | null\n /** All fields from the record (strategies will filter based on their needs) */\n fields: Record<string, unknown>\n /** Optional presenter for result display */\n presenter?: SearchResultPresenter\n /** Primary URL for the record */\n url?: string\n /** Additional action links */\n links?: SearchResultLink[]\n /** Text content for embedding (from buildSource, used by vector strategy) */\n text?: string | string[]\n /** Source object for checksum calculation (change detection) */\n checksumSource?: unknown\n}\n\n// =============================================================================\n// Strategy Interface\n// =============================================================================\n\n/**\n * Interface that all search strategies must implement.\n * Following the cache module's strategy pattern.\n */\nexport interface SearchStrategy {\n /** Unique strategy identifier */\n readonly id: SearchStrategyId\n\n /** Human-readable name for debugging/logging */\n readonly name: string\n\n /** Priority for result merging (higher = more prominent in results) */\n readonly priority: number\n\n /** Check if strategy is available and configured */\n isAvailable(): Promise<boolean>\n\n /** Initialize strategy resources (lazy, called on first use) */\n ensureReady(): Promise<void>\n\n /** Execute a search query */\n search(query: string, options: SearchOptions): Promise<SearchResult[]>\n\n /** Index a record */\n index(record: IndexableRecord): Promise<void>\n\n /** Delete a record from the index */\n delete(entityId: EntityId, recordId: string, tenantId: string): Promise<void>\n\n /** Bulk index multiple records (optional optimization) */\n bulkIndex?(records: IndexableRecord[]): Promise<void>\n\n /** Purge all records for an entity type (optional) */\n purge?(entityId: EntityId, tenantId: string): Promise<void>\n}\n\n// =============================================================================\n// Service Configuration\n// =============================================================================\n\n/**\n * Configuration for result merging across strategies.\n */\nexport type ResultMergeConfig = {\n /** How to handle duplicate results: 'highest_score' | 'first' | 'merge_scores' */\n duplicateHandling: 'highest_score' | 'first' | 'merge_scores'\n /** Weight multipliers per strategy (e.g., { meilisearch: 1.2, tokens: 0.8 }) */\n strategyWeights?: Record<SearchStrategyId, number>\n /** Minimum score threshold to include in results */\n minScore?: number\n}\n\n/**\n * Callback function to enrich search results with presenter data.\n * Used to load presenter from database when not available from search strategy.\n */\nexport type PresenterEnricherFn = (\n results: SearchResult[],\n tenantId: string,\n organizationId?: string | null,\n) => Promise<SearchResult[]>\n\n/**\n * Options for creating a SearchService instance.\n */\nexport type SearchServiceOptions = {\n /** Array of strategy instances */\n strategies?: SearchStrategy[]\n /** Default strategies to use when not specified in search options */\n defaultStrategies?: SearchStrategyId[]\n /** Fallback strategy when others fail */\n fallbackStrategy?: SearchStrategyId\n /** Configuration for merging results from multiple strategies */\n mergeConfig?: ResultMergeConfig\n /** Callback to enrich results with presenter data from database */\n presenterEnricher?: PresenterEnricherFn\n /** TTL (ms) for the per-strategy availability cache. Defaults to 2_000. */\n availabilityCacheTtlMs?: number\n}\n\n// =============================================================================\n// Module Configuration (for modules defining searchable entities)\n// =============================================================================\n\n/**\n * Context passed to buildSource, formatResult, resolveUrl, and resolveLinks.\n */\nexport type SearchBuildContext = {\n /** The record being indexed */\n record: Record<string, unknown>\n /** Custom fields for the record */\n customFields: Record<string, unknown>\n /** Organization ID if applicable */\n organizationId?: string | null\n /** Tenant ID */\n tenantId?: string | null\n /** DI container for resolving dependencies */\n container?: unknown\n /** Query engine for loading related records (optional, used by buildSource for entity hydration) */\n queryEngine?: unknown\n}\n\n/**\n * Source data for indexing a record.\n */\nexport type SearchIndexSource = {\n /** Text content for keyword/fuzzy search (single string or array of chunks) */\n text: string | string[]\n /** Optional structured fields for filtering */\n fields?: Record<string, unknown>\n /** Presenter for quick display in search results */\n presenter?: SearchResultPresenter\n /** Deep links for the result */\n links?: SearchResultLink[]\n /** Source object used for checksum calculation (change detection) */\n checksumSource?: unknown\n}\n\n/**\n * Policy defining how fields should be handled for search indexing.\n */\nexport type SearchFieldPolicy = {\n /** Fields safe to send to external providers (fuzzy searchable) */\n searchable?: string[]\n /** Fields for hash-based search only (encrypted/sensitive) */\n hashOnly?: string[]\n /** Fields to exclude from all search */\n excluded?: string[]\n}\n\n/**\n * Configuration for a single searchable entity within a module.\n */\nexport type SearchEntityConfig = {\n /** Entity identifier, e.g., 'customers:customer_person_profile' */\n entityId: EntityId\n /** Enable/disable search for this entity (default: true) */\n enabled?: boolean\n /** Override strategies for this specific entity */\n strategies?: SearchStrategyId[]\n /** Priority for result ordering (higher = more prominent) */\n priority?: number\n /** Build searchable content from record */\n buildSource?: (ctx: SearchBuildContext) => Promise<SearchIndexSource | null> | SearchIndexSource | null\n /** Format result for display in Cmd+K */\n formatResult?: (ctx: SearchBuildContext) => Promise<SearchResultPresenter | null> | SearchResultPresenter | null\n /** Resolve primary URL when result is clicked */\n resolveUrl?: (ctx: SearchBuildContext) => Promise<string | null> | string | null\n /** Resolve additional action links */\n resolveLinks?: (ctx: SearchBuildContext) => Promise<SearchResultLink[] | null> | SearchResultLink[] | null\n /** Define which fields are searchable vs hash-only */\n fieldPolicy?: SearchFieldPolicy\n}\n\n/**\n * Module-level search configuration (defined in search.ts files).\n */\nexport type SearchModuleConfig = {\n /** Default strategies for all entities in this module */\n defaultStrategies?: SearchStrategyId[]\n /** Entity configurations */\n entities: SearchEntityConfig[]\n}\n\n// =============================================================================\n// Event Payloads (for indexer events)\n// =============================================================================\n\n/**\n * Payload for search.index_record events.\n */\nexport type SearchIndexPayload = {\n entityId: EntityId\n recordId: string\n tenantId: string\n organizationId?: string | null\n record: Record<string, unknown>\n customFields?: Record<string, unknown>\n}\n\n/**\n * Payload for search.delete_record events.\n */\nexport type SearchDeletePayload = {\n entityId: EntityId\n recordId: string\n tenantId: string\n}\n\n// =============================================================================\n// Global Registry for Search Module Configs\n// =============================================================================\n\nlet _searchModuleConfigs: SearchModuleConfig[] | null = null\n\n/**\n * Register search module configurations globally.\n * Called during app bootstrap with configs from search.generated.ts.\n */\nexport function registerSearchModuleConfigs(configs: SearchModuleConfig[]): void {\n if (_searchModuleConfigs !== null && process.env.NODE_ENV === 'development') {\n console.debug('[Bootstrap] Search module configs re-registered (this may occur during HMR)')\n }\n _searchModuleConfigs = configs\n}\n\n/**\n * Get registered search module configurations.\n * Returns empty array if not registered (search module may not be enabled).\n */\nexport function getSearchModuleConfigs(): SearchModuleConfig[] {\n return _searchModuleConfigs ?? []\n}\n"],
|
|
5
|
+
"mappings": "AAkUA,IAAI,uBAAoD;AAMjD,SAAS,4BAA4B,SAAqC;AAC/E,MAAI,yBAAyB,QAAQ,QAAQ,IAAI,aAAa,eAAe;AAC3E,YAAQ,MAAM,6EAA6E;AAAA,EAC7F;AACA,yBAAuB;AACzB;AAMO,SAAS,yBAA+C;AAC7D,SAAO,wBAAwB,CAAC;AAClC;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.4-develop.
|
|
3
|
+
"version": "0.6.4-develop.4254.1.7a123d970c",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"@mikro-orm/core": "^7.1.1",
|
|
93
93
|
"@mikro-orm/decorators": "^7.1.1",
|
|
94
94
|
"@mikro-orm/postgresql": "^7.1.1",
|
|
95
|
-
"@open-mercato/cache": "0.6.4-develop.
|
|
95
|
+
"@open-mercato/cache": "0.6.4-develop.4254.1.7a123d970c",
|
|
96
96
|
"dotenv": "^17.4.2",
|
|
97
97
|
"rate-limiter-flexible": "^11.1.0",
|
|
98
98
|
"re2js": "2.8.3",
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { isOrganizationAccessAllowed } from '@open-mercato/shared/lib/auth/organizationAccess'
|
|
2
|
+
|
|
3
|
+
describe('isOrganizationAccessAllowed', () => {
|
|
4
|
+
it('allows super admins regardless of scope', () => {
|
|
5
|
+
expect(
|
|
6
|
+
isOrganizationAccessAllowed({
|
|
7
|
+
isSuperAdmin: true,
|
|
8
|
+
allowedOrganizationIds: [],
|
|
9
|
+
targetOrganizationId: 'org-b',
|
|
10
|
+
}),
|
|
11
|
+
).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('allows truly unrestricted access (allowedOrganizationIds === null)', () => {
|
|
15
|
+
expect(
|
|
16
|
+
isOrganizationAccessAllowed({
|
|
17
|
+
isSuperAdmin: false,
|
|
18
|
+
allowedOrganizationIds: null,
|
|
19
|
+
targetOrganizationId: 'org-b',
|
|
20
|
+
}),
|
|
21
|
+
).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('denies a restricted principal with an empty allowed set (fail closed)', () => {
|
|
25
|
+
expect(
|
|
26
|
+
isOrganizationAccessAllowed({
|
|
27
|
+
isSuperAdmin: false,
|
|
28
|
+
allowedOrganizationIds: [],
|
|
29
|
+
targetOrganizationId: 'org-b',
|
|
30
|
+
}),
|
|
31
|
+
).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('denies a restricted principal acting on an org outside the allowed set', () => {
|
|
35
|
+
expect(
|
|
36
|
+
isOrganizationAccessAllowed({
|
|
37
|
+
isSuperAdmin: false,
|
|
38
|
+
allowedOrganizationIds: ['org-a'],
|
|
39
|
+
targetOrganizationId: 'org-b',
|
|
40
|
+
}),
|
|
41
|
+
).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('allows a restricted principal acting on an org inside the allowed set', () => {
|
|
45
|
+
expect(
|
|
46
|
+
isOrganizationAccessAllowed({
|
|
47
|
+
isSuperAdmin: false,
|
|
48
|
+
allowedOrganizationIds: ['org-a'],
|
|
49
|
+
targetOrganizationId: 'org-a',
|
|
50
|
+
}),
|
|
51
|
+
).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('denies a restricted principal when the target org is missing', () => {
|
|
55
|
+
expect(
|
|
56
|
+
isOrganizationAccessAllowed({
|
|
57
|
+
isSuperAdmin: false,
|
|
58
|
+
allowedOrganizationIds: ['org-a'],
|
|
59
|
+
targetOrganizationId: null,
|
|
60
|
+
}),
|
|
61
|
+
).toBe(false)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type OrganizationAccessDecisionInput = {
|
|
2
|
+
isSuperAdmin: boolean
|
|
3
|
+
allowedOrganizationIds: readonly string[] | null
|
|
4
|
+
targetOrganizationId: string | null
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fail-closed organization-access predicate. The single source of truth for
|
|
9
|
+
* "may this principal act on `targetOrganizationId`".
|
|
10
|
+
*
|
|
11
|
+
* Decision table:
|
|
12
|
+
* - `isSuperAdmin === true` -> allow (global access)
|
|
13
|
+
* - `allowedOrganizationIds === null` -> allow (truly unrestricted)
|
|
14
|
+
* - restricted + no target org -> deny (empty/unknown scope is not a bypass)
|
|
15
|
+
* - restricted + target org -> allow iff the target is a member of the allowed set
|
|
16
|
+
*/
|
|
17
|
+
export function isOrganizationAccessAllowed(input: OrganizationAccessDecisionInput): boolean {
|
|
18
|
+
if (input.isSuperAdmin) return true
|
|
19
|
+
if (input.allowedOrganizationIds === null) return true
|
|
20
|
+
if (!input.targetOrganizationId) return false
|
|
21
|
+
return input.allowedOrganizationIds.includes(input.targetOrganizationId)
|
|
22
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { ensureOrganizationScope } from '@open-mercato/shared/lib/commands/scope'
|
|
2
|
+
import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
|
|
3
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
4
|
+
|
|
5
|
+
type ScopeShape = NonNullable<CommandRuntimeContext['organizationScope']>
|
|
6
|
+
|
|
7
|
+
function buildCtx(overrides: {
|
|
8
|
+
isSuperAdmin?: boolean
|
|
9
|
+
orgId?: string | null
|
|
10
|
+
selectedOrganizationId?: string | null
|
|
11
|
+
organizationScope?: ScopeShape | null
|
|
12
|
+
}): CommandRuntimeContext {
|
|
13
|
+
return {
|
|
14
|
+
container: {} as CommandRuntimeContext['container'],
|
|
15
|
+
auth: {
|
|
16
|
+
sub: 'user-1',
|
|
17
|
+
tenantId: 'tenant-1',
|
|
18
|
+
orgId: overrides.orgId ?? null,
|
|
19
|
+
isSuperAdmin: overrides.isSuperAdmin ?? false,
|
|
20
|
+
},
|
|
21
|
+
organizationScope: overrides.organizationScope ?? null,
|
|
22
|
+
selectedOrganizationId: overrides.selectedOrganizationId ?? null,
|
|
23
|
+
organizationIds: null,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildScope(overrides: Partial<ScopeShape>): ScopeShape {
|
|
28
|
+
return {
|
|
29
|
+
selectedId: null,
|
|
30
|
+
filterIds: null,
|
|
31
|
+
allowedIds: null,
|
|
32
|
+
tenantId: 'tenant-1',
|
|
33
|
+
...overrides,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('ensureOrganizationScope', () => {
|
|
38
|
+
it('denies a restricted floating user acting on an org outside allowedIds (#2239)', () => {
|
|
39
|
+
const ctx = buildCtx({
|
|
40
|
+
orgId: null,
|
|
41
|
+
selectedOrganizationId: null,
|
|
42
|
+
organizationScope: buildScope({ allowedIds: ['org-a'], filterIds: ['org-a'] }),
|
|
43
|
+
})
|
|
44
|
+
expect(() => ensureOrganizationScope(ctx, 'org-b')).toThrow(CrudHttpError)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('allows super admins', () => {
|
|
48
|
+
const ctx = buildCtx({
|
|
49
|
+
isSuperAdmin: true,
|
|
50
|
+
organizationScope: buildScope({ allowedIds: ['org-a'] }),
|
|
51
|
+
})
|
|
52
|
+
expect(() => ensureOrganizationScope(ctx, 'org-b')).not.toThrow()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('allows truly unrestricted scope (allowedIds === null)', () => {
|
|
56
|
+
const ctx = buildCtx({
|
|
57
|
+
organizationScope: buildScope({ allowedIds: null }),
|
|
58
|
+
})
|
|
59
|
+
expect(() => ensureOrganizationScope(ctx, 'org-b')).not.toThrow()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('allows a restricted user acting on an org inside allowedIds (allow-path regression)', () => {
|
|
63
|
+
const ctx = buildCtx({
|
|
64
|
+
orgId: 'org-a',
|
|
65
|
+
organizationScope: buildScope({ allowedIds: ['org-a'], filterIds: ['org-a'] }),
|
|
66
|
+
})
|
|
67
|
+
expect(() => ensureOrganizationScope(ctx, 'org-a')).not.toThrow()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('absent organization scope (Pattern C — legacy fallback, not deny)', () => {
|
|
71
|
+
it('allows when no current org can be resolved', () => {
|
|
72
|
+
const ctx = buildCtx({ orgId: null, selectedOrganizationId: null, organizationScope: null })
|
|
73
|
+
expect(() => ensureOrganizationScope(ctx, 'org-b')).not.toThrow()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('allows when the resolved current org matches the target', () => {
|
|
77
|
+
const ctx = buildCtx({ selectedOrganizationId: 'org-a', organizationScope: null })
|
|
78
|
+
expect(() => ensureOrganizationScope(ctx, 'org-a')).not.toThrow()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('denies when the resolved current org differs from the target', () => {
|
|
82
|
+
const ctx = buildCtx({ selectedOrganizationId: 'org-a', organizationScope: null })
|
|
83
|
+
expect(() => ensureOrganizationScope(ctx, 'org-b')).toThrow(CrudHttpError)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
2
2
|
import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
|
|
3
|
+
import { isOrganizationAccessAllowed } from '@open-mercato/shared/lib/auth/organizationAccess'
|
|
3
4
|
import { env } from 'process'
|
|
4
5
|
|
|
5
6
|
function logScopeViolation(
|
|
@@ -89,16 +90,36 @@ function logTenantScopeViolation(
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
export function ensureOrganizationScope(ctx: CommandRuntimeContext, organizationId: string): void {
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
const isSuperAdmin = ctx.auth?.isSuperAdmin === true
|
|
94
|
+
const scope = ctx.organizationScope
|
|
95
|
+
|
|
96
|
+
// Pattern C: when no organization scope was resolved (system/worker/non-user
|
|
97
|
+
// command contexts that build ctx with `organizationScope: null`), preserve
|
|
98
|
+
// the legacy currentOrg fallback. This branch is load-bearing — switching it
|
|
99
|
+
// to deny would break payment, scheduled-command, and other scope-less flows.
|
|
100
|
+
if (!scope) {
|
|
101
|
+
if (isSuperAdmin) return
|
|
102
|
+
const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
|
|
103
|
+
if (currentOrg && currentOrg !== organizationId) {
|
|
104
|
+
logScopeViolation(ctx, organizationId, currentOrg)
|
|
105
|
+
throw new CrudHttpError(403, { error: 'Forbidden' })
|
|
106
|
+
}
|
|
94
107
|
return
|
|
95
108
|
}
|
|
96
109
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
110
|
+
if (
|
|
111
|
+
isOrganizationAccessAllowed({
|
|
112
|
+
isSuperAdmin,
|
|
113
|
+
allowedOrganizationIds: scope.allowedIds,
|
|
114
|
+
targetOrganizationId: organizationId,
|
|
115
|
+
})
|
|
116
|
+
) {
|
|
117
|
+
return
|
|
101
118
|
}
|
|
119
|
+
|
|
120
|
+
const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
|
|
121
|
+
logScopeViolation(ctx, organizationId, currentOrg)
|
|
122
|
+
throw new CrudHttpError(403, { error: 'Forbidden' })
|
|
102
123
|
}
|
|
103
124
|
|
|
104
125
|
export function ensureTenantScope(ctx: CommandRuntimeContext, tenantId: string): void {
|
package/src/modules/search.ts
CHANGED
|
@@ -52,6 +52,8 @@ export type SearchResult = {
|
|
|
52
52
|
links?: SearchResultLink[]
|
|
53
53
|
/** Extra metadata from the strategy */
|
|
54
54
|
metadata?: Record<string, unknown>
|
|
55
|
+
/** Organization scope of the result, when known by the strategy. */
|
|
56
|
+
organizationId?: string | null
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
// =============================================================================
|
|
@@ -70,6 +72,15 @@ export type SearchOptions = {
|
|
|
70
72
|
* - `undefined` or `null` means no organization filter (tenant-wide).
|
|
71
73
|
*/
|
|
72
74
|
organizationId?: string | null
|
|
75
|
+
/**
|
|
76
|
+
* Optional organization allowlist.
|
|
77
|
+
* - Non-empty array restricts results to one of those organizations.
|
|
78
|
+
* - Empty array means no organizations are visible and should return no results.
|
|
79
|
+
* - `undefined` or `null` means no organization filter (tenant-wide).
|
|
80
|
+
*
|
|
81
|
+
* `organizationId` takes precedence when both are provided.
|
|
82
|
+
*/
|
|
83
|
+
organizationIds?: string[] | null
|
|
73
84
|
/** Filter to specific entity types */
|
|
74
85
|
entityTypes?: EntityId[]
|
|
75
86
|
/** Use only specific strategies (defaults to all available) */
|