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
17 changes: 17 additions & 0 deletions .changeset/system-hub-permissions-leg-3655.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/console': patch
---

Point System Hub's Permissions card — both its link and its count — at `sys_permission_set`, closing the last of the five `system/*` navigation targets (objectui#3655).

Four of those URLs became redirects in an earlier change; `system/permissions` was deliberately held back, and so was the count beside it, because the framework splits what this console calls "Permissions" into two Setup entries and picking one would have silently bound every click, bookmark and badge to a surface nobody chose:

- `sys_capability` — ADR-0066 layer 1, the definition registry of "what can be done". Its own docblock notes it is what the ADR "loosely floats" as `sys_permission`, which is the name the retired page and the count query both used, so lineage pointed here.
- `sys_permission_set` — ADR-0066 layer 2, the grant container the permissions docs call "the only capability container" (object CRUD, field security, access depth, system capabilities), so function pointed here.

It is decided as `sys_permission_set`: the card reads "Manage permission rules and assignments", and rules-and-assignments is layer 2 — a capability is what a permission set references by name, not what an administrator is assigned. Two user-visible consequences:

- `/apps/:app/system/permissions` now forwards in one hop to `/apps/:app/sys_permission_set` instead of being rewritten to `…/system/record/permissions` and rendering a record detail page for an object literally named `system` — a dead link that read as a backend fault.
- The Permissions card's badge shows the real number of permission sets. It previously counted `sys_permission`, an object the framework does not register; the adapter absorbs that `404` into an empty page on purpose, so the card printed a confident `0` no administrator could tell apart from "there really are none".

Recorded as a transitional alias. Retiring this hand-written card wall along with the hub (already `@deprecated` in favour of the metadata-driven navigation) remains open and does not conflict — a redirect keeps old bookmarks resolving either way.
56 changes: 38 additions & 18 deletions apps/console/src/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,11 @@ function MetadataRedirect() {
}

/**
* Forwards the retired `system/{users,organizations,roles,positions}` console
* pages onto the canonical object routes served by the generic
* Forwards the retired `system/{users,organizations,roles,positions,permissions}`
* console pages onto the canonical object routes served by the generic
* `…/:objectName` route in `@object-ui/app-shell` (objectui#3655).
*
* These four were real routes until `apps/console` was slimmed for third-party
* These five were real routes until `apps/console` was slimmed for third-party
* customisation (cccdf84d7): "Delete bespoke /system/* wrapper pages
* (User/Role/Permission/Audit/Org) … these objects are now contributed by
* framework plugins (plugin-auth, -security, -audit) into the Setup app
Expand Down Expand Up @@ -134,9 +134,11 @@ function MetadataRedirect() {
* and the hub's "Positions" are the same
* surface under old/new vocabulary)
* positions -> sys_position (`nav_positions`)
*
* `system/permissions` is deliberately NOT redirected here — see the route
* block below.
* permissions -> sys_permission_set(`nav_permission_sets`; this one was
* held back in PR #3673 and is resolved
* by objectui#3655's decision A — the
* reasoning is recorded at the route
* block below)
*
* Same shape as `ObjectRedirect` / `MetadataRedirect` above (a legacy URL is
* translated, the page is not resurrected), including their treatment of
Expand Down Expand Up @@ -188,22 +190,40 @@ export const systemRoutes = (
<Route path="system/metadata/:metadataType" element={<MetadataRedirect />} />
<Route path="system/metadata/:metadataType/:itemName" element={<MetadataRedirect />} />
{/* Legacy URL redirects → the framework-owned system objects (objectui#3655).
`system/permissions` is absent on purpose: unlike the four above it has
no single measured equivalent. The framework splits what this console
calls "Permissions" into TWO Setup entries — `sys_capability`
(Capabilities: the definition registry, and the object whose own
docblock says it is what the ADR "loosely floats" as `sys_permission`,
which is the name the retired page and `SystemHubPage`'s count query
both use) and `sys_permission_set` (Permission Sets: the grant
container, which is what "Manage permission rules and assignments" and
admin CRUD describe). Picking one silently sends every future click and
bookmark to a surface the maintainer never chose, so it is left to be
decided rather than guessed; the test file pins its unchanged landing so
the gap stays visible instead of reading as an oversight. */}
All five resolve now. `system/permissions` was the one held back in PR
#3673: the framework splits what this console calls "Permissions" into
TWO Setup entries, and picking one on a hunch would have bound every
future click and bookmark to a surface nobody chose.

`sys_capability` (nav "Capabilities") — ADR-0066 layer 1, the
DEFINITION registry of "what can be done". Its own docblock says it
is what the ADR "loosely floats" as `sys_permission` — the name the
retired page used — so LINEAGE points here.
`sys_permission_set` (nav "Permission Sets") — ADR-0066 layer 2, the
grant/assignment container the permissions docs call "the only
capability container" (object CRUD + field security + access depth
+ system capabilities). FUNCTION points here.

Decided as A, `sys_permission_set` (objectui#3655): the card that emits
this URL reads "Manage permission rules and assignments", and
rules-and-assignments is layer 2 — the definition catalog is what you
reference BY NAME from a set, not what you assign. Deliberately a
transitional alias: option C (retiring this bespoke card wall together
with the hub, already `@deprecated`) stays open and does not conflict,
because a redirect keeps old bookmarks resolving either way.

One correction worth leaving here, since it circulated while this was
open: the "capabilities are platform-locked, permission sets are the
admin-CRUD one" reading does NOT survive a re-read of the framework.
BOTH objects are `managedBy: 'config'` with `protection.lock:
'no-overlay'`, and both docblocks say the lock is on the SCHEMA while
tenants/admins may add rows. The layer-1 / layer-2 split above is the
distinction that actually holds. */}
<Route path="system/users" element={<SystemObjectRedirect objectName="sys_user" />} />
<Route path="system/organizations" element={<SystemObjectRedirect objectName="sys_organization" />} />
<Route path="system/roles" element={<SystemObjectRedirect objectName="sys_position" />} />
<Route path="system/positions" element={<SystemObjectRedirect objectName="sys_position" />} />
<Route path="system/permissions" element={<SystemObjectRedirect objectName="sys_permission_set" />} />
</>
);

Expand Down
91 changes: 59 additions & 32 deletions apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@
*
* `ChainRecorder` records every location the router settles on, so "one hop" is
* an assertion rather than an inference; the pre-fix landings above are
* reachable by deleting the four `system/*` routes from `systemRoutes` (the
* reachable by deleting the five `system/*` routes from `systemRoutes` (the
* `object-view` probes go red and the chain returns to `Page not found` /
* `…/system/record/<word>`).
*
* ## Two branches, and the `permissions` gap
* ## Two branches
*
* `AppContent` has two route tables and this host passes the same fragment to
* both. With an active app the redirect target resolves (`:objectName` renders
Expand All @@ -52,10 +52,24 @@
* and pinned below rather than asserted away, because a deployment with no apps
* in its metadata also has no `sys_user` to show.
*
* `system/permissions` is deliberately still broken: the framework splits this
* console's "Permissions" into `sys_capability` and `sys_permission_set` and
* nothing in the issue picks one. Its unchanged landing is pinned so the gap is
* visible, and so whichever mapping is chosen has to come here to change it.
* ## The `permissions` leg, closed
*
* Four of the five landed in PR #3673. `system/permissions` was held back and
* its broken landing PINNED, because the framework splits this console's
* "Permissions" into two Setup entries (`sys_capability`, ADR-0066 layer 1 —
* the definition registry; `sys_permission_set`, layer 2 — the grant container
* the permissions docs call "the only capability container") and guessing would
* have bound every click and bookmark to a surface nobody chose. objectui#3655
* decided it as `sys_permission_set`: the card that emits this URL says "Manage
* permission rules and assignments", which is layer 2. Those pins are therefore
* REPLACED here, not merely kept passing — that was their stated purpose.
*
* What the pins measured beyond the gap itself — that an undeclared segment
* fails in two different ways depending on its LENGTH — is still true and still
* measured, now on a hypothetical pair that is undeclared in both branches
* (`system/teams`, 5 chars, and `system/workgroups`, 10). Re-pointing them was
* the alternative to letting the length asymmetry lose its only coverage the
* moment its last real specimen got a route.
*/

import '@testing-library/jest-dom/vitest';
Expand Down Expand Up @@ -214,16 +228,18 @@ beforeEach(() => {

describe('system-hub entries reach the framework system objects (objectui#3655)', () => {
/**
* The four whose equivalent the framework names unambiguously. `roles` and
* `positions` converge on ONE object on purpose: ADR-0090 D3 renamed
* `sys_role` -> `sys_position`, so the sidebar's "Roles" and the hub's
* "Positions" are the same surface in old and new vocabulary.
* All five. `roles` and `positions` converge on ONE object on purpose:
* ADR-0090 D3 renamed `sys_role` -> `sys_position`, so the sidebar's "Roles"
* and the hub's "Positions" are the same surface in old and new vocabulary.
* `permissions` -> `sys_permission_set` is objectui#3655's decision A (see
* the file docblock); it landed one PR after the other four.
*/
it.each([
['/apps/setup/system/users', '/apps/setup/sys_user', 'sys_user'],
['/apps/setup/system/organizations', '/apps/setup/sys_organization', 'sys_organization'],
['/apps/setup/system/roles', '/apps/setup/sys_position', 'sys_position'],
['/apps/setup/system/positions', '/apps/setup/sys_position', 'sys_position'],
['/apps/setup/system/permissions', '/apps/setup/sys_permission_set', 'sys_permission_set'],
])('%s reaches %s in ONE hop', async (url, target, objectName) => {
renderConsoleAt(url);

Expand Down Expand Up @@ -253,34 +269,34 @@ describe('system-hub entries reach the framework system objects (objectui#3655)'
});

/**
* MEASUREMENT — deliberately unresolved. "Permissions" has TWO candidate
* equivalents in the framework (`sys_capability`, the definition registry and
* the object its own docblock identifies as the ADR's `sys_permission`; and
* `sys_permission_set`, the grant container the permissions docs call "the
* only capability container"). Choosing one here would silently commit every
* click and bookmark to a surface nobody picked, so this leg keeps its
* pre-fix landing until the mapping is decided. This assertion is expected to
* be REPLACED, not merely to keep passing.
* MEASUREMENT — the length-dependent split this issue is really about, kept
* alive now that its last real specimen has a route. `permissions` used to
* stand here: 11 chars, judged a record id, rewritten to
* `…/system/record/permissions` and rendered as a record of an object
* literally named `system`. That pin was written to be REPLACED once the
* mapping was decided (objectui#3655 → `sys_permission_set`), and the one-hop
* case above is its replacement.
*
* The asymmetry itself outlives it, so it is re-pinned on a hypothetical pair
* that is undeclared in both route tables: `workgroups` (10 chars) here and
* `teams` (5) below — the same concept spelled two lengths, producing two
* unrelated failure screens. Nothing here asks for `looksLikeRecordId` to
* change; that is a separate question. The point is that the next reader
* still finds it measured against the shipped routes rather than described.
*/
it('MEASUREMENT: system/permissions still lands on a record of the object `system`', async () => {
renderConsoleAt('/apps/setup/system/permissions');
it('MEASUREMENT: an undeclared LONG system segment lands on a record of the object `system`', async () => {
renderConsoleAt('/apps/setup/system/workgroups');

const probe = await screen.findByTestId('record-detail-view');
expect(probe).toHaveTextContent('"objectName":"system"');
expect(probe).toHaveTextContent('"recordId":"permissions"');
expect(probe).toHaveTextContent('"recordId":"workgroups"');
expect(chain).toEqual([
'/apps/setup/system/permissions',
'/apps/setup/system/record/permissions',
'/apps/setup/system/workgroups',
'/apps/setup/system/record/workgroups',
]);
});

/**
* MEASUREMENT — the length-dependent split this issue is really about, taken
* on the one URL the fix does not cover. `permissions` (11 chars) is judged a
* record id; a hypothetical 5-char sibling would not be. Nothing here asks
* for `looksLikeRecordId` to change — it is a separate question — but the
* asymmetry is recorded so the next reader does not have to rediscover it.
*/
/** MEASUREMENT — the short half of the pair described just above. */
it('MEASUREMENT: an undeclared SHORT system segment still reaches Page not found', async () => {
renderConsoleAt('/apps/setup/system/teams');

Expand Down Expand Up @@ -310,6 +326,7 @@ describe('zero-app branch — measured, not asserted away (objectui#3655)', () =
['/apps/setup/system/organizations', '/apps/setup/sys_organization'],
['/apps/setup/system/roles', '/apps/setup/sys_position'],
['/apps/setup/system/positions', '/apps/setup/sys_position'],
['/apps/setup/system/permissions', '/apps/setup/sys_permission_set'],
])('%s still redirects, and the target is the no-apps empty state', async (url, target) => {
metadataApps = [];
renderConsoleAt(url);
Expand All @@ -319,12 +336,22 @@ describe('zero-app branch — measured, not asserted away (objectui#3655)', () =
expect(screen.queryByTestId('object-view')).not.toBeInTheDocument();
});

/**
* MEASUREMENT — same re-pointing as the with-app half. This case used to run
* on `system/permissions`, which is now declared and so redirects here too
* (the row added above). Its subject was never that URL, though: it is that
* on a zero-app deployment even a LONG undeclared word reaches
* `RouteNotFound` and never a record page, because `ShorthandRecordRedirect`
* is not declared in this branch at all — i.e. the length split measured in
* the with-app describe does not exist here. `workgroups` (10 chars) keeps
* that fact pinned on a segment that is still undeclared.
*/
it('MEASUREMENT: with no apps, an undeclared system URL reaches Page not found, never a record page', async () => {
metadataApps = [];
renderConsoleAt('/apps/setup/system/permissions');
renderConsoleAt('/apps/setup/system/workgroups');

expect(await screen.findByText('Page not found')).toBeInTheDocument();
expect(screen.queryByTestId('record-detail-view')).not.toBeInTheDocument();
expect(chain).toEqual(['/apps/setup/system/permissions']);
expect(chain).toEqual(['/apps/setup/system/workgroups']);
});
});
Loading
Loading