From 1ac718679d389eb6ab2a78c01d3b183c76dfc759 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 00:39:35 +0000 Subject: [PATCH] =?UTF-8?q?feat(app-shell):=20C2-=CE=B2=20=E2=80=94=20Acce?= =?UTF-8?q?ssExplainPanel=20record=20=E7=B2=92=E5=BA=A6=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=20(framework#2920)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AccessExplainPanel 支持记录级解释(ADR-0095): - 记录选择器(输入/RecordPickerDialog),请求带 recordId - 逐层行级归因:outcome 徽标、命中 rules[](权限集→岗位→共享→行规则,含三态圆点)、 有效行过滤 rowFilter、matchesRecord - 顶部 record.visible 结论横幅 + decidedBy 决定性层 - posture 档位徽标 + 每层 kernelTier(租户墙 vs 业务 RLS)标签 - i18n en + zh-CN 全量 key 不带 recordId 时行为与对象级完全一致(向后兼容)。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs --- .changeset/c2-beta-explain-record-grained.md | 15 + .../AccessExplainPanel.test.tsx | 58 ++++ .../metadata-admin/AccessExplainPanel.tsx | 281 ++++++++++++++++-- .../src/views/metadata-admin/i18n.ts | 66 ++++ 4 files changed, 400 insertions(+), 20 deletions(-) create mode 100644 .changeset/c2-beta-explain-record-grained.md diff --git a/.changeset/c2-beta-explain-record-grained.md b/.changeset/c2-beta-explain-record-grained.md new file mode 100644 index 000000000..a11abf046 --- /dev/null +++ b/.changeset/c2-beta-explain-record-grained.md @@ -0,0 +1,15 @@ +--- +"@object-ui/app-shell": minor +--- + +feat(app-shell): C2-β — AccessExplainPanel record 粒度渲染 (framework#2920) + +AccessExplainPanel 现支持记录级解释(ADR-0095): + +- **记录选择器**:选定对象后可输入或从 RecordPickerDialog 选择一条 `recordId`;请求带上 `recordId`。 +- **逐层行级归因**:每层展开该记录的 `record` 归因——outcome 徽标(准入/排除/未评估)、命中的 `rules[]`(权限集 → 岗位 → 共享 → 行规则,含 kind/grants/via/effect 三态圆点)、有效行过滤(rowFilter JSON)、matchesRecord。 +- **顶部记录判定**:`record.visible` 结论横幅 + `decidedBy` 决定性层(该记录为何可见/不可见)。 +- **posture / kernelTier**:principal 卡片显示 posture 档位徽标;每层显示 kernel tier(租户墙 vs 业务 RLS)标签。 +- i18n:en + zh-CN 全量 key。 + +**向后兼容**:不带 `recordId` 时行为与对象级完全一致。 diff --git a/packages/app-shell/src/views/metadata-admin/AccessExplainPanel.test.tsx b/packages/app-shell/src/views/metadata-admin/AccessExplainPanel.test.tsx index ae2a42e33..4de2ee730 100644 --- a/packages/app-shell/src/views/metadata-admin/AccessExplainPanel.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/AccessExplainPanel.test.tsx @@ -114,6 +114,64 @@ describe('AccessExplainPanel (ADR-0090 D6)', () => { expect(screen.getByTestId('explain-layer-object_crud').textContent).toMatch(/denies/i); }); + it('sends recordId and renders the record-grained row story (C2 / ADR-0095)', async () => { + const RECORD_DECISION: ExplainDecision = { + allowed: true, + object: 'crm_lead', + operation: 'read', + principal: { userId: 'u_1', positions: ['sales_rep', 'everyone'], permissionSets: ['sales_user'], posture: 'MEMBER' }, + layers: [ + { + layer: 'tenant_isolation', + kernelTier: 'layer_0_tenant', + verdict: 'narrows', + detail: 'Layer 0 tenant isolation.', + record: { + outcome: 'admitted', + rowFilter: { organization_id: 'org1' }, + matchesRecord: true, + rules: [{ kind: 'tenant_filter', name: 'organization_isolation', effect: 'admits', via: 'organization org1' }], + detail: "Record is inside the caller's active organization (org1).", + }, + }, + { + layer: 'sharing', + kernelTier: 'layer_1_business', + verdict: 'widens', + detail: 'Sharing widens.', + record: { + outcome: 'admitted', + rules: [{ kind: 'record_share', name: 'shr_1', grants: 'read', effect: 'admits', via: 'user:u_1' }], + detail: '1 share attached; access is granted for this record.', + }, + }, + ], + readFilter: { organization_id: 'org1' }, + record: { recordId: 'rec_9', visible: true, decidedBy: 'sharing' }, + }; + fetchSpy.mockResolvedValue(jsonResponse(200, RECORD_DECISION)); + renderPanel(); + fireEvent.change(screen.getByLabelText('Object'), { target: { value: 'crm_lead' } }); + fireEvent.change(screen.getByLabelText(/Record/i), { target: { value: 'rec_9' } }); + fireEvent.click(screen.getByRole('button', { name: /explain$/i })); + + await waitFor(() => expect(screen.getByTestId('explain-record-verdict')).toBeInTheDocument()); + + // request carried recordId + expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toEqual({ object: 'crm_lead', operation: 'read', recordId: 'rec_9' }); + + // top-level record verdict + decidedBy + expect(screen.getByTestId('explain-record-verdict').textContent).toMatch(/VISIBLE/i); + expect(screen.getByTestId('explain-record-verdict').textContent).toMatch(/rec_9/); + + // posture chip + tenant_isolation Layer 0 + per-layer record attribution + expect(screen.getByTestId('explain-posture').textContent).toMatch(/Member/i); + expect(screen.getByTestId('explain-layer-tenant_isolation')).toBeInTheDocument(); + expect(screen.getByTestId('explain-record-tenant_isolation').textContent).toMatch(/admitted/i); + expect(screen.getByTestId('explain-record-sharing').textContent).toMatch(/Record share/i); + expect(screen.getAllByText(/"organization_id": "org1"/).length).toBeGreaterThan(0); + }); + it('renders the friendly D12 message on 403 instead of a raw error', async () => { fetchSpy.mockResolvedValue(jsonResponse(403, { code: 'PERMISSION_DENIED', message: '[Security] Access denied: …' })); renderPanel(); diff --git a/packages/app-shell/src/views/metadata-admin/AccessExplainPanel.tsx b/packages/app-shell/src/views/metadata-admin/AccessExplainPanel.tsx index f14f56364..8bf2f4f5e 100644 --- a/packages/app-shell/src/views/metadata-admin/AccessExplainPanel.tsx +++ b/packages/app-shell/src/views/metadata-admin/AccessExplainPanel.tsx @@ -7,10 +7,18 @@ * engine (`GET/POST /api/v1/security/explain`) why a principal can — or cannot * — perform an operation on an object, and renders the `ExplainDecision` * trace: the resolved principal (position → permission-set binding chain) and - * the nine evaluation-pipeline layers with their verdicts (required - * capabilities, object CRUD, FLS, OWD baseline, depth, sharing, VAMA bypass, - * RLS). The report is produced by the SAME code paths enforcement runs, so - * what this panel shows is what the middleware does. + * the evaluation-pipeline layers with their verdicts (required capabilities, + * object CRUD, FLS, OWD baseline, depth, sharing, VAMA bypass, RLS). The report + * is produced by the SAME code paths enforcement runs, so what this panel shows + * is what the middleware does. + * + * [C2 / ADR-0095] Optionally scoped to ONE record: supply a record id and the + * report gains the row-level story — the always-first `tenant_isolation` Layer 0, + * each layer tagged with its kernel tier (tenant wall vs. business RLS), the + * principal's posture rung, a per-layer `record` attribution (permission set → + * position → share → row rule → effective row filter), and a top-level verdict + * pinning why THAT record is (in)visible and which layer decided it. Without a + * record id the report is object-level and unchanged. * * Explaining ANOTHER user is authorized server-side: `manage_users` or a * delegated adminScope covering that user (D12) — a 403 here renders as a @@ -48,21 +56,56 @@ import { t, tFormat, useMetadataLocale } from './i18n'; const OPERATIONS = ['read', 'create', 'update', 'delete', 'transfer', 'restore', 'purge'] as const; type ExplainOperation = (typeof OPERATIONS)[number]; -/** Mirrors `ExplainDecisionSchema` in `@objectstack/spec/security` (ADR-0090 D6). */ -export interface ExplainLayer { - layer: - | 'principal' - | 'required_permissions' - | 'object_crud' - | 'fls' +/** Pipeline layer ids — mirrors `ExplainLayerSchema.layer` (C2 adds `tenant_isolation`). */ +export type ExplainLayerId = + | 'tenant_isolation' + | 'principal' + | 'required_permissions' + | 'object_crud' + | 'fls' + | 'owd_baseline' + | 'depth' + | 'sharing' + | 'vama_bypass' + | 'rls'; + +/** [C2 / ADR-0095] One concrete rule that governed a specific record at a layer. */ +export interface ExplainMatchedRule { + kind: + | 'tenant_filter' | 'owd_baseline' - | 'depth' - | 'sharing' - | 'vama_bypass' - | 'rls'; + | 'ownership' + | 'record_share' + | 'sharing_rule' + | 'team' + | 'territory' + | 'rls_policy'; + name: string; + grants?: 'read' | 'edit' | 'full'; + via?: string; + predicate?: unknown; + effect: 'admits' | 'excludes' | 'neutral'; +} + +/** [C2 / ADR-0095] A layer's row-level determination for one record. */ +export interface ExplainRecordAttribution { + outcome: 'admitted' | 'excluded' | 'not_evaluated'; + rowFilter?: unknown; + matchesRecord?: boolean; + rules?: ExplainMatchedRule[]; + detail?: string; +} + +/** Mirrors `ExplainDecisionSchema` in `@objectstack/spec/security` (ADR-0090 D6 / C2 ADR-0095). */ +export interface ExplainLayer { + layer: ExplainLayerId; verdict: 'grants' | 'denies' | 'narrows' | 'widens' | 'neutral' | 'not_applicable'; detail: string; contributors?: Array<{ kind: 'permission_set' | 'position' | 'system'; name: string; via?: string }>; + /** [C2 / ADR-0095 D1] Kernel tier — the tenant wall (Layer 0) vs. business RLS (Layer 1). */ + kernelTier?: 'layer_0_tenant' | 'layer_1_business'; + /** [C2 / ADR-0095] Per-record row story; present only on record-grained reports. */ + record?: ExplainRecordAttribution; } export interface ExplainDecision { allowed: boolean; @@ -74,9 +117,13 @@ export interface ExplainDecision { permissionSets?: string[]; principalKind?: string; onBehalfOf?: { userId: string }; + /** [C2 / ADR-0095 D2] Posture rung, when resolved (record-grained reports). */ + posture?: 'PLATFORM_ADMIN' | 'TENANT_ADMIN' | 'MEMBER' | 'EXTERNAL'; }; layers: ExplainLayer[]; readFilter?: unknown; + /** [C2 / ADR-0095] Row-level verdict for the specific record under explanation. */ + record?: { recordId: string; visible: boolean; decidedBy?: ExplainLayerId }; } const VERDICT_BADGE: Record = { @@ -88,6 +135,20 @@ const VERDICT_BADGE: Record = { not_applicable: 'border-border bg-transparent text-muted-foreground/60', }; +/** [C2] Row-level outcome badge styling (record attribution). */ +const OUTCOME_BADGE: Record, string> = { + admitted: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400', + excluded: 'border-destructive/40 bg-destructive/10 text-destructive', + not_evaluated: 'border-border bg-transparent text-muted-foreground/60', +}; + +/** [C2] Rule-effect dot styling. */ +const EFFECT_DOT: Record = { + admits: 'bg-emerald-500', + excludes: 'bg-destructive', + neutral: 'bg-muted-foreground/40', +}; + const personLabel = (u: unknown): string => { const r = (u ?? {}) as Record; return String(r.full_name || r.name || r.display_name || r.email || r.id || ''); @@ -108,7 +169,9 @@ export function AccessExplainPanel({ open, onOpenChange, defaultObject }: Access const [objectName, setObjectName] = React.useState(defaultObject ?? ''); const [operation, setOperation] = React.useState('read'); const [user, setUser] = React.useState<{ id: string; label: string } | null>(null); + const [recordId, setRecordId] = React.useState(''); const [pickerOpen, setPickerOpen] = React.useState(false); + const [recordPickerOpen, setRecordPickerOpen] = React.useState(false); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); const [decision, setDecision] = React.useState(null); @@ -132,7 +195,14 @@ export function AccessExplainPanel({ open, onOpenChange, defaultObject }: Access method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ object, operation, ...(user ? { userId: user.id } : {}) }), + body: JSON.stringify({ + object, + operation, + ...(user ? { userId: user.id } : {}), + // [C2 / ADR-0095] Record-grained explain — the row-level story for one + // concrete record. Omitted → object-level (unchanged) request. + ...(recordId.trim() ? { recordId: recordId.trim() } : {}), + }), }); let data: Record | null = null; try { @@ -156,7 +226,7 @@ export function AccessExplainPanel({ open, onOpenChange, defaultObject }: Access } finally { setBusy(false); } - }, [authFetch, objectName, operation, user, locale]); + }, [authFetch, objectName, operation, user, recordId, locale]); const opLabel = (op: string) => t(`engine.studio.access.explain.op.${op}`, locale); @@ -238,6 +308,46 @@ export function AccessExplainPanel({ open, onOpenChange, defaultObject }: Access + {/* [C2 / ADR-0095] optional record selector — explains one concrete row */} +
+ +
+ setRecordId(e.target.value)} + placeholder={t('engine.studio.access.explain.recordPlaceholder', locale)} + className="h-8 min-w-0 flex-1 text-xs" + /> + + {recordId && ( + + )} +
+
+