From ae8ba83fe883de3c43b7ee05ad6ed922519013f1 Mon Sep 17 00:00:00 2001 From: gibson9583 Date: Mon, 12 Oct 2026 15:02:38 -0400 Subject: [PATCH 1/2] fix: isolate Data Pruner grants and recover web authorization Keep pruning controls independent of channel permissions in both UIs. Allow authenticated plugin discovery and recover failed permission loads without stale sessions, duplicate Roles panels, or endless polling. Pin browser CI to the companion Web Client permission fixes. Refs gibson9583/oie-web-support-plugin#11 Refs gibson9583/oie-web-client#112 --- .github/workflows/build.yml | 9 +- CLAUDE.md | 2 +- README.md | 4 + .../client/SecureAuthorizationController.java | 7 + .../auth/client/PermissionMapDriftTest.java | 5 +- .../SecureAuthorizationControllerTest.java | 20 ++ .../rbac/RbacAuthorizationController.java | 7 + .../rbac/RbacAuthorizationControllerTest.java | 27 ++ webadmin/e2e/fail-closed.spec.js | 15 +- webadmin/e2e/fixtures.js | 2 +- webadmin/e2e/mock.js | 2 +- webadmin/e2e/web-permissions.spec.js | 168 ++++++++++++ webadmin/playwright.config.js | 2 +- webadmin/plugin.json | 3 +- webadmin/web/authorization-session.js | 95 +++++++ webadmin/web/authorization-session.test.mjs | 241 ++++++++++++++++++ webadmin/web/controller.test.mjs | 15 ++ webadmin/web/map-completeness.test.mjs | 2 +- webadmin/web/plugin.jsx | 46 +--- webadmin/web/task-permission-map.js | 7 + webadmin/web/task-permission-map.json | 4 + webadmin/web/web-task-catalog.json | 8 +- 22 files changed, 627 insertions(+), 64 deletions(-) create mode 100644 webadmin/e2e/web-permissions.spec.js create mode 100644 webadmin/web/authorization-session.js create mode 100644 webadmin/web/authorization-session.test.mjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 36b28c1..917ced3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,7 @@ # Builds and tests every push to main and every pull request. The Maven build # runs the Java tests and, in the package module, the web administrator's JS # unit tests (npm test). The e2e job runs the Playwright suite in webadmin/e2e -# against a pinned OIE web client release. +# against an immutable OIE web client commit containing the companion host fixes. # # The engine jars this plugin compiles against come from the published OIE # distribution, the same tarball scripts/install-engine-jars.sh uses for local @@ -106,12 +106,13 @@ jobs: # Browser tests: the real built plugin.js loaded into the OIE web client, with # the engine's REST API mocked in the browser (webadmin/e2e/mock.js), so no - # engine is needed. The web client is pinned to a release tag; bump it when a - # new web client release ships and the suite passes against it. + # engine is needed. The web client commit includes the permission-surface and + # authorization-bootstrap fixes required by the regression matrix. Keep this + # immutable; advance it only after the complete suite passes on the new host. e2e: runs-on: ubuntu-latest env: - OIE_WEB_CLIENT_REF: v1.0.1 + OIE_WEB_CLIENT_REF: aeb6acace6c682cb26d8fe48f55c74d3862de623 steps: - uses: actions/checkout@v4 diff --git a/CLAUDE.md b/CLAUDE.md index 08c9639..13c9326 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ Output: `package/target/rbac-1.1.3.zip` - `isUserAuthorized()` checks user's role permissions against operation-to-permission mapping - On denial: throws `MirthApiException` with custom 403 reason phrase (e.g., "Missing permission: viewMessages") — RuntimeException bypasses MirthServlet's catch block, custom reason flows through to client error dialog - Users assigned to a role flagged `is_admin` bypass all permission checks (admin safety). The bypass is by role flag, not by user ID — multiple admin users are supported. User ID 1 is only special at *seed* time (the seeder assigns it to the admin role on a fresh install) -- Infrastructure operations whitelisted for all authenticated users: `getStatus`, `getServerId`, and RBAC's own `getMyPermissions`/`getExtensionTaskPermissions` (whitelisted by their composite `Role-Based Access Control#…` runtime name, since they arrive through an extension servlet). `getAllUsers`/`getUser` were removed from the whitelist and are handled via `DEGRADABLE_OPERATIONS` instead (see below); `getServerSettings` was removed entirely (it leaked SMTP credentials) and now requires `SERVER_SETTINGS_VIEW`. +- Infrastructure operations whitelisted for all authenticated users: `getStatus`, `getServerId`, and RBAC's own `getMyPermissions`/`getExtensionTaskPermissions` (whitelisted by their composite `Role-Based Access Control#…` runtime name, since they arrive through an extension servlet). Web plugin discovery/static resources (`getWebPluginPaths`/`getWebPluginResource`, native and `Web Support#…` forms) are also allowed so users without a role can load RBAC's web controller. Other Web Support operations are not exempt. `getAllUsers`/`getUser` were removed from the whitelist and are handled via `DEGRADABLE_OPERATIONS` instead (see below); `getServerSettings` was removed entirely (it leaked SMTP credentials) and now requires `SERVER_SETTINGS_VIEW`. - `DEGRADABLE_OPERATIONS`: core engine reads annotated `@DontCheckAuthorized` (e.g. `getChannels`, `getAllUsers`, `getUser`) whose servlets self-degrade (empty/self-only) when the controller reports not-authorized. For these, the denial path returns `false` instead of throwing a 403, so the engine's graceful degradation works and limited-user login is not broken. Hand-maintained; re-verify on every engine bump. - Extension-operation map is keyed by the **composite** `"#"` name, because the engine wraps extension-servlet ops in `ExtensionOperation` (its `getName()` prepends the plugin name). Keying by the bare op name left RBAC's own management endpoints unenforced — a day-one privilege-escalation hole, now fixed. Side effect (intended): RBAC now enforces the permission-gated ops of *every* installed extension, not just its own (previously they all fell through to allow-unknown). The rare exception is a third-party plugin whose own read op is `@DontCheckAuthorized` and expects to self-degrade — RBAC returns a 403 rather than degrade, since only core reads are in `DEGRADABLE_OPERATIONS`. A plugin that registers a null/blank `extensionName` still falls through (keyed bare, misses). - Cache: `ConcurrentHashMap` invalidated on all write REST endpoints. A one-time startup sweep (`deleteOrphanUserRoles`) removes `rbac_user_role` rows for engine-deleted users. diff --git a/README.md b/README.md index 47a2532..26a6b74 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ Role Based Access Control (RBAC) plugin for [Open Integration Engine](https://gi Web admin ready: the same zip ships both UIs. Role management and role-based task gating work in the desktop (Swing) Administrator and in the [OIE Web Administrator](https://github.com/diridium-com/role-based-access-control/wiki/Web-Administrator), with matching behavior in each. The web UI's permission gating is also more complete than Swing's, which cannot hide buttons inside panel bodies (an engine limitation that applies to all plugins); either way, denied operations always fail server-side. On servers where the Web Administrator isn't used, the web module is inert. +Data Pruner uses its own View Settings, Save Settings, and Start / Stop grants; channel Start/Stop permission does not grant pruning access. The web manifest marks RBAC as an authorization plugin so compatible web clients can stop startup if it cannot load. Authenticated users without a role can still discover and download the web authorization plugin, and failed permission loads stay closed until both permission APIs recover. Recovery restores the Roles tab when View Roles is granted. The [matching Web Client fixes](https://github.com/gibson9583/oie-web-client/commit/aeb6acace6c682cb26d8fe48f55c74d3862de623) are also required to hide Data Pruner actions and restricted navigation correctly; the browser CI suite pins that companion commit. + +An unavailable permission snapshot gets at most three automatic retries, 15 seconds apart. This lets a short outage recover without user interaction while avoiding an indefinite background heartbeat that would keep an idle engine session alive. Subsequent task checks retain the throttled lazy retry, and signing out cancels pending retries. + Full documentation is in the [wiki](https://github.com/diridium-com/role-based-access-control/wiki). Role-Based Access Control settings tab in the OIE Administrator diff --git a/client/src/main/java/com/mirth/connect/plugins/auth/client/SecureAuthorizationController.java b/client/src/main/java/com/mirth/connect/plugins/auth/client/SecureAuthorizationController.java index 86e706f..5659865 100644 --- a/client/src/main/java/com/mirth/connect/plugins/auth/client/SecureAuthorizationController.java +++ b/client/src/main/java/com/mirth/connect/plugins/auth/client/SecureAuthorizationController.java @@ -384,6 +384,13 @@ private void buildPermissionMaps() { // self-scoped server-side, so it is safe to show to everyone. Leaving it unmapped // lets it fall through checkTask's unknown-task allow branch. + // Data Pruner's published bare doStart/doStop task names collide with + // channel controls. Its settings group must resolve before the bare map. + String prunerKey = TaskConstants.SETTINGS_KEY_PREFIX + "Data Pruner"; + addSettingsMapping(prunerKey, "View Settings", "Save Settings"); + groupTaskPermissionMap.put(prunerKey + "/doStart", "Start / Stop"); + groupTaskPermissionMap.put(prunerKey + "/doStop", "Start / Stop"); + // RBAC plugin settings panel String rbacKey = TaskConstants.SETTINGS_KEY_PREFIX + RbacServletInterface.PLUGIN_NAME; addSettingsMapping(rbacKey, RbacServletInterface.PERMISSION_VIEW, RbacServletInterface.PERMISSION_MANAGE); diff --git a/client/src/test/java/com/mirth/connect/plugins/auth/client/PermissionMapDriftTest.java b/client/src/test/java/com/mirth/connect/plugins/auth/client/PermissionMapDriftTest.java index d9f95cb..8a77f6d 100644 --- a/client/src/test/java/com/mirth/connect/plugins/auth/client/PermissionMapDriftTest.java +++ b/client/src/test/java/com/mirth/connect/plugins/auth/client/PermissionMapDriftTest.java @@ -91,7 +91,7 @@ private void assertMapValuesAreKnownPermissions(String fieldName) throws Excepti /** * @return every engine core permission value plus the RBAC plugin's two - * extension permissions + * extension permissions and the bundled Data Pruner's permissions */ private static Set knownPermissions() throws Exception { Set values = new HashSet<>(); @@ -102,6 +102,9 @@ private static Set knownPermissions() throws Exception { } values.add(RbacServletInterface.PERMISSION_VIEW); values.add(RbacServletInterface.PERMISSION_MANAGE); + // DataPrunerServletInterface's extension permission values. The engine + // publishes these separately from its core Permissions constants. + values.addAll(Set.of("View Settings", "Save Settings", "Start / Stop")); return values; } diff --git a/client/src/test/java/com/mirth/connect/plugins/auth/client/SecureAuthorizationControllerTest.java b/client/src/test/java/com/mirth/connect/plugins/auth/client/SecureAuthorizationControllerTest.java index 63e5bfa..9ddd28c 100644 --- a/client/src/test/java/com/mirth/connect/plugins/auth/client/SecureAuthorizationControllerTest.java +++ b/client/src/test/java/com/mirth/connect/plugins/auth/client/SecureAuthorizationControllerTest.java @@ -62,6 +62,26 @@ void checkTask_settingsRefresh_resolvesByGroup() throws Exception { assertFalse(denied.checkTask(TaskConstants.SETTINGS_SERVER_KEY, TaskConstants.SETTINGS_REFRESH)); } + @Test + void checkTask_dataPrunerGrantsDoNotImplyChannelControlOrOtherSettingsAccess() throws Exception { + String group = TaskConstants.SETTINGS_KEY_PREFIX + "Data Pruner"; + for (String permission : Set.of("View Settings", "Save Settings", "Start / Stop", + "startStopChannels", "viewServerSettings", "editServerSettings")) { + SecureAuthorizationController c = withPermissions(Set.of(permission)); + for (String[] task : new String[][] { + { "doRefresh", "View Settings" }, { "doSave", "Save Settings" }, + { "doStart", "Start / Stop" }, { "doStop", "Start / Stop" } + }) { + org.junit.jupiter.api.Assertions.assertEquals(permission.equals(task[1]), + c.checkTask(group, task[0]), permission + ": " + task[0]); + } + for (String task : new String[] { "doStart", "doStop" }) { + org.junit.jupiter.api.Assertions.assertEquals(permission.equals("startStopChannels"), + c.checkTask("dashboard", task), permission + ": dashboard/" + task); + } + } + } + @Test void checkTask_rbacSettingsPanel_requiresViewRoles() throws Exception { String rbacKey = TaskConstants.SETTINGS_KEY_PREFIX + RbacServletInterface.PLUGIN_NAME; diff --git a/server/src/main/java/com/diridium/rbac/RbacAuthorizationController.java b/server/src/main/java/com/diridium/rbac/RbacAuthorizationController.java index e97d81a..3424891 100644 --- a/server/src/main/java/com/diridium/rbac/RbacAuthorizationController.java +++ b/server/src/main/java/com/diridium/rbac/RbacAuthorizationController.java @@ -50,6 +50,13 @@ public class RbacAuthorizationController extends AuthorizationController { "getExtensionTaskPermissions", // bare; inert at runtime, kept for the whitelist test RbacServletInterface.PLUGIN_NAME + "#getMyPermissions", // composite; real match RbacServletInterface.PLUGIN_NAME + "#getExtensionTaskPermissions", // composite; real match + // Authenticated clients must discover and load RBAC even without + // an assigned role. Only these read-only web assets are exempt; + // Web Support's validation/serialization operations are not. + "getWebPluginPaths", // native engine servlet + "getWebPluginResource", // native engine servlet + "Web Support#getWebPluginPaths", // Web Support extension servlet + "Web Support#getWebPluginResource", // Web Support extension servlet "getStatus", // Server status checks "getServerId" // Server identification )); diff --git a/server/src/test/java/com/diridium/rbac/RbacAuthorizationControllerTest.java b/server/src/test/java/com/diridium/rbac/RbacAuthorizationControllerTest.java index 764a5e3..209442b 100644 --- a/server/src/test/java/com/diridium/rbac/RbacAuthorizationControllerTest.java +++ b/server/src/test/java/com/diridium/rbac/RbacAuthorizationControllerTest.java @@ -130,6 +130,33 @@ void infrastructureOperation_compositeRbacNames_authorized() throws Exception { assertTrue(controller.isUserAuthorized(2, op(plugin + "#getExtensionTaskPermissions"), null, "127.0.0.1", false)); } + @Test + void webBootstrap_noRoleUserCanDiscoverAndLoadAuthorizationPlugin() throws Exception { + when(repo.getUserRoleId(2)).thenReturn(null); + for (String prefix : new String[] { "", "Web Support#" }) { + for (String operation : new String[] { "getWebPluginPaths", "getWebPluginResource" }) { + assertTrue(controller.isUserAuthorized(2, op(prefix + operation), null, "127.0.0.1", false), + prefix + operation); + } + } + } + + @Test + void webBootstrapExemptionDoesNotAllowOtherExtensionOperations() { + when(repo.getUserRoleId(2)).thenReturn(null); + for (String operation : new String[] { + "Web Support#validateScript", "Web Support#serializeMessage", + "Other Plugin#getWebPluginPaths", "Other Plugin#getWebPluginResource", + "Web Support#getWebPluginPathsExtra", "Web Support#setWebPluginResource", + "setWebPluginResource", "getServerSettings", "getGlobalScripts", + "Role-Based Access Control#getRoles", "Data Pruner#startDataPruner", "Data Pruner#stopDataPruner" + }) { + MirthApiException denied = assertThrows(MirthApiException.class, + () -> controller.isUserAuthorized(2, op(operation), null, "127.0.0.1", false), operation); + assertEquals(403, denied.getResponse().getStatus(), operation); + } + } + @Test void serverSettings_notWhitelisted_deniedWithoutPermission() { // getServerSettings was removed from the whitelist (it leaks SMTP credentials); diff --git a/webadmin/e2e/fail-closed.spec.js b/webadmin/e2e/fail-closed.spec.js index 95caa20..13e0b14 100644 --- a/webadmin/e2e/fail-closed.spec.js +++ b/webadmin/e2e/fail-closed.spec.js @@ -52,19 +52,16 @@ test.describe('P4 my-permissions 500 (fail closed)', () => { const menu = page.getByRole('menu'); await expect(menu.getByRole('menuitem', { name: 'Edit Account', exact: true })).toBeVisible(); await expect(menu.getByRole('menuitem', { name: 'Change Password', exact: true })).toBeVisible(); - // Its tagged items (view/doShowSettings, other/doLogout) are hidden. + // Settings fails closed. Account sign-out remains available so a user + // can leave a failed authorization session safely. await expect(menu.getByRole('menuitem', { name: 'Settings', exact: true })).toHaveCount(0); - await expect(menu.getByRole('menuitem', { name: 'Sign out', exact: true })).toHaveCount(0); + await expect(menu.getByRole('menuitem', { name: 'Sign out', exact: true })).toBeVisible(); }); - // fixme: web client 1.0.1 gates the built-in settings tabs too, so failing - // closed leaves no tab at all, and the Settings view then crashes on - // defs[0].label ("This view failed to render") before any tab renders. - // Re-enable once the web client handles an empty tab list - // (https://github.com/gibson9583/oie-web-client/issues/97). - test.fixme('the RBAC settings tab is skipped on a failed permission load', async ({ page }) => { + test('the Settings view safely handles no tabs on a failed permission load', async ({ page }) => { await page.goto('/settings'); - await expect(page.getByRole('tab', { name: 'Server', exact: true })).toBeVisible(); + await expect(page.getByText('No settings are available with your current permissions.', { exact: true })).toBeVisible(); + await expect(page.getByRole('tab')).toHaveCount(0); await expect(page.getByRole('tab', { name: 'Role-Based Access Control', exact: true })).toHaveCount(0); }); }); diff --git a/webadmin/e2e/fixtures.js b/webadmin/e2e/fixtures.js index 9ca17bd..113ec8f 100644 --- a/webadmin/e2e/fixtures.js +++ b/webadmin/e2e/fixtures.js @@ -68,7 +68,7 @@ export const DEFAULT_FIXTURES = { 'GET /server/timezone': 'EST (UTC -5)', 'GET /server/settings': { serverSettings: { serverName: 'E2E Engine', environmentName: 'test' } }, 'GET /server/about': '', - 'GET /server/channelTags': '', + 'GET /server/channelTags': '', 'GET /server/channelDependencies': '', 'GET /server/channelMetadata': {}, diff --git a/webadmin/e2e/mock.js b/webadmin/e2e/mock.js index 92c5538..75d750e 100644 --- a/webadmin/e2e/mock.js +++ b/webadmin/e2e/mock.js @@ -43,7 +43,7 @@ export async function mockEngine(page, overrides = {}) { } } - if (typeof fx === 'function') fx = fx(req); + if (typeof fx === 'function') fx = await fx(req); if (fx === undefined) { return route.fulfill({ status: 200, contentType: 'text/plain', body: '' }); } diff --git a/webadmin/e2e/web-permissions.spec.js b/webadmin/e2e/web-permissions.spec.js new file mode 100644 index 0000000..8732e55 --- /dev/null +++ b/webadmin/e2e/web-permissions.spec.js @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright 2025-2026 Diridium Technologies Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Real built RBAC bundle + real host surfaces; only engine HTTP is mocked. +import { test, expect } from '@playwright/test'; +import { mockEngineWithRbac, login } from './mock.js'; +import { rbacFixtures, permissionSet, taskPermissionMap } from './fixtures.js'; + +const properties = { enabled: 'true', pruningBlockSize: '1000', pruneEvents: 'false', archiveEnabled: 'false' }; +const prunerProperties = { properties: { property: Object.entries(properties).map(([name, value]) => ({ '@name': name, $: value })) } }; +const status = running => ({ map: { entry: { string: ['isRunning', String(running)] } } }); + +async function setup(page, permissions, overrides = {}) { + const writes = []; + page.on('request', request => { + if (request.method() !== 'GET' && /\/api\/extensions\/(Data%20Pruner|datapruner)/.test(request.url())) { + writes.push(`${request.method()} ${new URL(request.url()).pathname}`); + } + }); + await mockEngineWithRbac(page, { + ...rbacFixtures({ myPermissions: permissions }), + 'GET /extensions/Data%20Pruner/properties': prunerProperties, + 'GET /extensions/datapruner/status': status(false), + ...overrides + }); + return writes; +} + +async function openPruner(page) { + await page.goto('/settings'); + await page.getByRole('tab', { name: 'Data Pruner', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Refresh', exact: true })).toBeEnabled(); + await expect(page.getByText('Block Size', { exact: true })).toBeVisible(); +} + +async function openPalette(page) { + await page.keyboard.press('Control+k'); + await expect(page.locator('.cmdk')).toBeVisible(); +} + +test('Data Pruner viewer sees Refresh but no Save, Prune, Stop or Events actions', async ({ page }) => { + const writes = await setup(page, ['View Settings']); + await openPruner(page); + for (const name of ['Save', 'Prune Now', 'Stop Pruner', 'View Events']) { + await expect(page.getByRole('button', { name, exact: true })).toHaveCount(0); + } + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Refresh', exact: true })).toBeEnabled(); + expect(writes).toEqual([]); +}); + +test('channel-control grant does not grant Data Pruner control', async ({ page }) => { + const writes = await setup(page, ['View Settings', 'viewDashboard', 'startStopChannels']); + await openPruner(page); + for (const name of ['Prune Now', 'Stop Pruner']) { + await expect(page.getByRole('button', { name, exact: true })).toHaveCount(0); + } + await page.getByRole('button', { name: 'Dashboard', exact: true }).click(); + await page.getByText('Demo Stopped', { exact: true }).click(); + await expect(page.getByRole('button', { name: 'Start', exact: true })).toBeVisible(); + expect(writes).toEqual([]); +}); + +test('pruner-control grant starts and stops pruning without channel-control permission', async ({ page }) => { + let running = false; + const writes = await setup(page, ['View Settings', 'Start / Stop', 'viewDashboard'], { + 'GET /extensions/datapruner/status': () => status(running), + 'POST /extensions/datapruner/_start': () => { running = true; return ''; }, + 'POST /extensions/datapruner/_stop': () => { running = false; return ''; } + }); + await openPruner(page); + await expect(page.getByRole('button', { name: 'Prune Now', exact: true })).toBeEnabled(); + await expect(page.getByRole('button', { name: 'Stop Pruner', exact: true })).toBeDisabled(); + await page.getByRole('button', { name: 'Prune Now', exact: true }).click(); + await page.getByRole('dialog', { name: 'Prune Now', exact: true }).getByRole('button', { name: 'Start', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Stop Pruner', exact: true })).toBeEnabled(); + await expect(page.getByRole('button', { name: 'Prune Now', exact: true })).toBeDisabled(); + await page.getByRole('button', { name: 'Stop Pruner', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Prune Now', exact: true })).toBeEnabled(); + expect(writes).toEqual(['POST /api/extensions/datapruner/_start', 'POST /api/extensions/datapruner/_stop']); + await page.getByRole('button', { name: 'Dashboard', exact: true }).click(); + await page.getByText('Demo Stopped', { exact: true }).click(); + await expect(page.getByRole('button', { name: 'Start', exact: true })).toHaveCount(0); +}); + +for (const failed of [false, true]) { + test(`script/template navigation and palette entries hide with ${failed ? 'failed' : 'empty'} permissions`, async ({ page }) => { + await setup(page, [], failed ? { 'GET /extensions/rbac/my-permissions': { __status: 503 } } : {}); + await page.goto('/dashboard'); + await expect(page.getByText('Demo Started', { exact: true })).toBeVisible(); + for (const name of ['Global Scripts', 'Code Templates']) { + await expect(page.getByRole('button', { name, exact: true })).toHaveCount(0); + } + await openPalette(page); + for (const name of ['Global Scripts', 'Code Templates']) { + await page.locator('.cmdk-field input').fill(name); + await expect(page.locator('.cmdk-opt').filter({ hasText: name })).toHaveCount(0); + } + }); +} + +test('Settings commands match hidden tabs, including Data Pruner without extension-supplied mappings', async ({ page }) => { + await setup(page, [], { 'GET /extensions/rbac/task-permissions': taskPermissionMap([]) }); + await page.goto('/settings'); + await expect(page.getByRole('tab', { name: 'Administrator', exact: true })).toBeVisible(); + const tabs = ['Data Pruner', 'Server', 'Configuration Map', 'Database Tasks', 'Resources', 'Role-Based Access Control']; + for (const name of tabs) await expect(page.getByRole('tab', { name, exact: true })).toHaveCount(0); + await openPalette(page); + for (const name of tabs) { + await page.locator('.cmdk-field input').fill('Settings: ' + name); + await expect(page.locator('.cmdk-opt').filter({ hasText: 'Settings: ' + name })).toHaveCount(0); + } + await page.locator('.cmdk-field input').fill('Settings: Administrator'); + await expect(page.locator('.cmdk-opt').filter({ hasText: 'Settings: Administrator' })).toBeVisible(); +}); + +for (const failedPath of ['my-permissions', 'task-permissions']) { + test(`Roles and mounted Settings recover after ${failedPath} fails`, async ({ page }) => { + await page.clock.install(); + let failing = true; + let calls = 0; + const fixtures = rbacFixtures({ myPermissions: ['View Roles'], taskPermissions: taskPermissionMap([]) }); + const key = 'GET /extensions/rbac/' + failedPath; + await setup(page, ['View Roles'], { + ...fixtures, + [key]: () => { calls++; return failing ? { __status: 503 } : fixtures[key]; } + }); + await page.goto('/settings'); + await expect(page.getByText('No settings are available with your current permissions.', { exact: true })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Role-Based Access Control', exact: true })).toHaveCount(0); + failing = false; + await page.clock.fastForward(16000); + // No further click/render is necessary to recover a completely empty view. + await expect(page.getByRole('tab', { name: 'Role-Based Access Control', exact: true })).toHaveCount(1); + await page.getByRole('tab', { name: 'Role-Based Access Control', exact: true }).click(); + await expect(page.getByText('Roles', { exact: true })).toBeVisible(); + const recoveredCalls = calls; + await page.clock.fastForward(16000); + await openPalette(page); + await page.locator('.cmdk-field input').fill('Settings: Role-Based Access Control'); + await expect(page.locator('.cmdk-opt').filter({ hasText: 'Settings: Role-Based Access Control' })).toHaveCount(1); + await page.keyboard.press('Escape'); + expect(calls).toBe(recoveredCalls, 'successful recovery does not keep fetching'); + await expect(page.getByRole('tab', { name: 'Role-Based Access Control', exact: true })).toHaveCount(1); + }); +} + +test('same-tab account change reloads RBAC permissions before showing the new session', async ({ page }) => { + let loggedIn = true, restricted = false; + await setup(page, ['View Roles', 'viewGlobalScripts'], { + 'GET /users/current': () => loggedIn ? { user: { id: restricted ? 2 : 1, username: restricted ? 'limited' : 'admin' } } : { __status: 401 }, + 'POST /users/_logout': () => { loggedIn = false; return ''; }, + 'POST /users/_login': () => { loggedIn = true; restricted = true; return { status: 'SUCCESS', message: 'ok' }; }, + 'GET /extensions/rbac/task-permissions': taskPermissionMap([]), + 'GET /extensions/rbac/my-permissions': () => permissionSet(restricted ? [] : ['View Roles', 'viewGlobalScripts']) + }); + await page.goto('/settings'); + await expect(page.getByRole('tab', { name: 'Role-Based Access Control', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Global Scripts', exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Logout', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible(); + await login(page, 'limited'); + await expect(page.getByRole('button', { name: 'Settings', exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Settings', exact: true }).click(); + await expect(page.getByRole('tab', { name: 'Administrator', exact: true })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Role-Based Access Control', exact: true })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Global Scripts', exact: true })).toHaveCount(0); +}); diff --git a/webadmin/playwright.config.js b/webadmin/playwright.config.js index 653ddd9..d990e4e 100644 --- a/webadmin/playwright.config.js +++ b/webadmin/playwright.config.js @@ -7,7 +7,7 @@ * needed) and loads the REAL built web/plugin.js the way an engine would serve * it. Point WEBADMIN_DIR at your oie-web-client checkout if it is not the * sibling of this repository. The checkout must be built first (CI pins the - * release tag in .github/workflows/build.yml): + * companion host commit in .github/workflows/build.yml): * * (cd ~/src/oie-web-client && npm ci && npm run build -w web-administrator) * WEBADMIN_DIR=~/src/oie-web-client npx playwright test diff --git a/webadmin/plugin.json b/webadmin/plugin.json index b52872b..6623247 100644 --- a/webadmin/plugin.json +++ b/webadmin/plugin.json @@ -5,5 +5,6 @@ "author": "Diridium Technologies Inc.", "description": "Role management and role-based UI hiding for the web administrator: a Role-Based Access Control settings tab (roles, permission grants, channel restrictions, user assignments) plus task-permission enforcement that hides navigation and task buttons the user's role does not grant. Web administrator UI for the role-based-access-control engine plugin.", "oie": { "apiMin": "4.6" }, - "client": { "entry": "web/plugin.js" } + "client": { "entry": "web/plugin.js" }, + "authorization": true } diff --git a/webadmin/web/authorization-session.js b/webadmin/web/authorization-session.js new file mode 100644 index 0000000..1615b8a --- /dev/null +++ b/webadmin/web/authorization-session.js @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: Copyright 2025-2026 Diridium Technologies Inc. +// SPDX-License-Identifier: MPL-2.0 + +import { mergeExtensionTaskPermissions, createController, RELOAD_BACKOFF_MS } from './task-permission-map.js'; + +const PERMISSION_VIEW = 'View Roles'; +export const MAX_AUTOMATIC_RETRIES = 3; +const userKey = (user) => user == null ? null : String(user.id ?? user.username); + +/** Install the permission snapshot and recover it atomically after a failed load. */ +export async function registerAuthorization(platform, api, registerRoles) { + const owner = userKey(platform.store.getState('user')); + let active = owner !== null; + let permissions = null; + let panelRegistered = false; + let inFlight = null; + let retryTimer = null; + let automaticRetries = 0; + + function clearRetry() { + if (retryTimer !== null) clearTimeout(retryTimer); + retryTimer = null; + } + + // A logout invalidates this registration permanently, even if the same user + // signs in again before an old request finishes. Profile updates for the + // same user do not invalidate the session. + let unsubscribe; + const isCurrent = () => active && userKey(platform.store.getState('user')) === owner; + unsubscribe = platform.store.subscribe('user', (user) => { + if (userKey(user) !== owner) { + active = false; + permissions = null; + clearRetry(); + unsubscribe?.(); + } + }); + + function fetchPermissions(notify = false) { + if (!isCurrent()) return Promise.resolve(); + if (inFlight) return inFlight; + clearRetry(); + inFlight = (async () => { + const [perms, taskPerms] = await Promise.all([ + api.getMyPermissions(), + api.getExtensionTaskPermissions() + ]); + // Neither the permissions nor extension mappings may cross a + // session boundary or be applied from a partially successful load. + if (!isCurrent()) return; + mergeExtensionTaskPermissions(taskPerms); + if (perms.has(PERMISSION_VIEW) && !panelRegistered) { + registerRoles(platform, api); + panelRegistered = true; + } + permissions = perms; + if (notify) { + // Settings definitions, navigation and tasks must all observe + // a Roles panel registered during recovery without a reload. + platform.store.setState('webPlugins', [...(platform.store.getState('webPlugins') ?? [])]); + } + })().catch(error => { + if (isCurrent() && automaticRetries < MAX_AUTOMATIC_RETRIES) { + // Empty settings/navigation has no guaranteed future render to + // trigger checkTask's lazy retry. Recover without user input. + // Stop after a short outage window: indefinite background API + // requests would keep the engine's idle session alive. Lazy + // task checks can still retry, but never replenish this budget. + retryTimer = setTimeout(() => { + retryTimer = null; + automaticRetries++; + fetchPermissions(true).catch(() => {}); + }, RELOAD_BACKOFF_MS); + } + throw error; + }).finally(() => { inFlight = null; }); + return inFlight; + } + + try { + await fetchPermissions(); + } catch (e) { + console.warn('[rbac] permission load failed — failing closed:', e); + } + + if (!isCurrent()) { + clearRetry(); + unsubscribe?.(); + return; + } + platform.setAuthorizationController(createController({ + getPermissions: () => isCurrent() ? permissions : null, + reload: () => fetchPermissions(true) + })); +} diff --git a/webadmin/web/authorization-session.test.mjs b/webadmin/web/authorization-session.test.mjs new file mode 100644 index 0000000..ae5d8ca --- /dev/null +++ b/webadmin/web/authorization-session.test.mjs @@ -0,0 +1,241 @@ +// SPDX-FileCopyrightText: Copyright 2025-2026 Diridium Technologies Inc. +// SPDX-License-Identifier: MPL-2.0 + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { registerAuthorization, MAX_AUTOMATIC_RETRIES } from './authorization-session.js'; +import { GROUP_TASK_PERMISSIONS, RELOAD_BACKOFF_MS } from './task-permission-map.js'; + +const flush = () => new Promise(resolve => setImmediate(resolve)); +const deferred = () => { + let resolve, reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +}; + +function harness(t) { + t.mock.method(console, 'warn', () => {}); + let now = 100_000; + t.mock.method(Date, 'now', () => now); + const state = { user: { id: 7, username: 'viewer' }, webPlugins: [] }; + const listeners = new Map(); + const changes = []; + const registrations = []; + const calls = { permissions: 0, tasks: 0 }; + let permissions = async () => new Set(['View Roles']); + let tasks = async () => ({}); + const platform = { + store: { + getState: key => state[key], + setState(key, value) { + state[key] = value; + changes.push(key); + for (const listener of [...(listeners.get(key) ?? [])]) listener(value); + }, + subscribe(key, listener) { + const group = listeners.get(key) ?? new Set(); + listeners.set(key, group); + group.add(listener); + return () => group.delete(listener); + } + }, + setAuthorizationController(controller) { platform.controller = controller; } + }; + const api = { + getMyPermissions() { calls.permissions++; return permissions(); }, + getExtensionTaskPermissions() { calls.tasks++; return tasks(); } + }; + t.after(() => platform.store.setState('user', null)); + return { + platform, api, calls, registrations, changes, + register: () => registerAuthorization(platform, api, () => registrations.push('Roles')), + permissions: fn => { permissions = fn; }, + tasks: fn => { tasks = fn; }, + retry: () => { + now += RELOAD_BACKOFF_MS + 1; + assert.equal(platform.controller.checkTask('settings_Role-Based Access Control', 'doRefresh'), false); + }, + setUser: user => platform.store.setState('user', user) + }; +} + +for (const prerequisite of ['permissions', 'tasks']) { + test(`Roles registers once after initial ${prerequisite} failure; simultaneous retries share one request`, async t => { + const h = harness(t); + h[prerequisite](async () => { throw new Error('unavailable'); }); + await h.register(); + assert.deepEqual(h.registrations, []); + const originalPlugins = h.platform.store.getState('webPlugins'); + const perms = deferred(), tasks = deferred(); + h.permissions(() => perms.promise); + h.tasks(() => tasks.promise); + h.retry(); + await flush(); + h.retry(); // even after the backoff expires, one request remains in flight + await flush(); + assert.deepEqual(h.calls, { permissions: 2, tasks: 2 }); + perms.resolve(new Set(['View Roles'])); + await flush(); + assert.deepEqual(h.registrations, [], 'both prerequisites must succeed'); + tasks.resolve({}); + await flush(); + assert.deepEqual(h.registrations, ['Roles']); + assert.equal(h.platform.controller.checkTask('settings_Role-Based Access Control', 'doRefresh'), true); + assert.equal(h.platform.controller.checkTask('settings_Role-Based Access Control', 'doSave'), false); + assert.deepEqual(h.changes, ['webPlugins'], 'one successful recovery publishes one notification'); + assert.notEqual(h.platform.store.getState('webPlugins'), originalPlugins, + 'React external-store subscribers require a fresh snapshot reference'); + await flush(); + assert.deepEqual(h.calls, { permissions: 2, tasks: 2 }, 'healthy checks do not reload'); + }); +} + +test('healthy initial registration exposes Roles without a retry', async t => { + const h = harness(t); + await h.register(); + assert.deepEqual(h.registrations, ['Roles']); + assert.equal(h.platform.controller.checkTask('settings_Role-Based Access Control', 'doRefresh'), true); + assert.deepEqual(h.calls, { permissions: 1, tasks: 1 }); +}); + +test('recovery with an empty or Manage-only permission set never registers Roles', async t => { + for (const recovered of [new Set(), new Set(['Manage Roles'])]) { + const h = harness(t); + h.permissions(async () => { throw new Error('unavailable'); }); + await h.register(); + h.permissions(async () => recovered); + h.retry(); + await flush(); + assert.deepEqual(h.registrations, []); + assert.equal(h.platform.controller.checkTask('settings_Role-Based Access Control', 'doRefresh'), false); + assert.deepEqual(h.calls, { permissions: 2, tasks: 2 }); + assert.deepEqual(h.changes, ['webPlugins']); + } +}); + +test('a second failed prerequisite keeps the whole snapshot closed until both recover', async t => { + const h = harness(t); + h.permissions(async () => { throw new Error('permissions down'); }); + await h.register(); + h.permissions(async () => new Set(['View Roles'])); + h.tasks(async () => { throw new Error('mappings down'); }); + h.retry(); + await flush(); + assert.deepEqual(h.registrations, []); + assert.deepEqual(h.changes, []); + h.tasks(async () => ({})); + h.retry(); + await flush(); + assert.deepEqual(h.registrations, ['Roles']); +}); + +for (const transition of ['logout', 'different user', 'logout then same user']) { + test(`recovery response after ${transition} cannot publish permissions, mappings, or Roles`, async t => { + const h = harness(t); + h.permissions(async () => { throw new Error('unavailable'); }); + await h.register(); + const perms = deferred(); + const mapping = `settings_Stale ${transition}/doRefresh`; + h.permissions(() => perms.promise); + h.tasks(async () => ({ [mapping]: 'View Roles' })); + h.retry(); + await flush(); + h.setUser(transition === 'different user' ? { id: 8 } : null); + if (transition === 'logout then same user') h.setUser({ id: 7 }); + perms.resolve(new Set(['View Roles'])); + await flush(); + assert.deepEqual(h.registrations, []); + assert.equal(Object.hasOwn(GROUP_TASK_PERMISSIONS, mapping), false); + assert.equal(h.changes.includes('webPlugins'), false); + h.retry(); + await flush(); + assert.deepEqual(h.calls, { permissions: 2, tasks: 2 }, 'expired registration never retries'); + }); +} + +test('initial response after logout cannot install a stale controller', async t => { + const h = harness(t); + const perms = deferred(); + h.permissions(() => perms.promise); + const pending = h.register(); + h.setUser(null); + perms.resolve(new Set(['View Roles'])); + await pending; + assert.equal(h.platform.controller, undefined); + assert.deepEqual(h.registrations, []); +}); + +test('loaded controller denies after logout; a same-user profile update retains access', async t => { + const h = harness(t); + await h.register(); + h.setUser({ id: 7, username: 'renamed' }); + assert.equal(h.platform.controller.checkTask('settings_Role-Based Access Control', 'doRefresh'), true); + h.setUser(null); + h.setUser({ id: 7 }); + h.retry(); + await flush(); + assert.deepEqual(h.calls, { permissions: 1, tasks: 1 }); +}); + +test('a failed snapshot retries automatically without rendering; success cancels further retries', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(t); + h.permissions(async () => { throw new Error('unavailable'); }); + await h.register(); + t.mock.timers.tick(RELOAD_BACKOFF_MS); + await flush(); + assert.deepEqual(h.calls, { permissions: 2, tasks: 2 }); + assert.deepEqual(h.registrations, []); + h.permissions(async () => new Set(['View Roles'])); + t.mock.timers.tick(RELOAD_BACKOFF_MS); + await flush(); + assert.deepEqual(h.registrations, ['Roles']); + assert.deepEqual(h.changes, ['webPlugins']); + t.mock.timers.tick(RELOAD_BACKOFF_MS * 4); + await flush(); + assert.deepEqual(h.calls, { permissions: 3, tasks: 3 }); +}); + +test('logout cancels an automatic retry, even after the same user signs back in', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(t); + h.permissions(async () => { throw new Error('unavailable'); }); + await h.register(); + h.setUser(null); + h.setUser({ id: 7 }); + t.mock.timers.tick(RELOAD_BACKOFF_MS * 4); + await flush(); + assert.deepEqual(h.calls, { permissions: 1, tasks: 1 }); +}); + +test('automatic retries exhaust their budget; later lazy checks can still recover without restarting timers', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(t); + h.permissions(async () => { throw new Error('unavailable'); }); + await h.register(); + for (let attempt = 0; attempt < MAX_AUTOMATIC_RETRIES; attempt++) { + t.mock.timers.tick(RELOAD_BACKOFF_MS); + await flush(); + } + assert.deepEqual(h.calls, { permissions: 4, tasks: 4 }, 'initial fetch plus three automatic attempts'); + t.mock.timers.tick(RELOAD_BACKOFF_MS * 100); + await flush(); + assert.deepEqual(h.calls, { permissions: 4, tasks: 4 }, 'idle page stops issuing permission requests'); + assert.deepEqual(h.registrations, []); + assert.deepEqual(h.changes, []); + + h.retry(); // A render-triggered retry still works after the automatic budget. + await flush(); + assert.deepEqual(h.calls, { permissions: 5, tasks: 5 }); + t.mock.timers.tick(RELOAD_BACKOFF_MS * 100); + await flush(); + assert.deepEqual(h.calls, { permissions: 5, tasks: 5 }, 'lazy failure does not restart automatic polling'); + + h.permissions(async () => new Set(['View Roles'])); + h.retry(); + await flush(); + assert.deepEqual(h.calls, { permissions: 6, tasks: 6 }); + assert.deepEqual(h.registrations, ['Roles']); + assert.deepEqual(h.changes, ['webPlugins']); + assert.equal(h.platform.controller.checkTask('settings_Role-Based Access Control', 'doRefresh'), true); +}); diff --git a/webadmin/web/controller.test.mjs b/webadmin/web/controller.test.mjs index 166a989..b52ffb8 100644 --- a/webadmin/web/controller.test.mjs +++ b/webadmin/web/controller.test.mjs @@ -243,3 +243,18 @@ test('settings_Role-Based Access Control pair: View Roles / Manage Roles', () => assert.equal(manager.checkTask('settings_Role-Based Access Control', 'doRefresh'), false, 'doRefresh maps to "View Roles" - Manage alone does not imply View'); }); + +test('Data Pruner permissions are independent of channel control and settings permissions', () => { + for (const permission of ['View Settings', 'Save Settings', 'Start / Stop', 'startStopChannels', 'viewServerSettings', 'editServerSettings']) { + const controller = controllerFor(new Set([permission])); + for (const [task, required] of [ + ['doRefresh', 'View Settings'], ['doSave', 'Save Settings'], + ['doStart', 'Start / Stop'], ['doStop', 'Start / Stop'] + ]) { + assert.equal(controller.checkTask('settings_Data Pruner', task), permission === required, `${permission}: ${task}`); + } + for (const task of ['doStart', 'doStop']) { + assert.equal(controller.checkTask('dashboard', task), permission === 'startStopChannels'); + } + } +}); diff --git a/webadmin/web/map-completeness.test.mjs b/webadmin/web/map-completeness.test.mjs index 41bb91d..9848a05 100644 --- a/webadmin/web/map-completeness.test.mjs +++ b/webadmin/web/map-completeness.test.mjs @@ -52,7 +52,7 @@ for (const [group, tasks] of Object.entries(groups)) { test('catalog snapshot is well-formed', () => { assert.ok(typeof catalog.$comment === 'string' && catalog.$comment.includes('RBAC.md'), 'snapshot must carry its provenance note'); - assert.equal(Object.keys(groups).length, 20, 'RBAC.md §3 defines 20 task groups'); + assert.equal(Object.keys(groups).length, 21, 'RBAC.md §3 defines 21 task groups including Data Pruner'); for (const [group, tasks] of Object.entries(groups)) { assert.ok(Array.isArray(tasks) && tasks.length > 0, `group "${group}" must be a non-empty array`); assert.equal(new Set(tasks).size, tasks.length, `group "${group}" has duplicate tasks`); diff --git a/webadmin/web/plugin.jsx b/webadmin/web/plugin.jsx index 5f6adfe..3c69f57 100644 --- a/webadmin/web/plugin.jsx +++ b/webadmin/web/plugin.jsx @@ -15,53 +15,13 @@ * The Roles settings tab registers ONLY when the user's permissions include * "View Roles" (RbacServletInterface.PERMISSION_VIEW) — hidden-tab decision, * matching how Swing users without the permission never get a working panel. - * A failed permission fetch also skips it (no tab is better than a dead one). + * A failed permission fetch skips it until a successful retry grants View Roles. */ import { makeApi } from './rbac-api.js'; -import { mergeExtensionTaskPermissions, createController } from './task-permission-map.js'; +import { registerAuthorization } from './authorization-session.js'; import { registerRolesPanel } from './roles-panel.jsx'; -// RbacServletInterface.PERMISSION_VIEW — gate for the Roles settings tab -// (same string GROUP_TASK_PERMISSIONS maps for .../doRefresh). -const PERMISSION_VIEW = 'View Roles'; - export async function register(platform) { - const api = makeApi(platform.api); - - // The user's loaded permission set; null = "could not be loaded", which the - // controller treats as deny-everything (fail closed). - let permissions = null; - - async function fetchPermissions() { - const [perms, taskPerms] = await Promise.all([ - api.getMyPermissions(), - api.getExtensionTaskPermissions() - ]); - // Other extensions' task→permission mappings merge putIfAbsent - // (hardcoded entries win); repeat merges after the first are no-ops. - mergeExtensionTaskPermissions(taskPerms); - permissions = perms; - } - - try { - await fetchPermissions(); - } catch (e) { - console.warn('[rbac] permission load failed — failing closed:', e); - } - - platform.setAuthorizationController(createController({ - getPermissions: () => permissions, - // Fired (throttled, fire-and-forget) by the fail-closed path. On success, - // re-set webPlugins to itself: setState always notifies, forcing the nav - // and any mounted task panes to re-run checkTask with real permissions. - reload: async () => { - await fetchPermissions(); - platform.store.setState('webPlugins', platform.store.getState('webPlugins')); - } - })); - - if (permissions !== null && permissions.has(PERMISSION_VIEW)) { - registerRolesPanel(platform, api); - } + await registerAuthorization(platform, makeApi(platform.api), registerRolesPanel); } diff --git a/webadmin/web/task-permission-map.js b/webadmin/web/task-permission-map.js index df00f75..b47aa22 100644 --- a/webadmin/web/task-permission-map.js +++ b/webadmin/web/task-permission-map.js @@ -214,6 +214,13 @@ export const GROUP_TASK_PERMISSIONS = { 'settings_Resources/doRemoveResource': 'editResources', 'settings_Resources/doReloadResource': 'reloadResources', + // Data Pruner publishes bare task names that overlap channel controls. + // Resolve its settings tasks before the generic doStart/doStop entries. + 'settings_Data Pruner/doRefresh': 'View Settings', + 'settings_Data Pruner/doSave': 'Save Settings', + 'settings_Data Pruner/doStart': 'Start / Stop', + 'settings_Data Pruner/doStop': 'Start / Stop', + // RBAC plugin settings panel (RbacServletInterface.PLUGIN_NAME / // PERMISSION_VIEW / PERMISSION_MANAGE) 'settings_Role-Based Access Control/doRefresh': 'View Roles', diff --git a/webadmin/web/task-permission-map.json b/webadmin/web/task-permission-map.json index d3fd32d..577f674 100644 --- a/webadmin/web/task-permission-map.json +++ b/webadmin/web/task-permission-map.json @@ -128,6 +128,10 @@ "settings_Resources/doAddResource": "editResources", "settings_Resources/doRemoveResource": "editResources", "settings_Resources/doReloadResource": "reloadResources", + "settings_Data Pruner/doRefresh": "View Settings", + "settings_Data Pruner/doSave": "Save Settings", + "settings_Data Pruner/doStart": "Start / Stop", + "settings_Data Pruner/doStop": "Start / Stop", "settings_Role-Based Access Control/doRefresh": "View Roles", "settings_Role-Based Access Control/doSave": "Manage Roles" } diff --git a/webadmin/web/web-task-catalog.json b/webadmin/web/web-task-catalog.json index 7503277..f604554 100644 --- a/webadmin/web/web-task-catalog.json +++ b/webadmin/web/web-task-catalog.json @@ -1,5 +1,5 @@ { - "$comment": "Snapshot of the OIE web administrator's RBAC task catalog (oie-web-client web-administrator/RBAC.md section 3, captured 2026-07-14) - every (taskGroup, taskName) the host's checkTask hook can be asked about. Generated ONCE from that document so this repo's map-completeness test is self-contained; when the host catalog changes, regenerate this file from RBAC.md section 3 and re-run map-completeness.test.mjs to surface the drift.", + "$comment": "Snapshot of the OIE web administrator's RBAC task catalog (oie-web-client web-administrator/RBAC.md section 3, captured 2026-07-14, Data Pruner tasks added 2026-10-12) - every (taskGroup, taskName) the host's checkTask hook can be asked about. Generated ONCE from that document so this repo's map-completeness test is self-contained; when the host catalog changes, regenerate this file from RBAC.md section 3 and re-run map-completeness.test.mjs to surface the drift.", "groups": { "view": [ "doShowDashboard", @@ -180,6 +180,12 @@ "doAddResource", "doRemoveResource", "doReloadResource" + ], + "settings_Data Pruner": [ + "doRefresh", + "doSave", + "doStart", + "doStop" ] } } From e63cce6d34d44ebb85996bd4294d88d4227bf1ac Mon Sep 17 00:00:00 2001 From: gibson9583 Date: Mon, 12 Oct 2026 15:12:25 -0400 Subject: [PATCH 2/2] fix: target Web Client v1.1.0 in browser CI Use the next release tag instead of the companion development commit. Align the workflow comments and documentation with the release target. Refs diridium-com/role-based-access-control#21 --- .github/workflows/build.yml | 10 +++++----- README.md | 2 +- webadmin/playwright.config.js | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 917ced3..0fbfed3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,7 @@ # Builds and tests every push to main and every pull request. The Maven build # runs the Java tests and, in the package module, the web administrator's JS # unit tests (npm test). The e2e job runs the Playwright suite in webadmin/e2e -# against an immutable OIE web client commit containing the companion host fixes. +# against a pinned OIE web client release. # # The engine jars this plugin compiles against come from the published OIE # distribution, the same tarball scripts/install-engine-jars.sh uses for local @@ -106,13 +106,13 @@ jobs: # Browser tests: the real built plugin.js loaded into the OIE web client, with # the engine's REST API mocked in the browser (webadmin/e2e/mock.js), so no - # engine is needed. The web client commit includes the permission-surface and - # authorization-bootstrap fixes required by the regression matrix. Keep this - # immutable; advance it only after the complete suite passes on the new host. + # engine is needed. Web Client v1.1.0 includes the permission-surface and + # authorization-bootstrap fixes required by the regression matrix. Advance + # the release tag when a newer release ships and the complete suite passes. e2e: runs-on: ubuntu-latest env: - OIE_WEB_CLIENT_REF: aeb6acace6c682cb26d8fe48f55c74d3862de623 + OIE_WEB_CLIENT_REF: v1.1.0 steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 26a6b74..6ac3884 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Role Based Access Control (RBAC) plugin for [Open Integration Engine](https://gi Web admin ready: the same zip ships both UIs. Role management and role-based task gating work in the desktop (Swing) Administrator and in the [OIE Web Administrator](https://github.com/diridium-com/role-based-access-control/wiki/Web-Administrator), with matching behavior in each. The web UI's permission gating is also more complete than Swing's, which cannot hide buttons inside panel bodies (an engine limitation that applies to all plugins); either way, denied operations always fail server-side. On servers where the Web Administrator isn't used, the web module is inert. -Data Pruner uses its own View Settings, Save Settings, and Start / Stop grants; channel Start/Stop permission does not grant pruning access. The web manifest marks RBAC as an authorization plugin so compatible web clients can stop startup if it cannot load. Authenticated users without a role can still discover and download the web authorization plugin, and failed permission loads stay closed until both permission APIs recover. Recovery restores the Roles tab when View Roles is granted. The [matching Web Client fixes](https://github.com/gibson9583/oie-web-client/commit/aeb6acace6c682cb26d8fe48f55c74d3862de623) are also required to hide Data Pruner actions and restricted navigation correctly; the browser CI suite pins that companion commit. +Data Pruner uses its own View Settings, Save Settings, and Start / Stop grants; channel Start/Stop permission does not grant pruning access. The web manifest marks RBAC as an authorization plugin so compatible web clients can stop startup if it cannot load. Authenticated users without a role can still discover and download the web authorization plugin, and failed permission loads stay closed until both permission APIs recover. Recovery restores the Roles tab when View Roles is granted. The [matching Web Client fixes](https://github.com/gibson9583/oie-web-client/pull/112) are also required to hide Data Pruner actions and restricted navigation correctly; the browser CI suite targets the upcoming Web Client `v1.1.0` release. An unavailable permission snapshot gets at most three automatic retries, 15 seconds apart. This lets a short outage recover without user interaction while avoiding an indefinite background heartbeat that would keep an idle engine session alive. Subsequent task checks retain the throttled lazy retry, and signing out cancels pending retries. diff --git a/webadmin/playwright.config.js b/webadmin/playwright.config.js index d990e4e..653ddd9 100644 --- a/webadmin/playwright.config.js +++ b/webadmin/playwright.config.js @@ -7,7 +7,7 @@ * needed) and loads the REAL built web/plugin.js the way an engine would serve * it. Point WEBADMIN_DIR at your oie-web-client checkout if it is not the * sibling of this repository. The checkout must be built first (CI pins the - * companion host commit in .github/workflows/build.yml): + * release tag in .github/workflows/build.yml): * * (cd ~/src/oie-web-client && npm ci && npm run build -w web-administrator) * WEBADMIN_DIR=~/src/oie-web-client npx playwright test