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
14 changes: 14 additions & 0 deletions .changeset/7611-setup-catalog-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/app-shell': minor
'@object-ui/fields': minor
'@object-ui/console': minor
---

The Setup catalog for positions and permission sets reads the registry. It is built from the metadata-admin list and editors in their environment scope, not from new pages (objectui#7611, objectstack ADR-0131 D3/D7).

- **The environment scope (`?scope=environment`).** `…/metadata/position` and `…/metadata/permission` with this parameter list every item the registry serves for the type, including the platform's own sets. Without it, the list shows one project package's items, as Studio does. Every link the pages emit keeps the scope: the item links, the create link and the editor's breadcrumb. `ENVIRONMENT_SCOPE_QUERY` is exported for hosts that route their own URLs onto it.
- **Gated for Setup.** A caller without `manage_metadata` gets no create affordance on the list and a read-only editor, and the page says why. Under `single`, the reason is that the platform administrator defines these items and the caller's organization assigns them. Under a wall, it is that the operator defines them for every organization. The generic editor and the permission-set editor now apply this caller gate wherever they render. The metadata door refuses that caller's save anyway; this states the refusal before the click.
- **The active switch.** The catalog list has an active column and an active/inactive filter. Both show the activation ledger's state (`sys_metadata_activation`), which is what the authorization resolver reads; an item with no ledger row is active. The switch writes through the ledger's door, `POST /api/v1/security/_activation/:type/:name` with `{ enabled }`, so switching a set or a position off takes it away from its holders, and switching it on gives it back. It reads and writes no catalog row, so the catalog row's own `active` column is neither shown nor changed. The door's refusals are shown in the server's own words. The audience anchors `everyone` and `guest`, which the door refuses to switch, show a disabled switch and the reason. The door is objectstack's ADR-0131 stage 2c.
- **Holders.** In the environment scope, the permission-set editor shows who holds the set under its definition, and the position editor shows who holds the position. A set's holders are read by name: grants come from the `permission_set` column, and the positions that distribute the set come from the registry's position definitions (`permissionSets`). They are no longer read from `sys_position_permission_set` or `sys_position` rows, so on a framework from before objectstack's ADR-0131 stage 1, where positions declare no `permissionSets`, a set lists no holders through a position. A new grant still carries `permission_set_id`, because the server does not accept a grant by name alone yet. A position is assigned by its name.
- **The sharing-rule recipient picker lists the `position` registry** through the console's metadata store. It no longer reads `sys_position` rows. Without a metadata store mounted, the `position` kind falls back to the plain text input.
- **The console's `system/roles`, `system/positions` and `system/permissions`** now go to the catalog list, not to the object pages.
53 changes: 36 additions & 17 deletions apps/console/src/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import { lazy, Suspense, useMemo } from 'react';
import { Route, useParams, useLocation, useNavigate, Navigate } from 'react-router-dom';
import { DefaultAppContent, LoadingScreen, RecordDetailView, useAdapter, useMetadata } from '@object-ui/app-shell';
import { DefaultAppContent, ENVIRONMENT_SCOPE_QUERY, LoadingScreen, RecordDetailView, useAdapter, useMetadata } from '@object-ui/app-shell';
import { MePermissionsProvider } from '@object-ui/permissions';
import { createAuthenticatedFetch } from '@object-ui/auth';
import type { DataSource } from '@object-ui/types';
Expand Down Expand Up @@ -129,17 +129,12 @@ function MetadataRedirect() {
* record-scoped `nav_organization`
* needs a runtime `{current_org_id}`
* that a static redirect cannot resolve)
* roles -> sys_position (ADR-0090 D3 renamed `sys_role` ->
* `sys_position`; the sidebar's "Roles"
* and the retired hub's "Positions" were
* the same surface under old/new
* vocabulary)
* positions -> sys_position (`nav_positions`)
* 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)
*
* Three of the five no longer land on an object at all (objectui#7611):
* `roles`, `positions` and `permissions` name the security CATALOG, which
* ADR-0131 D3 moves into the environment registry, so they forward onto the
* Setup catalog — the metadata-admin list in its environment scope — through
* {@link SystemCatalogRedirect} below.
*
* Same shape as `ObjectRedirect` / `MetadataRedirect` above (a legacy URL is
* translated, the page is not resurrected), including their treatment of
Expand All @@ -154,6 +149,27 @@ function SystemObjectRedirect({ objectName }: { objectName: string }) {
return <Navigate to={`${prefix}/${objectName}`} replace />;
}

/**
* Forwards the retired `system/{roles,positions,permissions}` console pages
* onto the Setup CATALOG (objectui#7611): the metadata-admin list of the
* registry type, in its environment scope (`…/metadata/<type>?scope=environment`,
* `catalog-scope.ts` in `@object-ui/app-shell`).
*
* ADR-0131 D3 gives positions and permission sets one home, the environment
* registry, and D7 says Setup lists that registry; the ruling that unblocked
* objectui#7611 says the Setup pages are the existing metadata-admin pages,
* re-routed. The words map as they always did — `roles` was ADR-0090 D3's old
* name for positions, and objectui#3655 decision A bound `permissions` to the
* permission-set surface (layer 2), not the capability definitions (layer 1).
* Same prefix handling, and the same treatment of `location.search` /
* `location.hash`, as {@link SystemObjectRedirect}.
*/
function SystemCatalogRedirect({ type }: { type: 'position' | 'permission' }) {
const location = useLocation();
const prefix = location.pathname.replace(/\/system\/[^/]+\/?$/, '');
return <Navigate to={`${prefix}/metadata/${type}?${ENVIRONMENT_SCOPE_QUERY}`} replace />;
}

/**
* The bare `…/system` landing: forwards onto `…/system/settings`, the settings
* hub (objectui#3743).
Expand Down Expand Up @@ -238,8 +254,11 @@ export const systemRoutes = (
<Route path="system/metadata" element={<MetadataRedirect />} />
<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).
All five resolve now. `system/permissions` was the one held back in PR
{/* Legacy URL redirects → the framework-owned system objects (objectui#3655),
and — since objectui#7611 — the Setup catalog for the last three
(`SystemCatalogRedirect`: positions and permission sets are registry
items, ADR-0131 D3). The history below is why `permissions` means the
permission-set surface. All five resolve. `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.
Expand Down Expand Up @@ -269,9 +288,9 @@ export const systemRoutes = (
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" />} />
<Route path="system/roles" element={<SystemCatalogRedirect type="position" />} />
<Route path="system/positions" element={<SystemCatalogRedirect type="position" />} />
<Route path="system/permissions" element={<SystemCatalogRedirect type="permission" />} />
</>
);

Expand Down
66 changes: 53 additions & 13 deletions apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,9 @@ import { systemRoutes } from '../AppContent';
const chain: string[] = [];
function ChainRecorder() {
const location = useLocation();
const here = location.pathname;
// The query string rides along: the Setup catalog's scope is a query
// parameter (objectui#7611), and every other landing here carries none.
const here = location.pathname + location.search;
if (chain[chain.length - 1] !== here) chain.push(here);
return null;
}
Expand Down Expand Up @@ -269,19 +271,12 @@ beforeEach(() => {

describe('system-hub entries reach the framework system objects (objectui#3655)', () => {
/**
* 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 retired hub's "Positions" were 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.
* The two that still name an OBJECT. The other three name the security
* catalog and are measured in the next describe (objectui#7611).
*/
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 @@ -347,6 +342,54 @@ describe('system-hub entries reach the framework system objects (objectui#3655)'
});
});

describe('the catalog entries reach the Setup catalog (objectui#7611)', () => {
/**
* `roles`, `positions` and `permissions` name the security CATALOG, which
* ADR-0131 D3 moves into the environment registry, so they land on the
* metadata-admin list of the registry type in its environment scope. `roles`
* and `positions` still converge on ONE surface (ADR-0090 D3 renamed
* `sys_role` -> `sys_position`), and `permissions` still means the
* permission-set surface (objectui#3655 decision A).
*/
it.each([
['/apps/setup/system/roles', '/apps/setup/metadata/position?scope=environment', 'position'],
['/apps/setup/system/positions', '/apps/setup/metadata/position?scope=environment', 'position'],
['/apps/setup/system/permissions', '/apps/setup/metadata/permission?scope=environment', 'permission'],
])('%s reaches %s in ONE hop', async (url, target, type) => {
renderConsoleAt(url);

expect(await screen.findByTestId('metadata-resource-list-page')).toHaveTextContent(type);
expect(chain).toEqual([url, target]);
// Never the row page the URL used to land on.
expect(screen.queryByTestId('object-view')).not.toBeInTheDocument();
});

it('preserves the app prefix', () => {
renderConsoleAt('/apps/my-app/system/permissions');

expect(chain).toEqual([
'/apps/my-app/system/permissions',
'/apps/my-app/metadata/permission?scope=environment',
]);
});

/**
* The zero-app branch declares the metadata routes too, so the catalog is
* reachable on a deployment with no app — unlike the object pages the three
* URLs used to land on, which end at the "No Apps Configured" guard there.
*/
it.each([
['/apps/setup/system/positions', '/apps/setup/metadata/position?scope=environment'],
['/apps/setup/system/permissions', '/apps/setup/metadata/permission?scope=environment'],
])('with no apps, %s still reaches the catalog', async (url, target) => {
metadataApps = [];
renderConsoleAt(url);

expect(await screen.findByTestId('metadata-resource-list-page')).toBeInTheDocument();
expect(chain).toEqual([url, target]);
});
});

describe('zero-app branch — measured, not asserted away (objectui#3655)', () => {
/**
* On a zero-app deployment this is the branch the sidebar's Users /
Expand All @@ -369,9 +412,6 @@ describe('zero-app branch — measured, not asserted away (objectui#3655)', () =
it.each([
['/apps/setup/system/users', '/apps/setup/sys_user'],
['/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 Down
8 changes: 8 additions & 0 deletions content/docs/guide/console-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ full list:
Read those two route trees in the source rather than trusting a hand-copied table to stay
current.

The Setup catalog for positions and permission sets is not a separate route family. It is
the metadata-admin list and editor with the query parameter `scope=environment`, for
example `/apps/setup/metadata/permission?scope=environment`. That scope lists every item
the registry serves for the type and adds the Setup half of an item, which is who holds
it. The console's legacy `system/roles`, `system/positions` and `system/permissions`
URLs forward there. The `@object-ui/app-shell` README describes the scope under "The
Setup catalog".

| Route Pattern | Component | Purpose |
|---------------|-----------|---------|
| `/apps/:appName` | Home redirect | Redirects to the first object in navigation |
Expand Down
37 changes: 37 additions & 0 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,43 @@ enable, or disable packages; direct `/metadata/package` links redirect there.
The Studio sidebar also flattens the root Overview group so Home and package
navigation sit directly under the package selector.

### The Setup catalog: the environment scope (`?scope=environment`)

Positions and permission sets live in the environment registry (objectstack
ADR-0131 D3), and Setup lists that registry (D7). Setup does not add a page
family for them: it uses the same metadata-admin list and editors at
`…/metadata/position` and `…/metadata/permission`, with the query parameter
`scope=environment` (`ENVIRONMENT_SCOPE_QUERY`, from
`views/metadata-admin/catalog-scope.ts`). The scope changes three things:

- **The list** shows every item the registry serves for the type, the
platform's own sets included. It is not narrowed to one project package.
Every link it emits keeps the scope, and so does the editor's breadcrumb.
- **The gates.** A caller without `manage_metadata` gets no create affordance
and a read-only editor, and the page says why in the deployment's own terms.
Under `single` the platform administrator defines these items. Under a wall
the operator defines them in Studio. In both cases the organization assigns
them. The editors apply the caller gate in every scope, because the metadata
door refuses that caller's save in every scope.
- **The Setup half of an item.** It renders under the definition: a permission
set's holders (`AssignedUsersSection`), and a position's holders
(`PositionHoldersSection`). Both read and write assignment rows by the
item's name.

The list has an active switch and an active/inactive filter, through
`catalog-activation.ts`. Both show the activation ledger's state
(`sys_metadata_activation`, read through the data door), which is what the
authorization resolver reads: an item with no ledger row is active. The switch
writes through the ledger's door, `POST /api/v1/security/_activation/:type/:name`
with `{ enabled }` (objectstack's ADR-0131 stage 2c), and reads or writes no
catalog row. A refusal from the door (`403` for a caller who may not switch, or
for switching off the last administrator who can sign in; `404`; `503`) is
shown in the server's own words. The audience anchors `everyone` and `guest`,
which the door refuses to switch, show a disabled switch and the reason.
Capabilities are not in this scope yet: the Capabilities page and the
capability picker still read `sys_capability` rows, and a capability has no
switch.

### Package-less flows (`/studio/~org/automations`)

A flow that belongs to no package — a clone of a packaged flow is one, by
Expand Down
4 changes: 4 additions & 0 deletions packages/app-shell/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,10 @@ export { MetadataQuickFind } from './views/metadata-admin/QuickFind.js';
export { PageShell as MetadataPageShell } from './views/metadata-admin/PageShell.js';
export { SchemaForm } from './views/metadata-admin/SchemaForm.js';
export { LayeredDiff } from './views/metadata-admin/LayeredDiff.js';
// objectui#7611 — the Setup catalog's URL scope (`?scope=environment`), for a
// host that routes its own legacy URLs onto the catalog (the console's
// `system/positions` / `system/permissions`). A dependency-free leaf.
export { ENVIRONMENT_SCOPE_QUERY } from './views/metadata-admin/catalog-scope.js';
export {
registerMetadataResource,
getMetadataResource,
Expand Down
11 changes: 5 additions & 6 deletions packages/app-shell/src/services/builtinComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,15 +197,14 @@ registerMetadataResource({
domain: 'security',
EditPage: PermissionMatrixEditPage,
searchableFields: ['name', 'label'],
// objectui#7611 — no `managedBy` column: the registry serves no such key on a
// permission set (measured on objectstack main), so it read "Custom" for every
// set, the platform's own included. Provenance is the list's own Source badge,
// read from the served `_packageId` / `_provenance`.
listColumns: [
{ key: 'name', label: 'Name', width: '30%' },
{ key: 'label', label: 'Label', width: '30%' },
{
key: 'managedBy',
label: 'Source',
width: '15%',
render: (v) => (v === 'package' ? 'Package' : 'Custom'),
},
{ key: 'description', label: 'Description' },
],
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,17 @@ const asBareArray: Envelope = (rows) => rows;
const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length });
const asItems: Envelope = (rows) => ({ items: rows, total: rows.length });

/** A directory holding one set with one DIRECT grantee — nothing else. */
/**
* A directory holding one set with one DIRECT grantee — nothing else. The
* grant names its set (objectui#7611: holders are read by the set's NAME); no
* position distributes it (no metadata store is mounted, so the registry
* answers no positions).
*/
const DIRECTORY: Record<string, Record<string, unknown>[]> = {
sys_permission_set: [{ id: 'ps_1', name: 'showcase_contributor' }],
sys_user_permission_set: [{ id: 'grant_1', permission_set_id: 'ps_1', user_id: 'u_direct' }],
sys_position_permission_set: [],
sys_position: [],
sys_user_permission_set: [
{ id: 'grant_1', permission_set_id: 'ps_1', permission_set: 'showcase_contributor', user_id: 'u_direct' },
],
sys_user_position: [],
sys_user: [{ id: 'u_direct', name: 'Direct Dana', email: 'dana@example.com' }],
};
Expand Down
Loading
Loading