From 69da71b7de733a096df6455d33ce37c49f5bd763 Mon Sep 17 00:00:00 2001 From: Jack Date: Sun, 7 Jun 2026 23:39:54 +0800 Subject: [PATCH] feat(security): expose getReadFilter service; analytics enforces real RLS (ADR-0021 D-C, Task #9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last P0 gap: the analytics raw-SQL path (which bypasses the ObjectQL engine middleware) can now enforce the SAME tenant/RLS row scoping the engine applies on `find`, in production — not just when a test wires a provider. ## Security side (plugin-security) - Extract the middleware's per-request RLS resolution into two shared private methods used by BOTH the engine middleware and the new public method, so the find-path and the analytics path are provably in lock-step: - resolvePermissionSetsForContext() — roles + explicit + implicit/post-resolution baseline fallback. - computeRlsFilter() — collect applicable policies → field-existence safety (wildcard org policy on a column the object lacks → deny sentinel; tenancy opt-out → skip) → compile (+ deny sentinel). The middleware now calls these; behavior is byte-identical (59 existing tests green). - New `async getReadFilter(object, context)` composing the two. Returns: undefined (system / anonymous → no scope), a FilterCondition (AND into the object's scan), or the RLS_DENY_FILTER sentinel (policies applied but none compiled, OR resolution threw) — FAIL-CLOSED: a degraded permission subsystem denies (zero rows), never leaks cross-tenant rows. - Register the `security` service exposing getReadFilter (once metadata/ql/dbLoader handles are wired in start()), which the analytics plugin auto-bridges to. ## Analytics side (service-analytics) - The provider may now be async (getReadFilter can hit the DB). Since the strategy builds SQL synchronously, AnalyticsService pre-resolves the read scope for every object the query scans — base (`cube.sql`) + every `cube.joins[*].name`, a superset of what the strategy scopes — into a Map BEFORE the SQL builder runs; the strategy reads each object's filter synchronously from that map. The NativeSQLStrategy is untouched (StrategyContext.getReadScope stays sync → no spec contract change). - Fail-closed: if the provider throws for any object, the whole query is rejected (no unscoped SQL is ever emitted/executed). ## Tests - plugin-security: +8 getReadFilter tests (parity with find-path, fail-closed deny on missing column + on resolution throw, tenancy opt-out, system/anon bypass, service registration). 86 green. - service-analytics: +2 (async provider scopes base+joined by tenant; async reject → fail-closed, zero SQL emitted). 87 green. Existing sync-provider + deny-sentinel integration gates still pass unchanged. - Smoke: kernel boots clean with both plugins (Security + AnalyticsServicePlugin). Resolves the SecurityPlugin half of Task #9; the analytics auto-bridge seam was already in place from the P0 work. Co-Authored-By: Claude Opus 4.8 --- .../src/security-plugin.test.ts | 96 ++++++ .../plugin-security/src/security-plugin.ts | 274 ++++++++++++------ .../__tests__/dataset-rls-integration.test.ts | 48 +++ .../src/analytics-service.ts | 84 +++++- .../services/service-analytics/src/plugin.ts | 18 +- 5 files changed, 428 insertions(+), 92 deletions(-) diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 6ae0d3c66..e15375574 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -287,6 +287,102 @@ describe('SecurityPlugin', () => { expect(opCtx.ast.where).toEqual({ organization_id: 'org-1' }); }); + // ------------------------------------------------------------------------- + // getReadFilter service (ADR-0021 D-C) — the reusable READ scope the + // analytics raw-SQL path bridges to. Must produce the SAME FilterCondition + // the engine middleware injects on `find`, and fail CLOSED on any error. + // ------------------------------------------------------------------------- + describe('getReadFilter service', () => { + it('registers a "security" service exposing getReadFilter', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet] }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const call = (harness.ctx.registerService as any).mock.calls.find( + (c: any[]) => c[0] === 'security', + ); + expect(call).toBeTruthy(); + expect(typeof call[1].getReadFilter).toBe('function'); + }); + + it('returns the SAME tenant filter the find-path injects (org-scoping on)', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet], orgScoping: true }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const ctx = { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }; + const filter = await plugin.getReadFilter('task', ctx); + expect(filter).toEqual({ organization_id: 'org-1' }); + }); + + it('returns undefined (no scope) when tenant_isolation is stripped (org-scoping off)', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet] }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const filter = await plugin.getReadFilter('task', { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }); + expect(filter).toBeUndefined(); + }); + + it('fail-closed: wildcard org policy on an object missing the column → deny sentinel', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ + permissionSets: [tenantPolicySet], + objectFields: ['id', 'name'], // no organization_id, tenancy not opted out + orgScoping: true, + }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const filter = await plugin.getReadFilter('task', { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }); + expect(filter).toEqual(RLS_DENY_FILTER); + }); + + it('tenancy opt-out → undefined (not denied), matching the find-path', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ + permissionSets: [tenantPolicySet], + objectFields: ['id', 'name'], + schemaExtra: { tenancy: { enabled: false, strategy: 'shared' } }, + orgScoping: true, + }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const filter = await plugin.getReadFilter('task', { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }); + expect(filter).toBeUndefined(); + }); + + it('system context bypasses scoping (returns undefined)', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet], orgScoping: true }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const filter = await plugin.getReadFilter('task', { isSystem: true, userId: 'u1', tenantId: 'org-1' }); + expect(filter).toBeUndefined(); + }); + + it('anonymous (no userId/roles/permissions) → undefined (authn gated elsewhere)', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet], orgScoping: true }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const filter = await plugin.getReadFilter('task', { roles: [], permissions: [] }); + expect(filter).toBeUndefined(); + }); + + it('fail-closed: a permission-resolution throw yields the deny sentinel (never allow-all)', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet], orgScoping: true }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + // Force resolution to blow up. + (plugin as any).permissionEvaluator.resolvePermissionSets = async () => { + throw new Error('metadata service unavailable'); + }; + const filter = await plugin.getReadFilter('task', { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }); + expect(filter).toEqual(RLS_DENY_FILTER); + }); + }); + // ------------------------------------------------------------------------- // FLS write enforcement (Backend FLS strip — gap #1) // ------------------------------------------------------------------------- diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 9bdc3e32b..fe22106b8 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -99,6 +99,15 @@ export class SecurityPlugin implements Plugin { * zero rows. */ private readonly tenancyDisabledCache = new Map(); + /** + * Service handles captured in `start()` so the request-time RLS resolution + * (used by BOTH the engine middleware and the public {@link getReadFilter} + * service method) shares one code path. `null` until `start()` wires them. + */ + private metadata: any = null; + private ql: any = null; + private dbLoader?: (names: string[]) => Promise; + private logger: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void } = {}; constructor(options: SecurityPluginOptions = {}) { this.bootstrapPermissionSets = @@ -189,6 +198,12 @@ export class SecurityPlugin implements Plugin { return; } + // Capture handles so the request-time RLS resolution is shared by the + // engine middleware AND the public getReadFilter service method. + this.metadata = metadata; + this.ql = ql; + this.logger = ctx.logger; + // Probe for OrgScopingPlugin presence. When registered, its // `init()` exposes itself as the `org-scoping` service. We capture // the boolean once at start time (plugin DI graph is static after @@ -238,6 +253,24 @@ export class SecurityPlugin implements Plugin { })); } : undefined; + this.dbLoader = dbLoader; + + // ADR-0021 D-C — expose the per-request READ scope as a reusable service. + // The analytics raw-SQL path (which bypasses this engine middleware) + // auto-bridges to `getService('security').getReadFilter(object, context)` + // to enforce tenant/RLS on every base + joined object. We register the + // service only once the metadata/ql/dbLoader handles are wired (above), so + // a degraded start never exposes a half-initialised resolver. + try { + ctx.registerService('security', { + getReadFilter: (object: string, context?: any) => this.getReadFilter(object, context), + }); + ctx.logger.info('[security] registered "security" service (getReadFilter) for raw-SQL RLS bridging'); + } catch (e) { + ctx.logger.warn?.('[security] failed to register "security" service', { + error: (e as Error).message, + }); + } // Register security middleware ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { @@ -261,51 +294,14 @@ export class SecurityPlugin implements Plugin { } // 1. Resolve permission sets from BOTH role names and explicit - // permission set names attached to the execution context. + // permission set names attached to the execution context. The + // resolution (incl. the implicit + post-resolution baseline + // fallback) is shared with the public getReadFilter service via + // resolvePermissionSetsForContext — keeping the find-path RLS and + // the analytics raw-SQL RLS provably in lock-step. let permissionSets: PermissionSet[] = []; try { - const requested = [...roles, ...explicitPermissionSets]; - // Implicit baseline: when an authenticated request resolved zero - // permission sets, fall back to the configured baseline (default - // `member_default`). This guarantees tenant + owner RLS even - // before an admin has assigned a profile/permission set. - if ( - requested.length === 0 && - opCtx.context?.userId && - this.fallbackPermissionSet - ) { - requested.push(this.fallbackPermissionSet); - } - permissionSets = await this.permissionEvaluator.resolvePermissionSets( - requested, - metadata, - this.bootstrapPermissionSets, - dbLoader, - ); - // **Post-resolution fallback** — closes the fail-open hole that - // appears when a user's `roles` array is populated (e.g. a - // better-auth `sys_member.role` like `owner`/`admin`/`member`) - // but no `sys_role`→`sys_permission_set` binding exists yet, so - // resolution returns an empty array. Without this, both the - // CRUD check (`permissionSets.length > 0`) and the RLS injection - // (`allRlsPolicies.length > 0`) below get skipped → the user - // sees every tenant's data. Authenticated users with no - // resolved permission sets always inherit the configured - // baseline (default `member_default`, which carries - // `tenant_isolation` + `owner_only_writes`). - if ( - permissionSets.length === 0 && - opCtx.context?.userId && - this.fallbackPermissionSet - ) { - const fallback = await this.permissionEvaluator.resolvePermissionSets( - [this.fallbackPermissionSet], - metadata, - this.bootstrapPermissionSets, - dbLoader, - ); - permissionSets = fallback; - } + permissionSets = await this.resolvePermissionSetsForContext(opCtx.context); } catch (e) { // Fail CLOSED. A permission-resolution failure must DENY the request, // never bypass the checks (that would let a degraded metadata service @@ -417,48 +413,17 @@ export class SecurityPlugin implements Plugin { } } - // 3. RLS filter injection - const allRlsPolicies = this.collectRLSPolicies(permissionSets, opCtx.object, opCtx.operation); - if (allRlsPolicies.length > 0 && opCtx.ast) { - // Field-existence safety: wildcard policies (`object: '*'`) target - // fields like `organization_id` that may not exist on every object - // (e.g. system tables, CRM apps that haven't yet adopted multi-tenancy). - // - // We treat such policies as a *deny* contribution rather than dropping - // them, so they fail-closed when no per-object policy provides an - // alternate match. Any per-object policy that DOES compile against - // the object will OR-combine and grant access (e.g. `sys_user_self`). - // When the schema lookup itself fails we keep all policies (drivers - // will surface column errors clearly during compilation). - const objectFields = await this.getObjectFieldNames(metadata, opCtx.object, ql); - const tenancyDisabled = this.tenancyDisabledCache.get(opCtx.object) === true; - let dropped = 0; - const compilable = objectFields - ? allRlsPolicies.filter((p) => { - const targetField = this.extractTargetField(p.using); - if (!targetField) return true; - if (objectFields.has(targetField)) return true; - // Schema-level opt-out: when the object explicitly - // disabled tenancy (`tenancy.enabled === false`), the - // wildcard `tenant_isolation` policy targeting - // `organization_id` was never meant to apply. Treat as - // "not applicable" — skip silently without contributing - // to the deny sentinel, mirroring how the registry skips - // injecting the column itself for these tables. - if (tenancyDisabled && targetField === 'organization_id') { - return false; - } - dropped++; - return false; - }) - : allRlsPolicies; - let rlsFilter = this.rlsCompiler.compileFilter(compilable, opCtx.context); - // If every applicable policy was dropped because of missing fields, - // contribute the deny sentinel (zero rows) — matches the rls-compiler - // semantics for "policies were applicable but none compiled". - if (rlsFilter == null && dropped > 0) { - rlsFilter = { ...RLS_DENY_FILTER }; - } + // 3. RLS filter injection. The policy collection + field-existence + // safety + compile (incl. the fail-closed deny sentinel) is shared with + // the public getReadFilter service via computeRlsFilter, so the engine + // find-path and the analytics raw-SQL path enforce identical scoping. + if (opCtx.ast) { + const rlsFilter = await this.computeRlsFilter( + permissionSets, + opCtx.object, + opCtx.operation, + opCtx.context, + ); if (rlsFilter) { if (opCtx.ast.where) { opCtx.ast.where = { $and: [opCtx.ast.where, rlsFilter] }; @@ -600,6 +565,145 @@ export class SecurityPlugin implements Plugin { // No cleanup needed } + /** + * ADR-0021 D-C — resolve the per-request READ scope (tenant + RLS predicate) + * for one object as a canonical `FilterCondition`, WITHOUT touching the + * ObjectQL engine. This is the seam the analytics raw-SQL path bridges to so + * it enforces the SAME row scoping the engine middleware applies on `find`. + * + * Returns: + * - `undefined` → no scope applies (system context, or an unauthenticated + * request with no userId/roles/permissions — authn is gated elsewhere). + * - a `FilterCondition` → AND it into the object's scan (the join's `ON`/ + * `WHERE` for analytics; the where clause for a plain find). + * - the `RLS_DENY_FILTER` sentinel → policies applied but none compiled, or + * resolution failed — fail-closed to zero rows. NEVER returns "allow all" + * on error, so a degraded permission subsystem cannot leak cross-tenant + * rows through analytics. + * + * Async because permission-set resolution can hit the database; the analytics + * service pre-resolves these per request (base + every joined object) before + * the synchronous SQL builder runs. + */ + async getReadFilter( + object: string, + context?: any, + ): Promise | null | undefined> { + // System operations bypass scoping (mirrors the middleware's isSystem skip). + if (context?.isSystem) return undefined; + const roles = context?.roles ?? []; + const explicit = context?.permissions ?? []; + // Unauthenticated + role-less + permission-less → no scope (the auth layer, + // not RLS, gates anonymous access; the analytics REST endpoint already 401s + // without a token). Mirrors the middleware's early `return next()`. + if (roles.length === 0 && explicit.length === 0 && !context?.userId) { + return undefined; + } + try { + const permissionSets = await this.resolvePermissionSetsForContext(context); + const filter = await this.computeRlsFilter(permissionSets, object, 'find', context); + return filter ?? undefined; + } catch (e) { + // Fail CLOSED — a resolution failure must deny (zero rows), never expose + // every tenant's data through the raw-SQL analytics path. + this.logger.error?.( + `[security] getReadFilter failed for object '${object}' ` + + `(user ${context?.userId ?? 'unknown'}) — denying (fail-closed)`, + e instanceof Error ? e : new Error(String(e)), + ); + return { ...RLS_DENY_FILTER }; + } + } + + /** + * Resolve the effective permission sets for an execution context — roles + + * explicit permission sets, with the configured baseline applied both as an + * implicit request (when none were named) and as a post-resolution fallback + * (when named ones resolved to nothing). Shared by the engine middleware and + * {@link getReadFilter} so both enforce identical RLS. May throw if the + * underlying metadata/db resolution fails (callers fail-closed). + */ + private async resolvePermissionSetsForContext( + context: any, + ): Promise { + const roles = context?.roles ?? []; + const explicitPermissionSets = context?.permissions ?? []; + const requested = [...roles, ...explicitPermissionSets]; + // Implicit baseline: an authenticated request that named no roles/perms + // still gets the configured baseline (default `member_default`) so tenant + + // owner RLS apply before an admin assigns a profile. + if (requested.length === 0 && context?.userId && this.fallbackPermissionSet) { + requested.push(this.fallbackPermissionSet); + } + let permissionSets = await this.permissionEvaluator.resolvePermissionSets( + requested, + this.metadata, + this.bootstrapPermissionSets, + this.dbLoader, + ); + // Post-resolution fallback — closes the fail-open hole where a populated + // `roles` array maps to no permission set yet (no sys_role binding), which + // would otherwise skip RLS entirely and expose every tenant's data. + if ( + permissionSets.length === 0 && + context?.userId && + this.fallbackPermissionSet + ) { + permissionSets = await this.permissionEvaluator.resolvePermissionSets( + [this.fallbackPermissionSet], + this.metadata, + this.bootstrapPermissionSets, + this.dbLoader, + ); + } + return permissionSets; + } + + /** + * Compile the applicable RLS policies for (object, operation) into a single + * `FilterCondition`, applying the field-existence safety net (wildcard + * policies targeting a column the object lacks fail-closed to the deny + * sentinel, unless the object explicitly opted out of tenancy). Shared by the + * engine middleware and {@link getReadFilter}. Returns `null` when no policy + * applies (caller adds no filter). + */ + private async computeRlsFilter( + permissionSets: PermissionSet[], + object: string, + operation: string, + context: any, + ): Promise | null> { + const allRlsPolicies = this.collectRLSPolicies(permissionSets, object, operation); + if (allRlsPolicies.length === 0) return null; + // Field-existence safety: wildcard policies (`object: '*'`) target fields + // like `organization_id` that may not exist on every object. Treat such a + // policy as a *deny* contribution (fail-closed) rather than dropping it — + // unless the object explicitly opted out of tenancy, where it's "not + // applicable" and skipped silently. When the schema lookup itself fails we + // keep all policies (drivers surface column errors clearly at compile time). + const objectFields = await this.getObjectFieldNames(this.metadata, object, this.ql); + const tenancyDisabled = this.tenancyDisabledCache.get(object) === true; + let dropped = 0; + const compilable = objectFields + ? allRlsPolicies.filter((p) => { + const targetField = this.extractTargetField(p.using); + if (!targetField) return true; + if (objectFields.has(targetField)) return true; + if (tenancyDisabled && targetField === 'organization_id') { + return false; + } + dropped++; + return false; + }) + : allRlsPolicies; + let rlsFilter = this.rlsCompiler.compileFilter(compilable, context); + // Every applicable policy dropped for a missing field → deny sentinel. + if (rlsFilter == null && dropped > 0) { + rlsFilter = { ...RLS_DENY_FILTER }; + } + return rlsFilter; + } + /** * Collect all RLS policies from permission sets applicable to the given object/operation. */ diff --git a/packages/services/service-analytics/src/__tests__/dataset-rls-integration.test.ts b/packages/services/service-analytics/src/__tests__/dataset-rls-integration.test.ts index 733a3ec6d..92c9a826e 100644 --- a/packages/services/service-analytics/src/__tests__/dataset-rls-integration.test.ts +++ b/packages/services/service-analytics/src/__tests__/dataset-rls-integration.test.ts @@ -100,6 +100,54 @@ describe('Dataset RLS integration (R1 gate)', () => { expect(captured[0].params.filter((p) => String(p).startsWith('__rls_deny__'))).toHaveLength(2); }); + it('supports an ASYNC read-scope provider (production security.getReadFilter shape)', async () => { + // The production bridge resolves RLS from the `security` service, which can + // hit the DB — i.e. the provider returns a Promise. The service must + // pre-resolve it before the synchronous SQL builder runs and still scope + // BOTH base and joined objects by the per-request tenant. + const captured: { sql: string; params: unknown[] }[] = []; + const compiled = compileDataset(dataset); + const asyncReadScope = async ( + _object: string, + context?: ExecutionContext, + ): Promise => { + await Promise.resolve(); + return context?.tenantId ? { organization_id: context.tenantId } : undefined; + }; + const service = new AnalyticsService({ + cubes: [compiled.cube], + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async (_o, sql, params) => { captured.push({ sql, params }); return []; }, + getReadScope: asyncReadScope, + getAllowedRelationships: () => compiled.allowedRelationships, + }); + await new DatasetExecutor(service).execute( + compiled, + { dimensions: ['region'], measures: ['revenue'] }, + ctx('org_async'), + ); + expect(captured[0].sql).toMatch(/"opportunity"\."organization_id" = \$\d+/); + expect(captured[0].sql).toMatch(/"account"\."organization_id" = \$\d+/); + expect(captured[0].params.filter((p) => p === 'org_async')).toHaveLength(2); + }); + + it('fail-closed: an async provider that REJECTS denies the whole query (no unscoped SQL)', async () => { + const captured: { sql: string; params: unknown[] }[] = []; + const compiled = compileDataset(dataset); + const service = new AnalyticsService({ + cubes: [compiled.cube], + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async (_o, sql, params) => { captured.push({ sql, params }); return []; }, + getReadScope: async () => { throw new Error('security service unavailable'); }, + getAllowedRelationships: () => compiled.allowedRelationships, + }); + await expect( + new DatasetExecutor(service).execute(compiled, { dimensions: ['region'], measures: ['revenue'] }, ctx('org_A')), + ).rejects.toThrow(/fail-closed/); + // Crucially, no SQL was emitted — we denied before building/executing it. + expect(captured).toHaveLength(0); + }); + it('rejects the join when the relationship is not declared (defense in depth)', async () => { const captured: { sql: string; params: unknown[] }[] = []; const compiled = compileDataset(dataset); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 7beb11adc..21a15d805 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -63,8 +63,20 @@ export interface AnalyticsServiceConfig { * object (exactly what `RLSCompiler` emits). The service binds the active * context per query and the strategy compiles the filter into alias-qualified * SQL injected into every base and joined table. + * + * MAY be async: the production bridge resolves RLS from the `security` + * service's `getReadFilter`, which can hit the database. The service + * pre-resolves the scope for every base + joined object of a query (before + * the synchronous SQL builder runs), so a sync return still works unchanged. */ - getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined; + getReadScope?: ( + objectName: string, + context?: ExecutionContext, + ) => + | FilterCondition + | null + | undefined + | Promise; /** * ADR-0021 D-C — join allowlist per cube (the dataset's declared `include`). * Joins outside this set are rejected by the strategy. Compiled datasets @@ -188,14 +200,76 @@ export class AnalyticsService implements IAnalyticsService { * current request's ExecutionContext (ADR-0021 D-C). The strategy then sees a * `getReadScope(objectName)` that already knows the active tenant. */ - private callCtx(context?: ExecutionContext): StrategyContext { + private async callCtx( + query: AnalyticsQuery, + context?: ExecutionContext, + ): Promise { if (!this.readScopeProvider) return this.baseCtx; + // Pre-resolve the read scope for every object the strategy will scan (base + // + all declared joins) BEFORE the synchronous SQL builder runs, since the + // provider may be async (the production `security.getReadFilter` bridge). + // The strategy then reads each object's filter synchronously from the map. + const scopes = await this.resolveReadScopes(query, context); return { ...this.baseCtx, - getReadScope: (objectName: string) => this.readScopeProvider!(objectName, context), + getReadScope: (objectName: string) => scopes.get(objectName) ?? null, }; } + /** + * Resolve the read scope (tenant + RLS `FilterCondition`) for the base object + * AND every joined object of the query's cube, keyed by object name. This is + * the async pre-pass that lets the synchronous strategy enforce scoping even + * when the provider (security `getReadFilter`) resolves asynchronously. + * + * The object set is `cube.sql` (base) plus every `cube.joins[*].name` — a + * SUPERSET of what the strategy actually scans (the strategy only joins along + * declared relationships), so no scanned object is ever left unscoped. + * + * Fail-closed: if the provider throws for an object, the whole query is + * rejected rather than emitting SQL with that object unscoped. + */ + private async resolveReadScopes( + query: AnalyticsQuery, + context?: ExecutionContext, + ): Promise> { + const map = new Map(); + const provider = this.readScopeProvider; + if (!provider || !query.cube) return map; + const cube = this.cubeRegistry.get(query.cube); + if (!cube) return map; + + const objects = new Set(); + if (typeof cube.sql === 'string' && cube.sql.trim()) { + objects.add(cube.sql.trim()); + } + const joins = (cube as { joins?: Record }).joins; + if (joins) { + for (const [alias, j] of Object.entries(joins)) { + objects.add(j?.name ?? alias); + } + } + + for (const object of objects) { + let filter: FilterCondition | null | undefined; + try { + filter = await provider(object, context); + } catch (e) { + // Deny the entire query — never fall through to unscoped SQL. + this.logger.error?.( + `[Analytics] read-scope resolution failed for object "${object}" — ` + + `rejecting query (fail-closed, ADR-0021 D-C)`, + e instanceof Error ? e : new Error(String(e)), + ); + throw new Error( + `[Analytics] read-scope resolution failed for "${object}"; query denied (fail-closed).`, + ); + } + if (filter != null) map.set(object, filter); + } + return map; + } + /** * Execute an analytical query by delegating to the first capable strategy. */ @@ -205,7 +279,7 @@ export class AnalyticsService implements IAnalyticsService { } this.ensureCube(query); - const ctx = this.callCtx(context); + const ctx = await this.callCtx(query, context); const strategy = this.resolveStrategy(query, ctx); this.logger.debug(`[Analytics] Query on cube "${query.cube}" → ${strategy.name}`); @@ -274,7 +348,7 @@ export class AnalyticsService implements IAnalyticsService { } this.ensureCube(query); - const ctx = this.callCtx(context); + const ctx = await this.callCtx(query, context); const strategy = this.resolveStrategy(query, ctx); this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" → ${strategy.name}`); diff --git a/packages/services/service-analytics/src/plugin.ts b/packages/services/service-analytics/src/plugin.ts index 42ed0960a..8a98f0a41 100644 --- a/packages/services/service-analytics/src/plugin.ts +++ b/packages/services/service-analytics/src/plugin.ts @@ -57,7 +57,14 @@ export interface AnalyticsServicePluginOptions { * emits). When omitted, the plugin auto-bridges to a registered `'security'` * service exposing `getReadFilter(object, context)` if one is present. */ - getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined; + getReadScope?: ( + objectName: string, + context?: ExecutionContext, + ) => + | FilterCondition + | null + | undefined + | Promise; /** * ADR-0021 D-C — join allowlist per cube (the dataset's declared `include`). * Typically wired from the dataset registry's compiled `allowedRelationships`. @@ -221,7 +228,14 @@ export class AnalyticsServicePlugin implements Plugin { // `getReadFilter(object, context)` (resolved at call time so plugin-init // order does not matter). This keeps analytics decoupled from security. interface SecurityReadFilter { - getReadFilter(object: string, context?: ExecutionContext): FilterCondition | null | undefined; + getReadFilter( + object: string, + context?: ExecutionContext, + ): + | FilterCondition + | null + | undefined + | Promise; } let getReadScope = this.options.getReadScope; let autoBridgedReadScope = false;