Skip to content

Commit da94c11

Browse files
fix(app-crm): hide the Pipeline nav entry from a member who cannot read opportunities (#27805)
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgC1o88pHYeo2G8hvcGVV1s1)_ Fixes #27735 Clause-②: no Metadata-protocol: none Ruling-ref: 6512784198 ## 维护者速读 - 改了什么:CRM 侧栏的 Pipeline 入口加了一个显示条件 `current_user.can('crm_opportunity', 'read')`,读不到商机的成员看不到它;仪表盘本身、权限集和能力都不动。 - 为什么改:没有销售权限集的成员原来侧栏有 Pipeline,点进去六个组件全被拒。按裁决 G 用「藏」而不是「拒」:首版声明能力的做法会让 CRM 不能非托管安装,种子管理员也会被拒。 - 风险与回滚:只改示例的一个导航项和一个测试文件。拒绝仍由数据集查询的 403 负责,未变;种子管理员和销售用户照旧能看到。回滚就是 revert。 - 先看:`examples/app-crm/src/apps/crm.app.ts`(一行)、`examples/app-crm/test/pipeline-audience.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/example-crm exec vitest run test/pipeline-audience.test.ts`,预期 2 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes Per ruling 6512784198 (letter G, which amends the mechanism of #27491 ruling 6508997538 item 5): - `examples/app-crm/src/apps/crm.app.ts`: `nav_pipeline_dashboard` gains `visible: "current_user.can('crm_opportunity', 'read')"`. Nothing else on the entry changes. `opportunity_metrics`, the dataset every Pipeline widget reads, is bound to `crm_opportunity`. - The first revision's capability approach is fully reverted: no `crm.view_pipeline` declaration, no stack `capabilities`, no `systemPermissions` on `crm_sales_user`, and no `requiredPermissions` on `pipeline_dashboard` or its nav entry. The branch diff against `main` is the two files listed here. `visible` hides and refuses nothing (ADR-0066 D4). The refusal stays the dataset query's 403 for a caller who cannot read `crm_opportunity`, unchanged. The server's meta read gate does not judge `visible`, so the seeded administrator's reads of the dashboard (the dogfood's managed-install lock control) are untouched, and the package stays installable unmanaged. Acceptance (as amended by the ruling): a member who cannot read `crm_opportunity` sees no Pipeline entry in the CRM sidebar; a sales user and the seeded administrator see it as today; a direct link for the refused member shows the #27491 no-access panel (PR #27777), not a blank. ## Tests `examples/app-crm/test/pipeline-audience.test.ts` (new, 2 cases), read off the parsed stack: - the Pipeline nav entry carries a CEL `visible` of the form `current_user.can(OBJECT, 'read')` (the stack parse normalises the authored string to `{ dialect: 'cel', source }`); - OBJECT equals the `object` of the dataset that every `pipeline_dashboard` widget reads, derived from the definitions, not hard-coded. Ablation: `scripts/ablation-replace.mjs` replacing `crm_opportunity` with `crm_account` in the predicate (anchor hit 1) turns the second case red (`expected [ 'crm_opportunity' ] to deeply equal [ 'crm_account' ]`), restored to the HEAD blob with `git diff HEAD` empty. ## Acceptance notes - What the tests do not run: the console's nav guard that evaluates `current_user.can` (`ui/packages/app-shell`, objectui#4421). The example has no renderer harness; the pins cover the predicate's shape and its object. - No changeset: `@objectstack/example-crm` is `private: true`; labelled `skip-changeset`. - The first revision (head `87e10674`) measured why a declared capability does not fit here: `install-local-unmanaged-mode.dogfood.test.ts` went 11 failed / 9 passed (422 `UNMANAGED_INSTALL_UNCOPYABLE` on the stack's `capabilities`, and 403 `audience-permission` for the seeded administrator), against 20/20 at the merge-base. Recorded in the ruling. ## Verification Local, at `a1cd6d8d` (after merging `origin/main` once more): - `pnpm --filter @objectstack/example-crm test`: exit 0, Test Files 7 passed (7), Tests 48 passed (48); `typecheck` exit 0 (lock VERDICT command-exit 0). - `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/install-local-unmanaged-mode.dogfood.test.ts` (closure built first): exit 0, Test Files 1 passed (1), Tests 20 passed (20). At the first revision `87e10674` the same file was 11 failed / 9 passed. - `node scripts/pm/dispatch-gates.mjs --ran`: 39 derived, 38 run, 1 NOT-MEASURED (`check:dual-build-cjs-loads`, exit 3 PREREQUISITE NOT MET: needs every package's dist; CI's). Three families first answered from a mid-build tree and were re-run after the build settled: `check:dts-closure` (exit 1 naming `@objectstack/runtime` / `service-automation` while the dogfood closure was rebuilding, then `driver-odata`, new on `main`, unlinked until `pnpm install`) exit 0; `check:i18n-coverage` exit 0 ("OK (14 config(s), 527 baselined untranslated string(s), none new)"); `check:type-check-debt` exit 0 ("0 raw tsc error(s) total, none above its recorded number"). The diff touches no package, so none of these read it beyond the example. - `node scripts/check-sdui-manifest.mjs`: exit 0. <details><summary>Derived gate families, exit codes (final readings)</summary> ```text node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 3 pnpm check:engine-double-contract :: exit 0 pnpm check:examples-live-imports :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n-coverage :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CXo8iLQ8vn2fXj6wxp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c91a004 commit da94c11

2 files changed

Lines changed: 68 additions & 1 deletion

File tree

‎examples/app-crm/src/apps/crm.app.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@ export const CrmApp = App.create({
3030
label: 'Analytics',
3131
icon: 'chart-bar',
3232
children: [
33-
{ id: 'nav_pipeline_dashboard', type: 'dashboard', dashboardName: 'pipeline_dashboard', label: 'Pipeline', icon: 'layout-dashboard' },
33+
// Offered only to a member who can read the opportunities every
34+
// Pipeline widget aggregates (`opportunity_metrics` is bound to
35+
// `crm_opportunity`); anyone else would open a page whose every tile
36+
// the server refuses. `visible` hides; the dataset query's own 403
37+
// stays the enforcement (ADR-0066 D4).
38+
{ id: 'nav_pipeline_dashboard', type: 'dashboard', dashboardName: 'pipeline_dashboard', label: 'Pipeline', icon: 'layout-dashboard', visible: "current_user.can('crm_opportunity', 'read')" },
3439
],
3540
},
3641
],
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27735] The Pipeline nav entry is offered only to a member who can read
5+
* what the dashboard shows (ruling 6512784198, letter G).
6+
*
7+
* The entry carries a `visible` predicate, `current_user.can(OBJECT, 'read')`,
8+
* which the console's nav guard answers from the member's permissions payload.
9+
* It hides; it refuses nothing. The refusal stays where it already is: the
10+
* dataset query of every widget answers 403 to a caller who cannot read the
11+
* dataset's object (ADR-0066 D4: `visibleWhen` hides, `requiredPermissions`
12+
* refuses). So the one thing that can go wrong here is the predicate naming
13+
* an object other than the one the widgets read, and that is what is pinned,
14+
* off the parsed stack rather than the source constants.
15+
*/
16+
17+
import { describe, expect, it } from 'vitest';
18+
import stack from '../objectstack.config.js';
19+
20+
type NavEntry = {
21+
id?: string;
22+
type?: string;
23+
dashboardName?: string;
24+
visible?: { dialect?: string; source?: string };
25+
children?: NavEntry[];
26+
};
27+
28+
function findNav(entries: NavEntry[] | undefined, id: string): NavEntry | undefined {
29+
for (const entry of entries ?? []) {
30+
if (entry.id === id) return entry;
31+
const hit = findNav(entry.children, id);
32+
if (hit) return hit;
33+
}
34+
return undefined;
35+
}
36+
37+
const READ_PREDICATE = /^current_user\.can\('([a-z0-9_]+)', 'read'\)$/;
38+
39+
const app = (stack.apps ?? []).find((a) => a.name === 'crm_app');
40+
const navEntry = findNav(app?.navigation as NavEntry[] | undefined, 'nav_pipeline_dashboard');
41+
const dashboard = (stack.dashboards ?? []).find((d) => d.name === 'pipeline_dashboard');
42+
// The stack parse normalises an authored CEL string to `{ dialect, source }`.
43+
const predicate = navEntry?.visible;
44+
45+
describe('Pipeline nav entry audience (#27735)', () => {
46+
it('carries a read predicate on one object', () => {
47+
expect(navEntry).toMatchObject({ type: 'dashboard', dashboardName: 'pipeline_dashboard' });
48+
expect(predicate?.dialect).toBe('cel');
49+
expect(predicate?.source).toMatch(READ_PREDICATE);
50+
});
51+
52+
it('names the object of the dataset every pipeline_dashboard widget reads', () => {
53+
const named = predicate?.source?.match(READ_PREDICATE)?.[1];
54+
expect(named).toBeDefined();
55+
const widgets = (dashboard?.widgets ?? []) as Array<{ dataset?: string }>;
56+
expect(widgets.length).toBeGreaterThan(0);
57+
const objects = new Set(
58+
widgets.map((w) => (stack.datasets ?? []).find((d) => d.name === w.dataset)?.object),
59+
);
60+
expect([...objects]).toEqual([named]);
61+
});
62+
});

0 commit comments

Comments
 (0)