diff --git a/roles/web-app-openwebui/README.md b/roles/web-app-openwebui/README.md index c0b2042907..6cd1c72a87 100644 --- a/roles/web-app-openwebui/README.md +++ b/roles/web-app-openwebui/README.md @@ -162,6 +162,12 @@ administrators too; upstream defaults it to true, which would let every administrator past any grant. `ENABLE_OAUTH_GROUP_MANAGEMENT` syncs the platform's groups on login, so membership follows Keycloak. +`ENABLE_OAUTH_ROLE_MANAGEMENT` maps the application's administrator group onto +the Open WebUI admin role, and it also makes `OAUTH_ALLOWED_ROLES` a sign-in +gate. The env sets `OAUTH_ALLOWED_ROLES=*` so every platform role passes that +gate; narrow it only to a list that matches the `/roles//` paths +Keycloak emits. + Entries render **disabled**, and the task enables each one in the same write that carries its grant. An empty `access_grants` is not "nobody": Open WebUI reads it as every administrator, which is wider than the `mcp` group. Since diff --git a/roles/web-app-openwebui/files/playwright/test-mcp-grant-via-lam.js b/roles/web-app-openwebui/files/playwright/test-mcp-grant-via-lam.js index 0b5a36ef7a..e7780de638 100644 --- a/roles/web-app-openwebui/files/playwright/test-mcp-grant-via-lam.js +++ b/roles/web-app-openwebui/files/playwright/test-mcp-grant-via-lam.js @@ -3,6 +3,18 @@ const { skipUnlessServiceEnabled } = require("./service-gating"); const { performKeycloakLoginForm } = require("./personas"); const LDAP_MEMBER_ATTRIBUTE = "member"; +const MEMBER_VALUE_LIST = `#attributeList_${LDAP_MEMBER_ATTRIBUTE}`; +const MEMBER_VALUE_INPUT = `input.lam-attr-${LDAP_MEMBER_ATTRIBUTE}`; +const ADD_VALUE_LINK = "a[onclick*='treeview.addValue']"; +const CLEAR_VALUE_LINK = "a[onclick*='treeview.clearValue']"; +const SAVE_BUTTON_NAME = /^\s*save\s*$/i; + +const CLAIM_PROPAGATION_MS = 120_000; + +const PERSONA_CONTEXT = { + ignoreHTTPSErrors: true, + viewport: { width: 1440, height: 1100 }, +}; function firstExpectedServer(shared) { const servers = (shared.env.mcpExpectedServers || "") @@ -16,13 +28,16 @@ function firstExpectedServer(shared) { return servers.slice().sort()[0]; } -async function servedToolIds(page, shared) { +async function sessionHeaders(page) { const token = await page.evaluate(() => window.localStorage.getItem("token")); expect(token, "OpenWebUI must store a session token after login").toBeTruthy(); + return { Authorization: `Bearer ${token}` }; +} +async function servedToolIds(page, shared) { const base = shared.env.openwebuiBaseUrl.replace(/\/+$/, ""); const response = await page.request.get(`${base}/api/v1/tools/`, { - headers: { Authorization: `Bearer ${token}` }, + headers: await sessionHeaders(page), }); expect( response.ok(), @@ -31,6 +46,20 @@ async function servedToolIds(page, shared) { return JSON.stringify(await response.json()); } +async function sessionUserId(page, shared) { + const base = shared.env.openwebuiBaseUrl.replace(/\/+$/, ""); + const response = await page.request.get(`${base}/api/v1/auths/`, { + headers: await sessionHeaders(page), + }); + expect( + response.ok(), + `the session endpoint must answer the signed-in user (HTTP ${response.status()})` + ).toBeTruthy(); + const id = (await response.json())?.id; + expect(id, "OpenWebUI must report the id of the signed-in user").toBeTruthy(); + return id; +} + async function signInToLam(page, shared) { await page.goto(`${shared.env.lamBaseUrl.replace(/\/+$/, "")}/`); @@ -70,44 +99,158 @@ async function signInToLam(page, shared) { ).toBeHidden({ timeout: 60_000 }); } -async function addMemberViaLamTree(page, shared, groupCn, username) { +async function openMemberValueList(page, shared, groupCn) { const base = shared.env.lamBaseUrl.replace(/\/+$/, ""); const groupDn = `cn=${groupCn},${shared.env.ldapRoleDnBase}`; const dnParam = encodeURIComponent(Buffer.from(groupDn, "utf8").toString("base64")); await page.goto(`${base}/lam/templates/tools/treeView.php?dn=${dnParam}`); await expect( - page.getByText(new RegExp(`cn=${groupCn}\\b`)).first(), - `LAM must open the ${groupCn} role group; an empty tree here means the LDAP bind did not take` + page.getByRole("heading", { name: new RegExp(`^cn=${groupCn},`) }).first(), + `LAM must open the ${groupCn} role group; a missing entry heading means the LDAP bind did not take` ).toBeVisible({ timeout: 60_000 }); - const addValue = page - .getByRole("link", { name: new RegExp(`add\\s+value|${LDAP_MEMBER_ATTRIBUTE}`, "i") }) - .first(); + const memberList = page.locator(MEMBER_VALUE_LIST); await expect( - addValue, - `LAM must offer an editable ${LDAP_MEMBER_ATTRIBUTE} attribute on ${groupCn}` + memberList, + `LAM must render ${groupCn}'s ${LDAP_MEMBER_ATTRIBUTE} attribute as an editable value list` ).toBeVisible({ timeout: 30_000 }); - await addValue.click(); + return memberList; +} - const memberField = page - .locator(`input[name*='${LDAP_MEMBER_ATTRIBUTE}'], textarea[name*='${LDAP_MEMBER_ATTRIBUTE}']`) - .last(); - await expect( - memberField, - `LAM must expose an input for the new ${LDAP_MEMBER_ATTRIBUTE} value` - ).toBeVisible({ timeout: 30_000 }); - await memberField.fill(`uid=${username},${shared.env.ldapUserDnBase}`); +function memberValues(memberList) { + return memberList + .locator(MEMBER_VALUE_INPUT) + .evaluateAll((inputs) => inputs.map((input) => input.value)); +} - await page - .getByRole("button", { name: /save|update|change/i }) - .first() +async function saveLamEntry(page) { + await page.getByRole("button", { name: SAVE_BUTTON_NAME }).first().click(); +} + +async function revokeMemberInLam(page, shared, groupCn, memberDn) { + const memberList = await openMemberValueList(page, shared, groupCn); + const values = await memberValues(memberList); + if (!values.includes(memberDn)) { + return; + } + + await memberList + .locator("li") + .nth(values.indexOf(memberDn)) + .locator(CLEAR_VALUE_LINK) .click(); + await saveLamEntry(page); - await expect( - page.getByText(new RegExp(`uid=${username}\\b`)).first(), - `LAM must show ${username} among the ${LDAP_MEMBER_ATTRIBUTE} values after saving` - ).toBeVisible({ timeout: 60_000 }); + await expect + .poll(() => memberValues(memberList), { + timeout: 60_000, + message: `LAM must drop ${memberDn} from ${groupCn}`, + }) + .not.toContain(memberDn); +} + +async function grantMemberInLam(page, shared, groupCn, memberDn) { + const memberList = await openMemberValueList(page, shared, groupCn); + expect( + await memberValues(memberList), + `${groupCn} must not already list ${memberDn}; the grant under test would prove nothing` + ).not.toContain(memberDn); + + await memberList.locator(ADD_VALUE_LINK).first().click(); + await memberList.locator(MEMBER_VALUE_INPUT).last().fill(memberDn); + await saveLamEntry(page); + + await expect + .poll(() => memberValues(memberList), { + timeout: 60_000, + message: `LAM must persist ${memberDn} as a ${LDAP_MEMBER_ATTRIBUTE} of ${groupCn}`, + }) + .toContain(memberDn); +} + +async function grantedGroupId(page, shared, server) { + const base = shared.env.openwebuiBaseUrl.replace(/\/+$/, ""); + const response = await page.request.get(`${base}/api/v1/configs/tool_servers`, { + headers: await sessionHeaders(page), + }); + expect( + response.ok(), + `the administrator must read the tool-server config (HTTP ${response.status()})` + ).toBeTruthy(); + const connection = ((await response.json())?.TOOL_SERVER_CONNECTIONS || []).find( + (entry) => entry?.info?.id === server + ); + expect(connection, `${server} must be a configured tool server`).toBeTruthy(); + const groupId = (connection.config?.access_grants || [])[0]?.principal_id; + expect(groupId, `${server} must name the group its access grant points at`).toBeTruthy(); + return groupId; +} + +/** + * Take one member out of the Open WebUI group an MCP server grants read to. + * + * Args: + * page: a page holding an administrator session. + * shared: the role's shared spec helpers. + * groupId: the Open WebUI group carrying the server's access grant. + * memberId: the Open WebUI user id to remove. + * + * Withdrawing the LDAP membership does not reach Open WebUI on its own: its + * OIDC sync drops a stale group only while the user's groups claim is + * non-empty, and a user whose only grant was just withdrawn arrives with an + * empty one. This write is what makes the revocation visible. + */ +async function dropGroupMember(page, shared, groupId, memberId) { + const base = shared.env.openwebuiBaseUrl.replace(/\/+$/, ""); + const headers = await sessionHeaders(page); + + const listed = await page.request.post(`${base}/api/v1/groups/id/${groupId}/users`, { headers }); + expect( + listed.ok(), + `the administrator must list the members of ${groupId} (HTTP ${listed.status()})` + ).toBeTruthy(); + if (!(await listed.json()).some((member) => member?.id === memberId)) { + return; + } + + const removed = await page.request.post(`${base}/api/v1/groups/id/${groupId}/users/remove`, { + headers, + data: { user_ids: [memberId] }, + }); + expect( + removed.ok(), + `the administrator must remove the revoked member from ${groupId} (HTTP ${removed.status()})` + ).toBeTruthy(); +} + +async function discardSession(page, shared) { + const base = shared.env.openwebuiBaseUrl.replace(/\/+$/, ""); + if (page.url().startsWith(base)) { + await page.evaluate(() => window.localStorage.clear()); + } + await page.context().clearCookies(); +} + +async function pollBiberTools(page, shared, server, expectServed, resetOnMiss) { + const deadline = Date.now() + CLAIM_PROPAGATION_MS; + for (;;) { + await discardSession(page, shared); + await shared.signInViaDashboardOidc( + page, + shared.env.biberUsername, + shared.env.biberPassword, + "biber" + ); + const userId = await sessionUserId(page, shared); + const served = await servedToolIds(page, shared); + if (served.includes(server) === expectServed || Date.now() >= deadline) { + return { served, userId }; + } + if (resetOnMiss) { + await resetOnMiss(userId); + } + } } exports.register = function (shared) { @@ -119,37 +262,53 @@ exports.register = function (shared) { !shared.env.lamBaseUrl, "LAM_BASE_URL is empty; web-app-lam is not in this deployment's inventory" ); - test.setTimeout(420_000); + test.setTimeout(600_000); const server = firstExpectedServer(shared); const groupCn = `${server}-mcp`; + const memberDn = `uid=${shared.env.biberUsername},${shared.env.ldapUserDnBase}`; + const browser = page.context().browser(); + const lamPage = await (await browser.newContext(PERSONA_CONTEXT)).newPage(); + const adminPage = await (await browser.newContext(PERSONA_CONTEXT)).newPage(); + + await signInToLam(lamPage, shared); await shared.signInViaDashboardOidc( - page, - shared.env.biberUsername, - shared.env.biberPassword, - "biber" + adminPage, + shared.env.adminUsername, + shared.env.adminPassword, + "administrator" ); - expect( - await servedToolIds(page, shared), - `${server} is granted to its mcp group, which biber is not in yet, so its tools must not be served` - ).not.toContain(server); - await shared.expectSignInRequiredAfterLogout(page); + const groupId = await grantedGroupId(adminPage, shared, server); - await page.context().clearCookies(); - await signInToLam(page, shared); - await addMemberViaLamTree(page, shared, groupCn, shared.env.biberUsername); - await page.context().clearCookies(); + await revokeMemberInLam(lamPage, shared, groupCn, memberDn); - await shared.signInViaDashboardOidc( - page, - shared.env.biberUsername, - shared.env.biberPassword, - "biber" - ); - expect( - await servedToolIds(page, shared), - `biber now holds ${groupCn}; absence here means the groups claim, the OIDC group mapping or the access grant is wrong` - ).toContain(server); + let biberId = null; + try { + const denied = await pollBiberTools(page, shared, server, false, async (userId) => { + biberId = userId; + await dropGroupMember(adminPage, shared, groupId, userId); + }); + biberId = denied.userId; + expect( + denied.served, + `${server} is granted to ${groupCn}, which biber is not in yet, so its tools must not be served` + ).not.toContain(server); + + await shared.expectSignInRequiredAfterLogout(page); + + await grantMemberInLam(lamPage, shared, groupCn, memberDn); + + const allowed = await pollBiberTools(page, shared, server, true, null); + expect( + allowed.served, + `biber now holds ${groupCn}; absence here means the groups claim, the OIDC group mapping or the access grant is wrong` + ).toContain(server); + } finally { + await revokeMemberInLam(lamPage, shared, groupCn, memberDn); + if (biberId) { + await dropGroupMember(adminPage, shared, groupId, biberId); + } + } }); }; diff --git a/roles/web-app-openwebui/templates/env.j2 b/roles/web-app-openwebui/templates/env.j2 index 150f939ce6..7a0b8635a5 100644 --- a/roles/web-app-openwebui/templates/env.j2 +++ b/roles/web-app-openwebui/templates/env.j2 @@ -56,6 +56,7 @@ OAUTH_SCOPES=openid email profile {{ RBAC.GROUP.CLAIM }} ENABLE_OAUTH_ROLE_MANAGEMENT=true OAUTH_ROLES_CLAIM={{ RBAC.GROUP.CLAIM }} OAUTH_ADMIN_ROLES=/{{ OPENWEBUI_OIDC_ADMIN_GROUP }} +OAUTH_ALLOWED_ROLES=* ENABLE_OAUTH_GROUP_MANAGEMENT=true ENABLE_OAUTH_GROUP_CREATION=true