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
7 changes: 4 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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: v1.0.1
OIE_WEB_CLIENT_REF: v1.1.0
steps:
- uses: actions/checkout@v4

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** `"<pluginName>#<op>"` 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<userId, UserAuthCache>` invalidated on all write REST endpoints. A one-time startup sweep (`deleteOrphanUserRoles`) removes `rbac_user_role` rows for engine-deleted users.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/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.

Full documentation is in the [wiki](https://github.com/diridium-com/role-based-access-control/wiki).

<img src="https://raw.githubusercontent.com/wiki/diridium-com/role-based-access-control/images/1.png" width="800" alt="Role-Based Access Control settings tab in the OIE Administrator">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> knownPermissions() throws Exception {
Set<String> values = new HashSet<>();
Expand All @@ -102,6 +102,9 @@ private static Set<String> 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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 6 additions & 9 deletions webadmin/e2e/fail-closed.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
2 changes: 1 addition & 1 deletion webadmin/e2e/fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -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': '<set/>',
'GET /server/channelDependencies': '',
'GET /server/channelMetadata': {},

Expand Down
2 changes: 1 addition & 1 deletion webadmin/e2e/mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '' });
}
Expand Down
Loading
Loading