Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/c2-beta-explain-record-grained.md
Original file line number Diff line number Diff line change
@@ -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` 时行为与对象级完全一致。
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading