mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
Tor: use the shared tor block (bond 0.5, enabled when svc-net-tor is in the node's groups, tor pinned on in both variants) so a node serves one network, onion on a Tor node and clearnet otherwise. SPOT and sizing: WAZUH_INDEXER_URL replaces four hand-built copies of the indexer URL, and the indexer heap is half of mem_limit instead of a hardcoded 1g (unchanged for the current 2g limit). Fixes: bcrypt_hash rejects secrets over 72 bytes; the Keycloak group-removal helper uses the same resolver as the add helper and checks every response (the realm is no longer URL-encoded, matching the add helper), and a failed cleanup fails an otherwise passing test; the swarm certs generator removes a stale container before each attempt; vm.max_map_count is set before the early handler flush; securityadmin failed_when now needs rc 0 and the success marker. The regenerated role README also picks up generator drift (mailu to stalwart).
401 lines
15 KiB
JavaScript
401 lines
15 KiB
JavaScript
/**
|
|
* Keycloak OIDC login helpers, plus Keycloak Admin REST API helpers for
|
|
* Playwright specs that need to manipulate group membership directly
|
|
* (RBAC-tier tests across web-app-* roles).
|
|
*
|
|
* `performKeycloakLoginForm(target, username, password)`
|
|
* Fills the Keycloak login form on `target` (a `Page` OR a
|
|
* `FrameLocator`) and clicks sign-in. Tolerates both the
|
|
* role-based selector strategy (`getByRole({ name: /username|
|
|
* email/i })`, etc.) and the legacy input-name selector strategy
|
|
* (`input[name='username']`, etc.) so iframe-embedded Keycloak
|
|
* forms work without branching at the call site. Does NOT assert
|
|
* post-login navigation.
|
|
*
|
|
* `performKeycloakLogin(page, username, password, canonicalDomain)`
|
|
* Calls `performKeycloakLoginForm` and additionally polls the
|
|
* page URL until its hostname is `canonicalDomain`, asserting the
|
|
* OAuth2-Proxy / app callback completes.
|
|
*
|
|
* `performKeycloakLoginExpectingDenial(page, username, password, canonicalDomain)`
|
|
* Drives the form with credentials expected to be REJECTED
|
|
* (insufficient privileges, forbidden role, denied app) and
|
|
* asserts the round-trip ends on a denial state (Keycloak error
|
|
* page, the same authorization endpoint with an error indicator,
|
|
* or a 401 / 403 on the relying party after the callback).
|
|
* Returns the resulting URL so callers can assert additional
|
|
* details if needed.
|
|
*
|
|
* `keycloakAdminToken(request, keycloakBaseUrl, username, password, opts)`
|
|
* Password-grant token fetch against Keycloak's own bootstrap realm
|
|
* (default `master`/`admin-cli`, both overridable via `opts` since
|
|
* they are deployment-configurable, not framework constants).
|
|
*
|
|
* `keycloakResolveGroupId(request, keycloakBaseUrl, realmName, accessToken, groupPath)`
|
|
* Resolves a group's internal id from its human-readable path.
|
|
*
|
|
* `keycloakAdminAddUserToGroup(request, keycloakBaseUrl, realmName, targetGroupPath, username, adminUsername, adminPassword, opts)`
|
|
* Idempotent group join; returns whether this call performed it.
|
|
*
|
|
* `keycloakRemoveUserFromGroupViaRest(request, keycloakBaseUrl, realmName, adminUsername, adminPassword, groupPath, username, opts)`
|
|
* Best-effort group removal (throws on lookup failure, no-ops if the
|
|
* user/group is already gone).
|
|
*/
|
|
|
|
const { expect } = require("@playwright/test");
|
|
const { resolveTimeout } = require("../../timeouts");
|
|
const { hostnameOf } = require("./dom");
|
|
|
|
// SPOT for the role-side OIDC adapter readiness contract. A role whose
|
|
// `templates/javascript/oidc.js.j2` wraps its Login link in a JS click
|
|
// handler (e.g. `keycloak.login()` with PKCE) MUST set this flag on
|
|
// `window` to `false` before the Login link renders and to `true` after
|
|
// the click interceptor is wired, so persona helpers can click the link
|
|
// without racing the adapter.
|
|
const OIDC_LOGIN_READY_FLAG = "__oidcLoginReady";
|
|
|
|
const OIDC_TRIGGER_NAME = /sso|openid|single[\s-]?sign/i;
|
|
|
|
// Keycloak's end-session URL also contains `openid-connect`; a page that
|
|
// renders a hidden Logout item before its Login link would otherwise pin
|
|
// `.first()` to the hidden Logout item until the probe times out.
|
|
const OIDC_ENTRY_SELECTOR = [
|
|
"a[href*='openid_connect' i]:not([href*='logout' i])",
|
|
"a[href*='openid-connect' i]:not([href*='logout' i])",
|
|
"a[href*='/auth/auth/' i]:not([href*='logout' i])",
|
|
"form[action*='openid' i] button",
|
|
"a[data-testid*='oidc' i]",
|
|
"button[data-testid*='oidc' i]",
|
|
].join(", ");
|
|
|
|
async function performKeycloakLoginForm(target, username, password) {
|
|
const usernameField = target
|
|
.getByRole("textbox", { name: /username|email/i })
|
|
.or(target.locator("input[name='username'], input#username"))
|
|
.first();
|
|
const passwordField = target
|
|
.getByRole("textbox", { name: /^password$/i })
|
|
.or(target.locator("input[name='password'], input#password"))
|
|
.first();
|
|
const signInButton = target
|
|
.getByRole("button", { name: /sign in|login|log in/i })
|
|
.or(target.locator("input#kc-login, button#kc-login, button[type='submit'], input[type='submit']"))
|
|
.first();
|
|
|
|
await usernameField.waitFor({ state: "visible", timeout: resolveTimeout(60_000) });
|
|
await usernameField.fill(username);
|
|
await usernameField.press("Tab").catch(() => {});
|
|
await passwordField.fill(password);
|
|
await signInButton.click({ timeout: resolveTimeout(30_000) });
|
|
}
|
|
|
|
async function performKeycloakLogin(page, username, password, canonicalDomain) {
|
|
await performKeycloakLoginForm(page, username, password);
|
|
|
|
await expect
|
|
.poll(() => hostnameOf(page.url()), {
|
|
timeout: resolveTimeout(60_000),
|
|
message: `Expected redirect back to ${canonicalDomain} after Keycloak login`,
|
|
})
|
|
.toBe(canonicalDomain);
|
|
}
|
|
|
|
// Click a role's in-app Login link to start the OIDC chain. Waits for
|
|
// the role's adapter to signal readiness (OIDC_LOGIN_READY_FLAG) before
|
|
// clicking, so the click hits the JS-wrapped handler (which stores
|
|
// PKCE state) and not the raw `href` (which would skip PKCE and break
|
|
// the post-login token exchange on PKCE-enforced clients). Roles that
|
|
// never declare the flag only wait for the page's load event. Returns the
|
|
// Page that reached `openid-connect/auth` — the opener, or the popup for
|
|
// roles that hand the IdP off via `window.open` — else null.
|
|
//
|
|
// The persona MUST pass `strictLink` (exact-match locator, e.g. accessible
|
|
// name `^\s*login\s*$/i`) AND `looseLink` (substring locator). The helper
|
|
// prefers the strict match — that targets the role's OWN Login button
|
|
// (e.g. nextcloud's plain `<a>Login</a>`) — and only falls back to the
|
|
// loose match when no strict candidate is visible. Without this two-pass
|
|
// approach, `sys-front-inj-all`-injected dashboard navbars in oauth2-proxy
|
|
// roles trap the substring match and redirect the persona to the dashboard
|
|
// flow instead of the role's own auth chain.
|
|
async function clickOidcLoginLink(page, strictLink, looseLink) {
|
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
let oidcLink = page.locator(OIDC_ENTRY_SELECTOR).first();
|
|
if (attempt > 0) {
|
|
oidcLink = oidcLink
|
|
.or(page.getByRole("button", { name: OIDC_TRIGGER_NAME }))
|
|
.or(page.getByRole("link", { name: OIDC_TRIGGER_NAME }))
|
|
.first();
|
|
}
|
|
const oidcVisible = await oidcLink
|
|
.waitFor({ state: "visible", timeout: resolveTimeout(5_000) })
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
|
|
let loginLink = oidcLink;
|
|
if (!oidcVisible) {
|
|
const strictVisible = await strictLink
|
|
.waitFor({ state: "visible", timeout: resolveTimeout(20_000) })
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
loginLink = strictVisible ? strictLink : looseLink;
|
|
if (!strictVisible) {
|
|
const looseVisible = await loginLink
|
|
.waitFor({ state: "visible", timeout: resolveTimeout(5_000) })
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
if (!looseVisible) return null;
|
|
}
|
|
}
|
|
|
|
await page.waitForLoadState("load", { timeout: resolveTimeout(15_000) }).catch(() => {});
|
|
await page
|
|
.waitForFunction(
|
|
(flag) => window[flag] !== false,
|
|
OIDC_LOGIN_READY_FLAG,
|
|
{ timeout: resolveTimeout(15_000) },
|
|
)
|
|
.catch(() => {});
|
|
const urlBeforeClick = page.url();
|
|
const reachedIdp = (candidate) =>
|
|
candidate
|
|
.waitForURL(/openid-connect\/auth/, { timeout: resolveTimeout(15_000) })
|
|
.then(() => candidate);
|
|
const popupPromise = page
|
|
.waitForEvent("popup", { timeout: resolveTimeout(15_000) })
|
|
.then(reachedIdp);
|
|
await loginLink.click({ timeout: resolveTimeout(30_000) }).catch(() => {});
|
|
const authPage = await Promise.any([reachedIdp(page), popupPromise]).catch(
|
|
() => null,
|
|
);
|
|
if (authPage) return authPage;
|
|
if (page.url().includes("openid-connect/auth")) return page;
|
|
if (page.url() === urlBeforeClick) return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function performKeycloakLoginExpectingDenial(page, username, password, canonicalDomain) {
|
|
await performKeycloakLoginForm(page, username, password);
|
|
|
|
await page.waitForLoadState("domcontentloaded", { timeout: resolveTimeout(60_000) }).catch(() => {});
|
|
|
|
const finalUrl = page.url();
|
|
const denied =
|
|
/access[\s_-]?denied|forbidden|not[\s_-]?authori[sz]ed|unauthori[sz]ed/i.test(
|
|
await page.content().catch(() => ""),
|
|
) ||
|
|
/openid-connect\/auth/.test(finalUrl) ||
|
|
hostnameOf(finalUrl) !== canonicalDomain;
|
|
|
|
expect(
|
|
denied,
|
|
`Expected ${username} to be DENIED at ${canonicalDomain} after Keycloak login (got URL ${finalUrl})`,
|
|
).toBe(true);
|
|
|
|
return finalUrl;
|
|
}
|
|
|
|
async function keycloakAdminToken(
|
|
request,
|
|
keycloakBaseUrl,
|
|
username,
|
|
password,
|
|
{ adminRealm = "master", adminClientId = "admin-cli" } = {},
|
|
) {
|
|
const tokenResp = await request.post(
|
|
`${keycloakBaseUrl}/realms/${encodeURIComponent(adminRealm)}/protocol/openid-connect/token`,
|
|
{
|
|
form: {
|
|
client_id: adminClientId,
|
|
grant_type: "password",
|
|
username,
|
|
password,
|
|
},
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
},
|
|
);
|
|
if (!tokenResp.ok()) {
|
|
throw new Error(
|
|
`Keycloak admin token request failed: ${tokenResp.status()} ${await tokenResp.text()}`,
|
|
);
|
|
}
|
|
const json = await tokenResp.json();
|
|
if (!json.access_token) {
|
|
throw new Error("Keycloak admin token response missing access_token");
|
|
}
|
|
return json.access_token;
|
|
}
|
|
|
|
// Resolves a group's internal Keycloak id from its human-readable path
|
|
// (e.g. "/roles/web-app-wazuh/administrator"). Tries the direct
|
|
// group-by-path admin endpoint first, since it is a single request; some
|
|
// Keycloak versions 404 that endpoint for paths containing certain
|
|
// characters, so on failure this falls back to walking the group tree one
|
|
// path segment at a time (list top-level groups filtered by name, then
|
|
// each subsequent segment's children), matching by exact name at each
|
|
// depth, and returns the id of the final matched segment.
|
|
async function keycloakResolveGroupId(request, keycloakBaseUrl, realmName, accessToken, groupPath) {
|
|
const headers = { Authorization: `Bearer ${accessToken}` };
|
|
const trimmed = groupPath.replace(/^\//, "");
|
|
const byPath = await request.get(
|
|
`${keycloakBaseUrl}/admin/realms/${encodeURIComponent(realmName)}/group-by-path/${trimmed}`,
|
|
{ headers },
|
|
);
|
|
if (byPath.ok()) {
|
|
const group = await byPath.json();
|
|
if (group?.id) return group.id;
|
|
}
|
|
const segments = trimmed.split("/").filter((s) => s !== "");
|
|
if (segments.length === 0) {
|
|
throw new Error(`Empty Keycloak group path: ${groupPath}`);
|
|
}
|
|
let parentId = null;
|
|
for (let i = 0; i < segments.length; i++) {
|
|
const wanted = segments[i];
|
|
const url =
|
|
parentId === null
|
|
? `${keycloakBaseUrl}/admin/realms/${realmName}/groups?max=500&search=${encodeURIComponent(wanted)}`
|
|
: `${keycloakBaseUrl}/admin/realms/${realmName}/groups/${parentId}/children?max=500`;
|
|
const resp = await request.get(url, { headers });
|
|
if (!resp.ok()) {
|
|
throw new Error(
|
|
`Keycloak groups lookup failed at segment ${i} (${wanted}): ${resp.status()} ${await resp.text()}`,
|
|
);
|
|
}
|
|
const items = await resp.json();
|
|
let match;
|
|
if (parentId === null) {
|
|
// The top-level search endpoint returns full subtrees, not just
|
|
// direct matches, so walk each returned tree looking for `wanted`
|
|
// at depth 0 only - a same-named descendant deeper in the tree
|
|
// MUST NOT be mistaken for the top-level segment.
|
|
const walk = (nodes, depth) => {
|
|
for (const n of nodes) {
|
|
if (depth === 0 && n.name === wanted) return n;
|
|
if (n.subGroups && n.subGroups.length) {
|
|
const r = walk(n.subGroups, depth - 1);
|
|
if (r) return r;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
match = walk(items, 0);
|
|
} else {
|
|
match = items.find((n) => n.name === wanted) || null;
|
|
}
|
|
if (!match) {
|
|
throw new Error(`Keycloak group "${groupPath}" not found while resolving segment "${wanted}"`);
|
|
}
|
|
parentId = match.id;
|
|
}
|
|
return parentId;
|
|
}
|
|
|
|
// Adds `username` to the group at `targetGroupPath`, fetching its own
|
|
// admin token internally. Returns true when this call performed the join
|
|
// (caller MUST tear down), false when the user was already a member
|
|
// (caller MUST NOT remove it, since removal would then affect membership
|
|
// this call did not grant).
|
|
async function keycloakAdminAddUserToGroup(
|
|
request,
|
|
keycloakBaseUrl,
|
|
realmName,
|
|
targetGroupPath,
|
|
username,
|
|
adminUsername,
|
|
adminPassword,
|
|
opts = {},
|
|
) {
|
|
const accessToken = await keycloakAdminToken(request, keycloakBaseUrl, adminUsername, adminPassword, opts);
|
|
const headers = { Authorization: `Bearer ${accessToken}` };
|
|
|
|
const userResp = await request.get(
|
|
`${keycloakBaseUrl}/admin/realms/${realmName}/users?username=${encodeURIComponent(username)}&exact=true`,
|
|
{ headers },
|
|
);
|
|
if (!userResp.ok()) {
|
|
throw new Error(`Keycloak user lookup failed: ${userResp.status()} ${await userResp.text()}`);
|
|
}
|
|
const users = await userResp.json();
|
|
const user = users.find((u) => u.username === username);
|
|
if (!user) {
|
|
throw new Error(`Keycloak user "${username}" not found`);
|
|
}
|
|
|
|
const groupId = await keycloakResolveGroupId(request, keycloakBaseUrl, realmName, accessToken, targetGroupPath);
|
|
|
|
const memberResp = await request.get(
|
|
`${keycloakBaseUrl}/admin/realms/${realmName}/users/${user.id}/groups?max=500`,
|
|
{ headers },
|
|
);
|
|
if (!memberResp.ok()) {
|
|
throw new Error(`Keycloak user-groups lookup failed: ${memberResp.status()} ${await memberResp.text()}`);
|
|
}
|
|
const currentGroups = await memberResp.json();
|
|
if (currentGroups.some((g) => g.id === groupId || g.path === targetGroupPath)) {
|
|
return false;
|
|
}
|
|
|
|
const joinResp = await request.put(
|
|
`${keycloakBaseUrl}/admin/realms/${realmName}/users/${user.id}/groups/${groupId}`,
|
|
{ headers },
|
|
);
|
|
if (!joinResp.ok()) {
|
|
throw new Error(
|
|
`Keycloak join-group failed (user=${username}, group=${targetGroupPath}): ${joinResp.status()} ${await joinResp.text()}`,
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function keycloakRemoveUserFromGroupViaRest(
|
|
request,
|
|
keycloakBaseUrl,
|
|
realmName,
|
|
adminUsername,
|
|
adminPassword,
|
|
groupPath,
|
|
username,
|
|
opts = {},
|
|
) {
|
|
const accessToken = await keycloakAdminToken(request, keycloakBaseUrl, adminUsername, adminPassword, opts);
|
|
const headers = { Authorization: `Bearer ${accessToken}` };
|
|
|
|
const userResp = await request.get(
|
|
`${keycloakBaseUrl}/admin/realms/${realmName}/users?username=${encodeURIComponent(username)}&exact=true`,
|
|
{ headers },
|
|
);
|
|
if (!userResp.ok()) {
|
|
throw new Error(`Keycloak user lookup failed: ${userResp.status()} ${await userResp.text()}`);
|
|
}
|
|
const users = await userResp.json();
|
|
const user = users.find((u) => u.username === username);
|
|
if (!user) {
|
|
throw new Error(`Keycloak user "${username}" not found`);
|
|
}
|
|
|
|
const groupId = await keycloakResolveGroupId(request, keycloakBaseUrl, realmName, accessToken, groupPath);
|
|
|
|
const leaveResp = await request.delete(
|
|
`${keycloakBaseUrl}/admin/realms/${realmName}/users/${user.id}/groups/${groupId}`,
|
|
{ headers },
|
|
);
|
|
if (!leaveResp.ok()) {
|
|
throw new Error(
|
|
`Keycloak leave-group failed (user=${username}, group=${groupPath}): ${leaveResp.status()} ${await leaveResp.text()}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
OIDC_LOGIN_READY_FLAG,
|
|
performKeycloakLoginForm,
|
|
performKeycloakLogin,
|
|
clickOidcLoginLink,
|
|
performKeycloakLoginExpectingDenial,
|
|
keycloakAdminToken,
|
|
keycloakResolveGroupId,
|
|
keycloakAdminAddUserToGroup,
|
|
keycloakRemoveUserFromGroupViaRest,
|
|
};
|