Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
60546ee
feat(intelligent-assistant): gate UI by consolidated RBAC permissions
rohitratannagar Sep 9, 2026
a76eb5b
test(intelligent-assistant): add RBAC permission gating automation
Sep 10, 2026
e7c79df
fix(intelligent-assistant): fix RBAC test mock typing for tsc:full
Sep 10, 2026
5ba758c
fix(intelligent-assistant): stabilize RBAC permission e2e in CI
Sep 10, 2026
129e18d
fix(intelligent-assistant): stabilize RBAC permission e2e tests
Sep 10, 2026
b59fb07
fix(intelligent-assistant): isolate RBAC permission e2e scenarios
Sep 10, 2026
106c07c
fix(intelligent-assistant): drop flaky RBAC permission e2e tests
Sep 10, 2026
9ab8162
test(intelligent-assistant): restore stable RBAC permission e2e suite
Sep 10, 2026
603196b
fix(intelligent-assistant): show 404 when user lacks chat and noteboo…
rohitratannagar Sep 10, 2026
e761566
Merge pull request #4 from HusneShabbir/test/intelligent-assistant-pe…
rohitratannagar Sep 10, 2026
5db8fe4
chore(intelligent-assistant): remove orphaned permission-required assets
rohitratannagar Sep 11, 2026
7e95c70
Merge upstream/main into feat/UI-permissions-gating
rohitratannagar Sep 11, 2026
1e64738
revert: drop out-of-scope boost test changes from IA PR
rohitratannagar Sep 11, 2026
325277c
docs(intelligent-assistant): align backend README RBAC policy examples
rohitratannagar Sep 14, 2026
d425adb
test(intelligent-assistant): run RBAC permission e2e on NFS in CI
Sep 15, 2026
b3ba46a
Merge branch 'main' into feat/UI-permissions-gating
its-mitesh-kumar Sep 15, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-intelligent-assistant': minor
---

Gate Intelligent Assistant UI by consolidated RBAC permissions (`intelligent-assistant.chat`, `intelligent-assistant.notebooks`, `intelligent-assistant.mcp.tools`, `intelligent-assistant.skills`). Features are hidden when access is denied instead of showing permission-denied screens or read-only MCP mode.
1 change: 1 addition & 0 deletions workspaces/intelligent-assistant/.eslintignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
playwright.config.ts
playwright.config.rbac.ts
e2e-tests/
!.eslintrc.js
!.prettierrc.js
7 changes: 7 additions & 0 deletions workspaces/intelligent-assistant/app-config.e2e-rbac.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Playwright overlay: enable the permission framework so the UI calls
# POST /api/permission/authorize (required for RBAC e2e route mocks).
permission:
enabled: true
rbac:
policies-csv-file: ./rbac-policy.e2e.csv
policyFileReload: true
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* Copyright Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { test, expect, type Browser } from '@playwright/test';

import { IaRbacPermissionsPage } from './pages/IaRbacPermissionsPage';
import {
IA_PERMISSIONS_ALL_ALLOWED,
waitForIaPermissionAuthorize,
type IaPermissionMatrix,
} from './utils/iaPermissionsE2e';
import { bootstrapLightspeedRbacE2ePage } from './utils/lightspeedE2eSetup';

async function withPermissionScenario(
browser: Browser,
matrix: IaPermissionMatrix,
run: (permissions: IaRbacPermissionsPage) => Promise<void>,
): Promise<void> {
const boot = await bootstrapLightspeedRbacE2ePage(browser, matrix);
const permissions = new IaRbacPermissionsPage(boot.page, boot.translations);
try {
await boot.page.goto('/');
await waitForIaPermissionAuthorize(boot.page);
await run(permissions);
} finally {
await boot.page.context().close();
}
}

test.describe('Intelligent assistant permissions', () => {
test.describe.configure({ mode: 'serial' });

test('shows FAB and chat and notebooks tabs', async ({ browser }) => {
await withPermissionScenario(
browser,
{ chat: true, notebooks: true, mcp: false },
async permissions => {
await permissions.expectFabVisible();
await permissions.openFromFab();
await permissions.expectChatAndNotebooksTabsVisible();
await expect(permissions.newChatButton()).toBeVisible();
},
);
});

test('shows chat-only layout without notebooks tab', async ({ browser }) => {
await withPermissionScenario(
browser,
{ chat: true, notebooks: false, mcp: false },
async permissions => {
await permissions.expectFabVisible();
await permissions.openFromFab();
await permissions.expectChatOnlyLayout();
},
);
});

test('shows notebooks-only layout without chat tab', async ({ browser }) => {
await withPermissionScenario(
browser,
{ chat: false, notebooks: true, mcp: false },
async permissions => {
await permissions.expectFabVisible();
await permissions.openFromFab();
await permissions.expectNotebooksOnlyLayout();
},
);
});

test('hides FAB when chat and notebooks are denied', async ({ browser }) => {
await withPermissionScenario(
browser,
{ chat: false, notebooks: false, mcp: false },
async permissions => {
await permissions.expectFabHidden();
},
);
});

test('shows MCP settings in header menu when allowed', async ({
browser,
}) => {
await withPermissionScenario(
browser,
IA_PERMISSIONS_ALL_ALLOWED,
async permissions => {
await permissions.expectMcpMenuVisible();
},
);
});

test('hides MCP settings from header menu when denied', async ({
browser,
}) => {
await withPermissionScenario(
browser,
{ ...IA_PERMISSIONS_ALL_ALLOWED, mcp: false },
async permissions => {
await permissions.expectMcpMenuHidden();
},
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* Copyright Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { expect, type Locator, type Page } from '@playwright/test';

import type { LightspeedMessages } from '../utils/translations';
import { openChatbot } from './LightspeedPage';

/**
* Intelligent Assistant permission gating: FAB visibility, tab layout, and MCP menu.
*/
export class IaRbacPermissionsPage {
constructor(
private readonly page: Page,
private readonly t: LightspeedMessages,
) {}

fabButton(): Locator {
return this.page.getByRole('button', { name: this.t['tooltip.fab.open'] });
}

chatbotRegion(): Locator {
return this.page.getByLabel('Chatbot', { exact: true });
}

newChatButton(): Locator {
return this.page.getByRole('button', { name: this.t['button.newChat'] });
}

chatTab(): Locator {
return this.chatbotRegion().getByRole('tab', { name: this.t['tabs.chat'] });
}

notebooksTab(): Locator {
return this.chatbotRegion().getByRole('tab', {
name: this.t['tabs.notebooks'],
});
}

notebooksEmptyTitle(): Locator {
return this.page.getByText(this.t['notebooks.empty.title']);
}

mcpSettingsMenuItem(): Locator {
return this.page.getByRole('menuitem', {
name: this.t['settings.mcp.label'],
});
}

async expectFabVisible(): Promise<void> {
await expect(this.fabButton()).toBeVisible();
}

async expectFabHidden(): Promise<void> {
await expect(this.fabButton()).toHaveCount(0);
}

async openFromFab(): Promise<void> {
await openChatbot(this.page, this.t);
await expect(this.chatbotRegion()).toBeVisible();
}

async expectChatAndNotebooksTabsVisible(): Promise<void> {
await expect(this.chatTab()).toBeVisible();
await expect(this.notebooksTab()).toBeVisible();
}

async expectChatOnlyLayout(): Promise<void> {
await expect(this.notebooksTab()).toBeHidden();
await expect(this.newChatButton()).toBeVisible();
await expect(this.notebooksEmptyTitle()).not.toBeVisible();
}

async expectNotebooksOnlyLayout(): Promise<void> {
await expect(this.chatTab()).toBeHidden();
await expect(this.newChatButton()).not.toBeVisible();
await expect(this.notebooksEmptyTitle()).toBeVisible();
}

async openOptionsMenu(): Promise<void> {
await this.page
.getByRole('button', { name: this.t['aria.options.label'] })
.click();
}

async expectMcpSettingsVisible(): Promise<void> {
await expect(this.mcpSettingsMenuItem()).toBeVisible();
}

async expectMcpSettingsHidden(): Promise<void> {
await expect(this.mcpSettingsMenuItem()).toHaveCount(0);
}

async expectMcpMenuVisible(): Promise<void> {
await this.openFromFab();
await this.openOptionsMenu();
await this.expectMcpSettingsVisible();
}

async expectMcpMenuHidden(): Promise<void> {
await this.openFromFab();
await this.openOptionsMenu();
await this.expectMcpSettingsHidden();
}
}
132 changes: 132 additions & 0 deletions workspaces/intelligent-assistant/e2e-tests/utils/iaPermissionsE2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
* Copyright Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import type { BrowserContext, Page } from '@playwright/test';

export type IaPermissionMatrix = {
chat: boolean;
notebooks: boolean;
mcp: boolean;
};

export const IA_PERMISSIONS_ALL_ALLOWED: IaPermissionMatrix = {
chat: true,
notebooks: true,
mcp: true,
};

const IA_PERMISSION_AUTHORIZE_ROUTE = '**/api/permission/authorize';

const IA_PERMISSION_NAMES = {
chat: 'intelligent-assistant.chat',
notebooks: 'intelligent-assistant.notebooks',
mcp: 'intelligent-assistant.mcp.tools',
skills: 'intelligent-assistant.skills',
} as const;

const IA_PERMISSION_NAME_SET = new Set<string>(
Object.values(IA_PERMISSION_NAMES),
);

function isIaPermissionName(name: string | undefined): boolean {
return name !== undefined && IA_PERMISSION_NAME_SET.has(name);
}

function isIaPermissionAllowed(
permissionName: string | undefined,
matrix: IaPermissionMatrix,
): boolean {
switch (permissionName) {
case IA_PERMISSION_NAMES.chat:
return matrix.chat;
case IA_PERMISSION_NAMES.notebooks:
return matrix.notebooks;
case IA_PERMISSION_NAMES.mcp:
return matrix.mcp;
case IA_PERMISSION_NAMES.skills:
return false;
default:
return true;
}
}

type AuthorizeRequestItem = {
id: string;
permission?: { name?: string };
};

/**
* Install before the first page is created on the context. Intercepts IA
* permission checks while allowing other authorize items through as ALLOW.
*/
export async function installIaPermissionsMock(
context: BrowserContext,
matrix: IaPermissionMatrix,
): Promise<void> {
const matrixRef = { current: matrix };

await context.route(IA_PERMISSION_AUTHORIZE_ROUTE, async route => {
if (route.request().method() !== 'POST') {
await route.continue();
return;
}

let body: { items?: AuthorizeRequestItem[] };
try {
body = route.request().postDataJSON() as {
items?: AuthorizeRequestItem[];
};
} catch {
await route.continue();
return;
}

const items = body?.items ?? [];
const touchesIaPermission = items.some(item =>
isIaPermissionName(item.permission?.name),
);

if (!touchesIaPermission) {
await route.continue();
return;
}

const activeMatrix = matrixRef.current;
await route.fulfill({
json: {
items: items.map(item => {
const permissionName = item.permission?.name;
const result =
isIaPermissionName(permissionName) &&
!isIaPermissionAllowed(permissionName, activeMatrix)
? 'DENY'
: 'ALLOW';
return { id: item.id, result };
}),
},
});
});
}

/** Wait until the FAB has resolved IA permission checks on the catalog page. */
export async function waitForIaPermissionAuthorize(page: Page): Promise<void> {
await page.waitForResponse(
response =>
response.url().includes('/api/permission/authorize') &&
response.request().method() === 'POST',
{ timeout: 30_000 },
);
}
Loading
Loading