fix(openwebui): stop the first MCP grant from locking a user out

ENABLE_OAUTH_ROLE_MANAGEMENT does two things and only one is obvious. Besides
mapping the administrator group onto the Open WebUI admin role, it turns
OAUTH_ALLOWED_ROLES into a sign-in gate: a user whose groups claim is non-empty
and matches neither the allowed list nor the admin list is refused outright.

Upstream defaults that list to the literals `user,admin`, and Keycloak emits
claims shaped /roles/<app>/<role>, which can never match them. A user with no
groups therefore signed in fine, and the moment they received their first `mcp`
grant their claim became non-empty, matched nothing, and they were refused. The
grant meant to give access was what removed it.

The env sets OAUTH_ALLOWED_ROLES=*. The grant itself stays the real gate, since
BYPASS_ADMIN_ACCESS_CONTROL=false keeps it binding on administrators too. Stated
plainly, this does widen a control: every authenticated platform user now passes
Open WebUI's role gate. A list matching the actual role paths would be stricter
and is not built here.

The LAM grant spec is rewritten to drive the real path rather than assert around
it. Three behaviours it now handles: LAM renders per-value add and clear controls
as anchors with an empty alt, so they cannot be found by accessible name and are
located structurally; a membership written in LAM crosses Keycloak federation and
then an Open WebUI login sync before it is observable, so the check polls instead
of reading once; and the sign-out step clears localStorage rather than only
cookies, because Open WebUI keeps its session token there and a cookie-only reset
left the old session live while the assertion passed.

Not verified: openwebui was not in the last deploy's role set, so neither the env
change nor the rewritten spec has run. The spec is also now genuinely
re-authenticating, which is more correct and will surface auth flakiness the
previous version masked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-24 14:20:25 +02:00
1 parent 8c3d6fcde1
commit 54aced1868
3 files changed
+216 -50

No files matched your search

+6
View File
@@ -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/<app>/<role>` 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
@@ -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);
}
}
});
};
+1
View File
@@ -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