diff --git a/.changeset/c2-beta-explain-record-grained.md b/.changeset/c2-beta-explain-record-grained.md new file mode 100644 index 0000000000..c0c831242f --- /dev/null +++ b/.changeset/c2-beta-explain-record-grained.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-security': minor +'@objectstack/rest': patch +--- + +feat(plugin-security): C2-β — explain 引擎 record 粒度行级归因 (#2920) + +`explain(principal, object, operation, recordId?)` 现支持记录级解释。透传 `recordId` 时,引擎在对象级流水线之上叠加**行级归因**,全部复用 enforcement 同一批函数(explained-by-construction): + +- **`tenant_isolation` Layer 0**:作为永远最先的层被 prepend;每层打上 `kernelTier`(`layer_0_tenant` vs `layer_1_business`),可区分「租户墙挡的」还是「业务 RLS 挡的」。 +- **每层 `record` 归因**(tenant / owd_baseline / sharing / rls):`outcome`(admitted/excluded/not_evaluated)、有效 `rowFilter`、`matchesRecord`(用 `@objectstack/formula` 的 `matchesFilterCondition` 对同一条 FilterCondition 求值)、命中的 `rules[]`(tenant_filter/owd_baseline/ownership/record_share/sharing_rule/team/rls_policy,含 grants/via/effect)。 +- **顶层 `record` 判定**:`visible` + `decidedBy` 决定性层。读走复合行过滤匹配,写走 sharing service 的 `canEdit`(均为 enforcement 原语)。 +- **`principal.posture`**:ADR-0095 D2 档位(PLATFORM_ADMIN/TENANT_ADMIN/MEMBER/EXTERNAL)的 B2 stand-in 派生(复用 `resolveAuthzContext` 已投影的 platform_admin / org 角色证据),待 B2 合并后替换。 +- `computeRlsFilter` 重构为 `computeLayeredRlsFilter`(暴露 `{ layer0, layer1 }` 拆分)+ 薄 andCompose 包装,单一代码路径,行级归因不会与执行漂移。 +- REST `security.explain`(GET/POST)接受可选 `recordId`。 + +**向后兼容**:无 `recordId` 的对象级请求输出 **byte-identical**——无 `tenant_isolation` 层、无 `kernelTier`、无 `posture`、无 `record`。 diff --git a/packages/plugins/plugin-security/src/explain-engine.test.ts b/packages/plugins/plugin-security/src/explain-engine.test.ts index 49d7943a3e..4d61a33c9e 100644 --- a/packages/plugins/plugin-security/src/explain-engine.test.ts +++ b/packages/plugins/plugin-security/src/explain-engine.test.ts @@ -170,6 +170,140 @@ describe('explainAccess (ADR-0090 D6)', () => { }); }); +describe('explainAccess — record-grained (C2 / ADR-0095)', () => { + const REC_CTX = { userId: 'u1', tenantId: 'org1', positions: ['sales_rep', 'everyone'], permissions: [] }; + + function recDeps(opts: { + layered?: { layer0: any; layer1: any }; + record?: Record | null; + sharingFilter?: unknown; + shares?: any[]; + canEdit?: boolean; + sets?: any[]; + schema?: any; + } = {}): ExplainEngineDeps { + const base = makeDeps({ sets: opts.sets, schema: opts.schema }); + return { + ...base, + computeLayeredRlsFilter: async () => opts.layered ?? { layer0: null, layer1: null }, + fetchRecord: async () => (opts.record !== undefined ? opts.record : { id: 'r1', organization_id: 'org1', owner_id: 'u1' }), + sharingReadFilter: async () => (opts.sharingFilter !== undefined ? opts.sharingFilter : null), + listRecordShares: async () => opts.shares ?? [], + canEditRecord: async () => opts.canEdit ?? false, + }; + } + + it('leaves the object-level report byte-identical when no recordId is supplied (backward compat)', async () => { + const d = await explainAccess(recDeps(), { object: 'leave_request', operation: 'read', context: REC_CTX }); + expect(d.record).toBeUndefined(); + expect(d.principal).not.toHaveProperty('posture'); + expect(d.layers.map((l) => l.layer)).toEqual([ + 'principal', 'required_permissions', 'object_crud', 'fls', + 'owd_baseline', 'depth', 'sharing', 'vama_bypass', 'rls', + ]); + expect(d.layers.every((l) => l.kernelTier === undefined)).toBe(true); + expect(d.layers.every((l) => l.record === undefined)).toBe(true); + }); + + it('prepends the tenant_isolation Layer 0, tags every layer with kernelTier, and resolves posture', async () => { + const d = await explainAccess( + recDeps({ layered: { layer0: { organization_id: 'org1' }, layer1: null }, record: { id: 'r1', organization_id: 'org1', owner_id: 'u1' } }), + { object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' }, + ); + expect(d.layers[0].layer).toBe('tenant_isolation'); + expect(d.layers[0].kernelTier).toBe('layer_0_tenant'); + expect(d.layers.find((l) => l.layer === 'rls')!.kernelTier).toBe('layer_1_business'); + expect(d.principal.posture).toBe('MEMBER'); + expect(d.record).toMatchObject({ recordId: 'r1', visible: true }); + }); + + it('derives PLATFORM_ADMIN posture from the platform_admin position', async () => { + const d = await explainAccess( + recDeps({ sets: [ADMIN], layered: { layer0: null, layer1: null } }), + { object: 'leave_request', operation: 'read', context: { userId: 'a1', tenantId: 'org1', positions: ['platform_admin', 'everyone'], permissions: [] }, recordId: 'r1' }, + ); + expect(d.principal.posture).toBe('PLATFORM_ADMIN'); + }); + + it('Layer 0 (the tenant wall) excludes a cross-org record — decidedBy tenant_isolation', async () => { + const d = await explainAccess( + recDeps({ layered: { layer0: { organization_id: 'org1' }, layer1: null }, record: { id: 'r1', organization_id: 'org2', owner_id: 'u1' } }), + { object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' }, + ); + const tenant = d.layers.find((l) => l.layer === 'tenant_isolation')!; + expect(tenant.record!.outcome).toBe('excluded'); + expect(tenant.record!.rules[0]).toMatchObject({ kind: 'tenant_filter', effect: 'excludes' }); + expect(d.record).toMatchObject({ visible: false, decidedBy: 'tenant_isolation' }); + }); + + it('Layer 1 (business RLS) excludes a non-matching record — decidedBy rls', async () => { + const d = await explainAccess( + recDeps({ layered: { layer0: null, layer1: { status: 'open' } }, record: { id: 'r1', organization_id: 'org1', owner_id: 'u1', status: 'closed' } }), + { object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' }, + ); + const rls = d.layers.find((l) => l.layer === 'rls')!; + expect(rls.record!.outcome).toBe('excluded'); + expect(rls.record!.matchesRecord).toBe(false); + expect(d.record).toMatchObject({ visible: false, decidedBy: 'rls' }); + }); + + it('a record_share admits a non-owner on a private object — sharing admits, decidedBy sharing', async () => { + const d = await explainAccess( + recDeps({ + layered: { layer0: null, layer1: null }, + record: { id: 'r1', organization_id: 'org1', owner_id: 'u_other' }, + shares: [{ id: 'shr_1', recipient_type: 'user', recipient_id: 'u1', access_level: 'read', source: 'manual' }], + sharingFilter: { $or: [{ owner_id: 'u1' }, { id: { $in: ['r1'] } }] }, + }), + { object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' }, + ); + const sharing = d.layers.find((l) => l.layer === 'sharing')!; + expect(sharing.record!.outcome).toBe('admitted'); + expect(sharing.record!.rules[0]).toMatchObject({ kind: 'record_share', effect: 'admits', grants: 'read' }); + expect(d.record).toMatchObject({ visible: true, decidedBy: 'sharing' }); + }); + + it('private object, non-owner, no admitting share — not visible, decidedBy sharing', async () => { + const d = await explainAccess( + recDeps({ layered: { layer0: null, layer1: null }, record: { id: 'r1', organization_id: 'org1', owner_id: 'u_other' }, shares: [], sharingFilter: { owner_id: 'u1' } }), + { object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' }, + ); + expect(d.layers.find((l) => l.layer === 'sharing')!.record!.outcome).toBe('excluded'); + expect(d.record).toMatchObject({ visible: false, decidedBy: 'sharing' }); + }); + + it('a missing record yields not_evaluated row layers and an invisible verdict', async () => { + const d = await explainAccess( + recDeps({ record: null }), + { object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'missing' }, + ); + expect(d.record).toMatchObject({ visible: false }); + expect(d.record!.decidedBy).toBeUndefined(); + expect(d.layers.find((l) => l.layer === 'rls')!.record!.outcome).toBe('not_evaluated'); + expect(d.layers.find((l) => l.layer === 'tenant_isolation')!.record!.outcome).toBe('not_evaluated'); + }); + + it('write ops use the sharing service canEdit as the by-construction verdict', async () => { + const editor = PermissionSetSchema.parse({ name: 'editor', objects: { leave_request: { allowRead: true, allowEdit: true } } }); + const d = await explainAccess( + recDeps({ sets: [editor], layered: { layer0: null, layer1: null }, record: { id: 'r1', organization_id: 'org1', owner_id: 'u_other' }, canEdit: true, shares: [] }), + { object: 'leave_request', operation: 'update', context: REC_CTX, recordId: 'r1' }, + ); + expect(d.layers.find((l) => l.layer === 'sharing')!.record!.outcome).toBe('admitted'); + expect(d.record).toMatchObject({ recordId: 'r1', visible: true }); + }); + + it('degrades gracefully with no record-grained deps — object-level layers plus a best-effort verdict', async () => { + // Only the base object-level deps: recordId is given but fetchRecord / + // computeLayeredRlsFilter etc. are absent (e.g. no plugin-sharing). + const d = await explainAccess(makeDeps(), { object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' }); + expect(d.layers[0].layer).toBe('tenant_isolation'); + expect(d.record).toMatchObject({ recordId: 'r1' }); + // record could not be fetched → not_evaluated tenant + invisible. + expect(d.layers.find((l) => l.layer === 'tenant_isolation')!.record!.outcome).toBe('not_evaluated'); + }); +}); + describe('buildContextForUser', () => { const ql = { async find(object: string, opts: any) { diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index 8d170bd64a..593a0d7bc9 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -19,17 +19,68 @@ * the SEMANTIC impact of a grant change instead of a JSON diff. */ -import { isGrantActive, isGrantExpired } from '@objectstack/core'; +import { isGrantActive, isGrantExpired, derivePosture as deriveAdminPosture } from '@objectstack/core'; +import { matchesFilterCondition } from '@objectstack/formula'; +import { BUILTIN_IDENTITY_PLATFORM_ADMIN, ADMIN_FULL_ACCESS, ORGANIZATION_ADMIN } from '@objectstack/spec'; import type { PermissionSet } from '@objectstack/spec/security'; import type { + AuthzPosture, ExplainDecision, ExplainLayer, + ExplainMatchedRule, ExplainOperation, + ExplainRecordAttribution, } from '@objectstack/spec/security'; import type { PermissionEvaluator } from './permission-evaluator.js'; const SYSTEM_CTX = { isSystem: true } as const; +/** Owner field convention — mirrors plugin-sharing's `OWNER_FIELD` (single-owner MVP). */ +const OWNER_FIELD = 'owner_id'; + +/** + * [C2 / ADR-0095 D1] Which kernel tier a pipeline layer belongs to: the + * always-first tenant wall (`layer_0_tenant`) vs. everything downstream of it + * (business RLS / sharing / ownership / the capability gates), all + * `layer_1_business`. The binary mirrors the enforcement split — Layer 0 has its + * own code path and is AND-composed BEFORE any business rule. + */ +function kernelTierOf(layer: ExplainLayer['layer']): 'layer_0_tenant' | 'layer_1_business' { + return layer === 'tenant_isolation' ? 'layer_0_tenant' : 'layer_1_business'; +} + +/** + * [C2 / ADR-0095 D2/D3] Resolve the principal's posture rung. The + * PLATFORM_ADMIN / TENANT_ADMIN / MEMBER tiers delegate to the single core + * derivation ({@link deriveAdminPosture}, `@objectstack/core`) so the admin-tier + * logic has ONE source of truth (B4/D3 — derived from held capability grants, + * never a better-auth role, closing the #2836 dual-track by construction). + * + * Explain layers one thing on top the enforcement resolver deliberately does + * NOT: an anonymous / guest principal is represented as EXTERNAL for the + * debugger. The enforcement posture resolver's floor is MEMBER (no external + * principal type exists yet — ADR-0095 D2), so this guest→EXTERNAL mapping lives + * here, in the explanation surface, rather than in `resolveAuthzContext`. + */ +function derivePosture(context: any): AuthzPosture { + const positions: string[] = Array.isArray(context?.positions) ? context.positions : []; + const permissions: string[] = Array.isArray(context?.permissions) ? context.permissions : []; + if (!context?.userId || context?.principalKind === 'guest') return 'EXTERNAL'; + return deriveAdminPosture({ + isPlatformAdmin: + positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN) || permissions.includes(ADMIN_FULL_ACCESS), + isTenantAdmin: + positions.includes('org_owner') || + positions.includes('org_admin') || + permissions.includes(ORGANIZATION_ADMIN), + }); +} + +/** True iff a composed filter is the zero-rows deny sentinel. */ +function isDenyAll(filter: unknown): boolean { + return !!filter && typeof filter === 'object' && (filter as any).id === '__deny_all__'; +} + /** Explain-operation → engine-operation (the middleware's vocabulary). */ const EXPLAIN_TO_ENGINE_OP: Record = { read: 'find', @@ -68,6 +119,36 @@ export interface ExplainEngineDeps { ) => Record; /** Configured additive baseline set name (default member_default), for attribution. */ fallbackPermissionSet: string | null; + + // ── [C2 / ADR-0095] Record-grained deps. All OPTIONAL: absent → the engine + // stays object-level and byte-compatible. Present → the sharing / rls / owd / + // tenant_isolation layers gain per-record attribution. Every one reuses an + // enforcement code path so the row story cannot drift from execution. ── + + /** + * The middleware's RLS composition, SPLIT into its two independent kernel + * layers (the same `Layer0(tenant) AND Layer1(business)` the effective filter + * is built from). Lets the tenant wall (Layer 0) and business RLS (Layer 1) be + * attributed to a record separately. `undefined` return = engine cannot split. + */ + computeLayeredRlsFilter?: ( + sets: PermissionSet[], + object: string, + engineOperation: string, + context: any, + ) => Promise<{ layer0: Record | null; layer1: Record | null }>; + /** Fetch the one record under a system context (organization_id / owner_id + fields for filter matching). */ + fetchRecord?: (object: string, recordId: string, engineOperation: string) => Promise | null>; + /** The sharing service's own read-filter contribution for the object (owner-match OR granted-ids), same as enforcement AND-s in. */ + sharingReadFilter?: (object: string, context: any) => Promise; + /** The concrete `sys_record_share` rows attached to the record (for `rules[]` attribution). */ + listRecordShares?: ( + object: string, + recordId: string, + context: any, + ) => Promise>; + /** The sharing service's per-record write gate (`canEdit`) — the by-construction verdict for write operations. */ + canEditRecord?: (object: string, recordId: string, context: any) => Promise; } export interface ExplainInput { @@ -75,6 +156,15 @@ export interface ExplainInput { operation: ExplainOperation; /** Execution context of the principal being EXPLAINED (not the caller). */ context: any; + /** + * [C2 / ADR-0095] Optional id of ONE concrete record to explain at row + * granularity. Omitted → object-level (the report is byte-identical to the + * pre-C2 engine). Present → the row-scoped layers gain a `record` attribution, + * the tenant wall surfaces as the `tenant_isolation` Layer 0, every layer is + * tagged with its `kernelTier`, the principal's `posture` is resolved, and the + * decision carries a top-level `record` verdict. + */ + recordId?: string; } /** @@ -269,6 +359,266 @@ function describeOwd(schema: any): { model: string; declared: boolean; effect: ' return { model: `${String(m)} (unknown → private, fail-closed)`, declared: true, effect: 'private' }; } +/** + * [C2 / ADR-0095] Inputs the record-grained augmentation needs from the already + * computed object-level pass — the row story is decomposed FROM the same facts, + * never re-judged. + */ +interface RecordAttributionContext { + deps: ExplainEngineDeps; + object: string; + recordId: string; + engineOp: string; + context: any; + sets: PermissionSet[]; + layers: ExplainLayer[]; + owd: { model: string; effect: 'private' | 'read' | 'public' }; + capsDeny: boolean; + crudAllowed: boolean; +} + +/** + * [C2 / ADR-0095] Fill the per-record row story onto the pipeline. Prepends the + * `tenant_isolation` Layer 0, tags every layer with its `kernelTier`, attaches a + * `record` attribution to the four row-scoped layers (tenant / owd / sharing / + * rls), and returns the top-level `record` verdict. Every row-level judgement is + * evaluated with the SAME artifacts enforcement produces: + * - Layer 0 / Layer 1 filters come from `computeLayeredRlsFilter` (the middleware's + * own `Layer0(tenant) AND Layer1(business)` split); + * - "does THIS record satisfy the filter?" is `matchesFilterCondition` — the + * third canonical backend for the same FilterCondition shape the query runs; + * - the write verdict is the sharing service's own `canEdit`. + * So the record story is explained by construction, exactly like the object-level pass. + */ +async function applyRecordAttribution( + ra: RecordAttributionContext, +): Promise<{ record: NonNullable; posture: AuthzPosture }> { + const { deps, object, recordId, engineOp, context, sets, layers, owd, capsDeny, crudAllowed } = ra; + const isRead = engineOp === 'find'; + const posture = derivePosture(context); + + const record = deps.fetchRecord + ? await deps.fetchRecord(object, recordId, engineOp).catch(() => null) + : null; + const recordExists = record != null; + const matches = (filter: unknown): boolean | undefined => { + if (!recordExists) return undefined; + if (filter == null) return true; + return matchesFilterCondition(record as Record, filter as any); + }; + + const layered = deps.computeLayeredRlsFilter + ? await deps.computeLayeredRlsFilter(sets, object, engineOp, context).catch(() => ({ layer0: null, layer1: null })) + : undefined; + const layer0 = layered?.layer0; + const layer1 = layered?.layer1; + + // ── Layer 0: tenant_isolation (prepended as the always-first layer) ────── + let tenantRecord: ExplainRecordAttribution; + if (!recordExists) { + tenantRecord = { outcome: 'not_evaluated', rules: [], detail: 'Record not found under a system read — filtered, deleted, or never existed.' }; + } else if (layered === undefined) { + tenantRecord = { outcome: 'not_evaluated', rules: [], detail: 'Tenant layer split is unavailable on this engine build.' }; + } else if (layer0 == null) { + tenantRecord = { + outcome: 'not_evaluated', rowFilter: null, rules: [], + detail: 'Layer 0 contributes nothing here — single-tenant mode, a non-tenant object, or a platform-admin crossing the wall (ADR-0095 D1).', + }; + } else { + const deny = isDenyAll(layer0); + const m = matches(layer0); + const excluded = deny || m === false; + tenantRecord = { + outcome: excluded ? 'excluded' : 'admitted', + rowFilter: layer0, + matchesRecord: deny ? false : m, + rules: [{ + kind: 'tenant_filter', + name: 'organization_isolation', + predicate: layer0, + via: context?.tenantId ? `organization ${context.tenantId}` : 'organization wall', + effect: excluded ? 'excludes' : 'admits', + }], + detail: deny + ? 'No active organization on the context — the tenant wall denies all rows (fail closed).' + : excluded + ? `Record's organization does not match the caller's active organization (${context?.tenantId ?? 'none'}).` + : `Record is inside the caller's active organization (${context?.tenantId ?? 'none'}).`, + }; + } + layers.unshift({ + layer: 'tenant_isolation', + kernelTier: 'layer_0_tenant', + verdict: tenantRecord.outcome === 'excluded' ? 'denies' : layer0 ? 'narrows' : 'not_applicable', + detail: 'Layer 0 tenant isolation — the always-first org wall, AND-composed before any business RLS (ADR-0095 D1).', + contributors: [], + record: tenantRecord, + }); + + // ── owd_baseline: ownership + the record baseline's own contribution ───── + const ownerRaw = recordExists ? (record as Record)[OWNER_FIELD] : undefined; + const hasOwnerField = recordExists && OWNER_FIELD in (record as Record); + const ownerIsMe = ownerRaw != null && context?.userId != null && String(ownerRaw) === String(context.userId); + const owdLayer = layers.find((l) => l.layer === 'owd_baseline'); + if (owdLayer) { + const owdRules: ExplainMatchedRule[] = [{ + kind: 'owd_baseline', + name: owd.model, + effect: owd.effect === 'private' ? (ownerIsMe ? 'admits' : 'excludes') : 'admits', + via: `OWD ${owd.model}`, + }]; + if (hasOwnerField) { + owdRules.push({ + kind: 'ownership', + name: OWNER_FIELD, + via: ownerIsMe ? 'owner' : `owned by ${ownerRaw ?? 'nobody'}`, + effect: ownerIsMe ? 'admits' : 'neutral', + }); + } + owdLayer.record = !recordExists + ? { outcome: 'not_evaluated', rules: [], detail: 'Record not found; baseline not evaluated.' } + : { + outcome: owd.effect === 'private' ? (ownerIsMe ? 'admitted' : 'excluded') : 'admitted', + matchesRecord: owd.effect === 'private' ? ownerIsMe : true, + rules: owdRules, + detail: owd.effect === 'private' + ? (ownerIsMe + ? 'Caller owns the record — the private baseline admits it.' + : 'Private baseline admits only the owner; a share or sharing rule may still widen access (see the sharing layer).') + : `Baseline ${owd.model} admits the record at the row level; capability and field layers still apply.`, + }; + } + + // ── sharing: the concrete shares + the sharing service's filter/gate ───── + const shares = deps.listRecordShares && recordExists + ? await deps.listRecordShares(object, recordId, context).catch(() => []) + : []; + const shareRules: ExplainMatchedRule[] = (shares ?? []).map((s) => { + const recipMatchesUser = s.recipient_type === 'user' && s.recipient_id != null && String(s.recipient_id) === String(context?.userId); + const kind: ExplainMatchedRule['kind'] = + s.source === 'rule' ? 'sharing_rule' : s.source === 'team' ? 'team' : 'record_share'; + return { + kind, + name: String(s.source_id || s.id || 'share'), + grants: s.access_level, + via: `${s.recipient_type ?? 'user'}:${s.recipient_id ?? '?'}`, + // A group/role/unit recipient can't be confirmed against the principal + // without expansion → reported `neutral` (evaluated, effect unknown here). + effect: recipMatchesUser ? 'admits' : 'neutral', + }; + }); + const sharingFilter = deps.sharingReadFilter && recordExists + ? await deps.sharingReadFilter(object, context).catch(() => null) + : undefined; + const sharingMatches = sharingFilter === undefined ? undefined : matches(sharingFilter); + // Write ops: the by-construction verdict is the sharing service's own canEdit. + const canEdit = !isRead && deps.canEditRecord && recordExists + ? await deps.canEditRecord(object, recordId, context).catch(() => undefined) + : undefined; + const anyShareAdmits = shareRules.some((r) => r.effect === 'admits'); + let sharingOutcome: ExplainRecordAttribution['outcome']; + if (!recordExists) { + sharingOutcome = 'not_evaluated'; + } else if (owd.effect !== 'private') { + sharingOutcome = 'not_evaluated'; // baseline already grants the rows sharing would add + } else if (canEdit !== undefined) { + sharingOutcome = canEdit ? 'admitted' : 'excluded'; + } else if (ownerIsMe || anyShareAdmits || sharingMatches === true) { + sharingOutcome = 'admitted'; + } else if (sharingFilter === undefined && shares.length === 0) { + sharingOutcome = 'not_evaluated'; + } else { + sharingOutcome = 'excluded'; + } + const sharingLayer = layers.find((l) => l.layer === 'sharing'); + if (sharingLayer) { + sharingLayer.record = { + outcome: sharingOutcome, + rowFilter: sharingFilter === undefined ? undefined : sharingFilter, + matchesRecord: sharingMatches, + rules: shareRules, + detail: !recordExists + ? 'Record not found; sharing not evaluated.' + : owd.effect !== 'private' + ? 'Baseline is not private — sharing adds nothing beyond it for this record.' + : canEdit !== undefined + ? (canEdit + ? 'The sharing service grants write on this record (ownership or an edit/full share).' + : 'No ownership and no edit/full share grants write on this record.') + : sharingOutcome === 'admitted' + ? (ownerIsMe ? 'Caller owns the record — visible without a share.' : `${shareRules.length} share(s) attached; access is granted for this record.`) + : `${shareRules.length} share(s) attached; none grants the caller access to this record.`, + }; + } + + // ── rls: the business (Layer 1) predicate for this record ──────────────── + const rlsLayer = layers.find((l) => l.layer === 'rls'); + if (rlsLayer) { + if (!recordExists) { + rlsLayer.record = { outcome: 'not_evaluated', rules: [], detail: 'Record not found; business RLS not evaluated.' }; + } else if (layered === undefined) { + rlsLayer.record = { outcome: 'not_evaluated', rules: [], detail: 'Business RLS split is unavailable on this engine build.' }; + } else if (layer1 == null) { + rlsLayer.record = { outcome: 'not_evaluated', rowFilter: null, rules: [], detail: 'No business RLS policy applies to this record.' }; + } else { + const deny = isDenyAll(layer1); + const m = matches(layer1); + const excluded = deny || m === false; + rlsLayer.record = { + outcome: excluded ? 'excluded' : 'admitted', + rowFilter: layer1, + matchesRecord: deny ? false : m, + rules: [{ kind: 'rls_policy', name: 'business_rls', predicate: layer1, effect: excluded ? 'excludes' : 'admits' }], + detail: deny + ? 'Business RLS composes to DENY ALL for this principal.' + : excluded + ? 'The record does not satisfy the business row-level predicate.' + : 'The record satisfies the business row-level predicate.', + }; + } + } + + // ── kernelTier on every layer + posture resolution ─────────────────────── + for (const l of layers) { + if (!l.kernelTier) l.kernelTier = kernelTierOf(l.layer); + } + + // ── decision.record: bottom line + decisive layer ──────────────────────── + const tenantExcluded = tenantRecord.outcome === 'excluded'; + const rlsExcluded = rlsLayer?.record?.outcome === 'excluded'; + const businessRowAdmits = isRead + ? owd.effect !== 'private' || ownerIsMe || sharingOutcome === 'admitted' + : canEdit !== undefined + ? canEdit + : owd.effect === 'public' || ownerIsMe || sharingOutcome === 'admitted'; + + let visible: boolean; + let decidedBy: NonNullable['decidedBy']; + if (capsDeny) { visible = false; decidedBy = 'required_permissions'; } + else if (!crudAllowed) { visible = false; decidedBy = 'object_crud'; } + else if (!recordExists) { visible = false; decidedBy = undefined; } + else if (tenantExcluded) { visible = false; decidedBy = 'tenant_isolation'; } + else if (rlsExcluded) { visible = false; decidedBy = 'rls'; } + else if (!businessRowAdmits) { visible = false; decidedBy = owd.effect === 'private' ? 'sharing' : 'owd_baseline'; } + else { + visible = true; + decidedBy = (owd.effect === 'private' && !ownerIsMe && sharingOutcome === 'admitted') + ? 'sharing' + : layer1 != null + ? 'rls' + : owd.effect === 'private' && ownerIsMe + ? 'owd_baseline' + : layer0 != null + ? 'tenant_isolation' + : 'object_crud'; + } + + return { + record: { recordId, visible, ...(decidedBy ? { decidedBy } : {}) }, + posture, + }; +} + export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput): Promise { const { object, operation, context } = input; const engineOp = EXPLAIN_TO_ENGINE_OP[operation]; @@ -546,6 +896,19 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput const allowed = !capsDeny && crudAllowed && !denyAll && !delegatorMissing; + // ── [C2 / ADR-0095] Record-grained augmentation ───────────────────────── + // Object-level (no recordId) is left BYTE-IDENTICAL: no tenant_isolation + // layer, no kernelTier, no posture, no per-layer/decision `record`. + let recordVerdict: ExplainDecision['record'] | undefined; + let posture: AuthzPosture | undefined; + if (input.recordId) { + const out = await applyRecordAttribution({ + deps, object, recordId: input.recordId, engineOp, context, sets, layers, owd, capsDeny, crudAllowed, + }); + recordVerdict = out.record; + posture = out.posture; + } + const decision: ExplainDecision = { allowed, object, @@ -556,9 +919,11 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput permissionSets: setNames, ...(context?.principalKind ? { principalKind: context.principalKind } : {}), ...(context?.onBehalfOf?.userId ? { onBehalfOf: { userId: context.onBehalfOf.userId } } : {}), + ...(posture ? { posture } : {}), }, layers, ...(operation === 'read' ? { readFilter: readFilter ?? null } : {}), + ...(recordVerdict ? { record: recordVerdict } : {}), }; return decision; } diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 8e6c44410f..8f0e62991b 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -240,6 +240,13 @@ export class SecurityPlugin implements Plugin { private ql: any = null; /** [ADR-0090 D12] Delegated-admin write gate — wired in start() once `ql` exists. */ private delegatedAdminGate: DelegatedAdminGate | null = null; + /** + * [C2 / ADR-0095] Lazy kernel-service resolver, captured in start(). Used by the + * record-grained explain path to reach the optional `sharing` service (a + * deployment without plugin-sharing simply resolves `undefined`). Late-bound so + * plugin load order does not matter. + */ + private resolveKernelService: ((name: string) => any) | null = null; /** Unsubscribe handle for metadata-change cache invalidation (runtime metadata edits). */ private metadataWatch: { unsubscribe: () => void } | null = null; /** ADR-0055: cache the resolved master-detail relation per controlled_by_parent object. */ @@ -357,6 +364,10 @@ export class SecurityPlugin implements Plugin { this.ql = ql; this.logger = ctx.logger; this.rlsCompiler.setLogger?.(ctx.logger); + // [C2 / ADR-0095] Late-bound resolver for the optional `sharing` service. + this.resolveKernelService = (name: string) => { + try { return ctx.getService(name); } catch { return undefined; } + }; // Invalidate metadata-derived caches when object/field metadata changes // at runtime (Studio / AI authoring). Without this they go stale until @@ -1471,12 +1482,13 @@ export class SecurityPlugin implements Plugin { * the middleware uses. */ async explainAccessForCaller( - request: { object: string; operation: string; userId?: string }, + request: { object: string; operation: string; userId?: string; recordId?: string }, callerContext?: any, ): Promise { const operation = String(request?.operation ?? 'read') as ExplainOperation; const object = String(request?.object ?? ''); if (!object) throw new Error('[Security] explain: request.object is required'); + const recordId = request?.recordId != null && request.recordId !== '' ? String(request.recordId) : undefined; let targetContext = callerContext ?? {}; if (request.userId && request.userId !== callerContext?.userId) { @@ -1504,6 +1516,11 @@ export class SecurityPlugin implements Plugin { targetContext = await buildContextForUser(this.ql, request.userId); } + // [C2 / ADR-0095] The optional `sharing` service backs the record-grained + // sharing attribution. Resolved lazily; absent (no plugin-sharing) → the + // record path still works, the sharing layer simply reports not_evaluated. + const sharing = recordId ? (this.resolveKernelService?.('sharing') as any) : undefined; + return explainAccess( { ql: this.ql, @@ -1518,8 +1535,31 @@ export class SecurityPlugin implements Plugin { return fp as any; }, fallbackPermissionSet: this.fallbackPermissionSet, + // ── record-grained deps (only consulted when recordId is present) ── + computeLayeredRlsFilter: (sets, o, engineOp, c) => this.computeLayeredRlsFilter(sets as any, o, engineOp, c), + fetchRecord: async (o: string, rid: string) => { + try { + const finder = this.ql?.findOne + ? this.ql.findOne(o, { where: { id: rid }, context: { isSystem: true } }) + : this.ql?.find?.(o, { where: { id: rid }, limit: 1, context: { isSystem: true } }); + const res = await finder; + const row = Array.isArray(res) ? res[0] : res; + return row ?? null; + } catch { + return null; + } + }, + ...(sharing && typeof sharing.buildReadFilter === 'function' + ? { sharingReadFilter: (o: string, c: any) => sharing.buildReadFilter(o, c) } + : {}), + ...(sharing && typeof sharing.listShares === 'function' + ? { listRecordShares: (o: string, rid: string, c: any) => sharing.listShares(o, rid, c) } + : {}), + ...(sharing && typeof sharing.canEdit === 'function' + ? { canEditRecord: (o: string, rid: string, c: any) => sharing.canEdit(o, rid, c) } + : {}), }, - { object, operation, context: targetContext }, + { object, operation, context: targetContext, recordId }, ); } @@ -1914,6 +1954,23 @@ export class SecurityPlugin implements Plugin { operation: string, context: any, ): Promise | null> { + const { layer0, layer1 } = await this.computeLayeredRlsFilter(permissionSets, object, operation, context); + return andComposeLayers(layer0, layer1); + } + + /** + * [C2 / ADR-0095 D1] The layered RLS split — `{ layer0, layer1 }` BEFORE the + * AND-compose. `computeRlsFilter` is the thin wrapper that composes them; the + * explain engine consumes the split directly so it can attribute the tenant + * wall (Layer 0) and business RLS (Layer 1) to a record SEPARATELY. Single code + * path → the record story cannot drift from the effective filter enforcement uses. + */ + private async computeLayeredRlsFilter( + permissionSets: PermissionSet[], + object: string, + operation: string, + context: any, + ): Promise<{ layer0: Record | null; layer1: Record | null }> { // [ADR-0095 D1] Effective filter = Layer0(tenant) AND Layer1(business RLS). // The two are computed independently and never share a compiler, a merge // step, or a bypass bit (closes W1 by construction, W2 structurally). @@ -1979,7 +2036,7 @@ export class SecurityPlugin implements Plugin { platformAdminBypass, }); - return andComposeLayers(layer0, layer1); + return { layer0, layer1 }; } /** diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 8d0555396d..a47761c765 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -4945,11 +4945,14 @@ export class RestServer { object: src.object, operation: src.operation ?? 'read', ...(src.userId != null && src.userId !== '' ? { userId: src.userId } : {}), + // [C2 / ADR-0095] Optional record id — explains ONE concrete + // row at record granularity; omitted stays object-level. + ...(src.recordId != null && src.recordId !== '' ? { recordId: src.recordId } : {}), }); if (!parsed.success) { return res.status(400).json({ code: 'VALIDATION_FAILED', - message: 'Invalid explain request — expected { object: string, operation: read|create|update|delete|transfer|restore|purge, userId?: string }.', + message: 'Invalid explain request — expected { object: string, operation: read|create|update|delete|transfer|restore|purge, userId?: string, recordId?: string }.', detail: String(parsed.error?.message ?? '').slice(0, 1000), }); }