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
24 changes: 24 additions & 0 deletions .changeset/nav-access-lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/lint': minor
'@objectstack/cli': minor
---

Navigation reachability vs. granted access (issue #3583, assessment R5)

`validate-nav-access` joins what an app's navigation exposes against
`buildAccessMatrix` — the first lint consumer of the ADR-0090 D6 matrix, which
previously only backed `os compile`'s snapshot gate. An object in the menu that
no permission set grants read on renders as an entry and then fails
permission-denied when opened: it works while you browse as an administrator
(the platform's built-in `admin_full_access` carries a wildcard grant) and
breaks for exactly the users the app ships permission sets for.

Advisory severity — a grant can legitimately come from a permission set another
installed package ships. Quiet by construction in three cases: platform-provided
objects (their own packages grant them), stacks that declare no permission sets
at all (permissions managed elsewhere, so flagging every entry says nothing),
and any stack where a set carries a wildcard `objects: { '*': … }` grant — the
shape `admin_full_access` itself uses, which the access matrix records under the
literal key `*`.

Wired into `os validate`, `os lint`, and `os compile`.
19 changes: 19 additions & 0 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,24 @@ An axis naming a measure the dataset declares but this chart does not *select*
nothing. The react `<ObjectChart>` block is object-bound rather than
dataset-bound and is not checked here.

### 7. Navigation exposing objects nobody can read

Navigation and permissions are separate metadata, each valid on its own — so an
app can put an object in its menu that no permission set grants read on. The
entry renders; opening it fails permission-denied for everyone except a holder
of the platform's built-in wildcard admin set. It works while you browse as an
administrator and breaks for the users the app ships permission sets for.

```ts
navigation: [{ id: 'nav_forecast', type: 'object', objectName: 'crm_forecast' }]
// …and no permission set lists `crm_forecast` under `objects` → warning
```

Advisory: the grant may come from a permission set another installed package
ships. Skipped for platform-provided objects (whose own packages grant them),
for stacks that declare no permission sets at all, and when any set carries a
wildcard (`objects: { '*': … }`) grant.

## The one gate, two entry points

`os validate` and `os build` (alias of `os compile`) run the **same** validator:
Expand All @@ -172,6 +190,7 @@ dataset-bound and is not checked here.
| Object & action name references (#3583) | ✓ | ✓ |
| Page-component field bindings (#3583) | ✓ | ✓ |
| Chart bindings outside dashboards (#3583) | ✓ | ✓ |
| Navigation vs. granted access (ADR-0090 D6) | ✓ | ✓ |
| Security posture (ADR-0090 — e.g. every custom object declares `sharingModel`) | ✓ | ✓ |
| Emits `dist/objectstack.json` | — | ✓ |

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { validateDashboardActionRefs } from '@objectstack/lint';
import { validateFilterTokens } from '@objectstack/lint';
import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint';
import { validatePageFieldBindings, validateChartBindings } from '@objectstack/lint';
import { validateNavAccess } from '@objectstack/lint';
import { validateResponsiveStyles } from '@objectstack/lint';
import { validateSecurityPosture, validateOrgAxisRedLines, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
import { validateReadonlyFlowWrites } from '@objectstack/lint';
Expand Down Expand Up @@ -340,6 +341,7 @@ export default class Compile extends Command {
...validateActionNameRefs(result.data as Record<string, unknown>),
...validatePageFieldBindings(result.data as Record<string, unknown>),
...validateChartBindings(result.data as Record<string, unknown>),
...validateNavAccess(result.data as Record<string, unknown>),
];
const refErrors = refFindings.filter((f) => f.severity === 'error');
const refWarnings = refFindings.filter((f) => f.severity === 'warning');
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { validateWidgetBindings } from '@objectstack/lint';
import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences, validateSecurityPosture, validateOrgAxisRedLines, validateApprovalApprovers, validateSeedReplaySafety, validateSeedStateMachine } from '@objectstack/lint';
import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint';
import { validatePageFieldBindings, validateChartBindings } from '@objectstack/lint';
import { validateNavAccess } from '@objectstack/lint';
import { collectAndLintDocs } from '../utils/collect-docs.js';
import { scoreMetadata } from '../lint/score.js';
import { runMetadataEval } from '../lint/metadata-eval.js';
Expand Down Expand Up @@ -551,6 +552,21 @@ export function lintConfig(config: any): LintIssue[] {
});
}

// ── Navigation reachability vs. granted access (ADR-0090 D6, issue #3583) ──
// Navigation and permissions are separate metadata, so an app can expose an
// object no permission set grants read on: the entry renders and the click
// fails permission-denied for every user, including admins. Advisory — the
// grant may come from a set another installed package ships.
for (const t of validateNavAccess(config)) {
issues.push({
severity: t.severity,
rule: t.rule,
message: `${t.where}: ${t.message}`,
path: t.path,
fix: t.hint,
});
}

return issues;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { validateDashboardActionRefs } from '@objectstack/lint';
import { validateFilterTokens } from '@objectstack/lint';
import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint';
import { validatePageFieldBindings, validateChartBindings } from '@objectstack/lint';
import { validateNavAccess } from '@objectstack/lint';
import { validateResponsiveStyles } from '@objectstack/lint';
import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint';
import { validateCapabilityReferences } from '@objectstack/lint';
Expand Down Expand Up @@ -302,6 +303,7 @@ export default class Validate extends Command {
...validateActionNameRefs(result.data as Record<string, unknown>),
...validatePageFieldBindings(result.data as Record<string, unknown>),
...validateChartBindings(result.data as Record<string, unknown>),
...validateNavAccess(result.data as Record<string, unknown>),
];
const refErrors = refFindings.filter((f) => f.severity === 'error');
const refWarnings = refFindings.filter((f) => f.severity === 'warning');
Expand Down
3 changes: 3 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,4 +203,7 @@ export {
} from './validate-chart-bindings.js';
export type { ChartBindingFinding, ChartBindingSeverity } from './validate-chart-bindings.js';

export { validateNavAccess, NAV_OBJECT_UNGRANTED } from './validate-nav-access.js';
export type { NavAccessFinding, NavAccessSeverity } from './validate-nav-access.js';

export { buildAccessMatrix, diffAccessMatrix } from './build-access-matrix.js';
192 changes: 192 additions & 0 deletions packages/lint/src/validate-nav-access.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { validateNavAccess, NAV_OBJECT_UNGRANTED } from './validate-nav-access.js';

const objects = [
{ name: 'crm_lead', fields: { name: { type: 'text' } } },
{ name: 'crm_forecast', fields: { name: { type: 'text' } } },
];

const navApp = (items: unknown[]) => [{ name: 'crm', label: 'CRM', navigation: items }];

const objectNav = (id: string, objectName: string) => ({
id,
type: 'object',
label: objectName,
objectName,
});

describe('validateNavAccess', () => {
// The HotCRM instance: an object in the menu that no set grants.
it('warns on a nav-exposed object no permission set grants read on', () => {
const findings = validateNavAccess({
objects,
apps: navApp([objectNav('nav_leads', 'crm_lead'), objectNav('nav_forecast', 'crm_forecast')]),
permissions: [
{ name: 'crm_user', label: 'CRM User', objects: { crm_lead: { allowRead: true } } },
],
});
expect(findings).toHaveLength(1);
expect(findings[0].severity).toBe('warning');
expect(findings[0].rule).toBe(NAV_OBJECT_UNGRANTED);
expect(findings[0].path).toBe('apps[0].navigation[1].objectName');
expect(findings[0].message).toContain('crm_forecast');
});

// The platform's own `admin_full_access` uses this shape; a stack may too.
// Without wildcard support the matrix records the literal key `*` and the
// rule would fire on exactly the stacks that granted the most.
it('accepts a wildcard read grant as covering every object', () => {
const findings = validateNavAccess({
objects,
apps: navApp([objectNav('nav_forecast', 'crm_forecast')]),
permissions: [
{ name: 'full', label: 'Full', objects: { '*': { allowRead: true, viewAllRecords: true } } },
],
});
expect(findings).toEqual([]);
});

it('accepts read granted through viewAllRecords or modifyAllRecords', () => {
for (const bit of ['viewAllRecords', 'modifyAllRecords']) {
const findings = validateNavAccess({
objects,
apps: navApp([objectNav('nav_forecast', 'crm_forecast')]),
permissions: [
{ name: 'admin', label: 'Admin', objects: { crm_forecast: { [bit]: true } } },
],
});
expect(findings, `${bit} should grant read`).toEqual([]);
}
});

it('does not treat a write-only grant as read', () => {
const findings = validateNavAccess({
objects,
apps: navApp([objectNav('nav_forecast', 'crm_forecast')]),
permissions: [
{ name: 'writer', label: 'Writer', objects: { crm_forecast: { allowCreate: true, allowEdit: true } } },
],
});
expect(findings).toHaveLength(1);
});

it('reports an object once even when several nav entries expose it', () => {
const findings = validateNavAccess({
objects,
apps: [
{
name: 'crm',
navigation: [objectNav('nav_a', 'crm_forecast')],
areas: [{ id: 'area', label: 'Area', navigation: [objectNav('nav_b', 'crm_forecast')] }],
},
],
permissions: [{ name: 'p', label: 'P', objects: { crm_lead: { allowRead: true } } }],
});
expect(findings).toHaveLength(1);
});

it('walks area navigation and nested children', () => {
const findings = validateNavAccess({
objects,
apps: [
{
name: 'crm',
areas: [
{
id: 'area_sales',
label: 'Sales',
navigation: [
{
id: 'grp',
type: 'group',
label: 'Group',
children: [objectNav('nav_forecast', 'crm_forecast')],
},
],
},
],
},
],
permissions: [{ name: 'p', label: 'P', objects: { crm_lead: { allowRead: true } } }],
});
expect(findings).toHaveLength(1);
expect(findings[0].path).toBe('apps[0].areas[0].navigation[0].children[0].objectName');
});
});

describe('validateNavAccess — exemptions (false-positive floor)', () => {
it('skips platform-provided objects — their own packages grant them', () => {
const findings = validateNavAccess({
objects,
apps: navApp([
objectNav('nav_users', 'sys_user'),
objectNav('nav_approvals', 'sys_approval_request'),
]),
permissions: [{ name: 'p', label: 'P', objects: { crm_lead: { allowRead: true } } }],
});
expect(findings).toEqual([]);
});

it('skips a stack that declares no permission sets at all', () => {
const findings = validateNavAccess({
objects,
apps: navApp([objectNav('nav_forecast', 'crm_forecast')]),
});
expect(findings).toEqual([]);
});

it('skips an object this stack does not define', () => {
// A dangling nav target is `validate-object-references`' finding, not this
// rule's — reporting it here would double-report one mistake.
const findings = validateNavAccess({
objects,
apps: navApp([objectNav('nav_ghost', 'crm_ghost')]),
permissions: [{ name: 'p', label: 'P', objects: { crm_lead: { allowRead: true } } }],
});
expect(findings).toEqual([]);
});

it('ignores non-object nav entries', () => {
const findings = validateNavAccess({
objects,
apps: navApp([
{ id: 'nav_dash', type: 'dashboard', label: 'D', dashboardName: 'd' },
{ id: 'nav_url', type: 'url', label: 'U', url: '/x' },
{ id: 'nav_sep', type: 'separator' },
]),
permissions: [{ name: 'p', label: 'P', objects: { crm_lead: { allowRead: true } } }],
});
expect(findings).toEqual([]);
});

it('is silent when every exposed object is granted, and tolerates empty input', () => {
const findings = validateNavAccess({
objects,
apps: navApp([objectNav('nav_leads', 'crm_lead'), objectNav('nav_forecast', 'crm_forecast')]),
permissions: [
{
name: 'p',
label: 'P',
objects: { crm_lead: { allowRead: true }, crm_forecast: { allowRead: true } },
},
],
});
expect(findings).toEqual([]);
expect(validateNavAccess({})).toEqual([]);
expect(validateNavAccess(null as unknown as Record<string, unknown>)).toEqual([]);
});

it('accepts a grant coming from any one of several sets', () => {
const findings = validateNavAccess({
objects,
apps: navApp([objectNav('nav_forecast', 'crm_forecast')]),
permissions: [
{ name: 'a', label: 'A', objects: { crm_lead: { allowRead: true } } },
{ name: 'b', label: 'B', objects: { crm_forecast: { allowRead: true } } },
],
});
expect(findings).toEqual([]);
});
});
Loading
Loading