mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
biber starts in no group and reaches no privileged surface. For each tier the administrator signs into LAM, adds him to the role group through Multi edit, and the spec asserts both the policy set he receives and the capability that tier grants, then removes him and asserts the revoke. One account covers all three tiers and the grant and revoke are observed rather than assumed. LAM's group list renders posixGroup entries only, while the RBAC role groups are groupOfNames under ou=roles, so Multi edit is the surface that reaches them. It applies the change asynchronously, so the spec polls the resulting policy set rather than LAM's own progress wording.
216 lines
8.4 KiB
JavaScript
216 lines
8.4 KiB
JavaScript
const { test, expect } = require("@playwright/test");
|
|
const { normalizeBaseUrl, decodeDotenvQuotedValue, gotoOnion } = require("./personas");
|
|
const { skipUnlessServiceEnabled, isServiceEnabled } = require("./service-gating");
|
|
const { resolveTimeout } = require("./timeouts");
|
|
|
|
const baseUrl = normalizeBaseUrl(process.env.OPENBAO_BASE_URL || "");
|
|
const kvMount = decodeDotenvQuotedValue(process.env.OPENBAO_KV_MOUNT || "");
|
|
const biberUsername = decodeDotenvQuotedValue(process.env.BIBER_USERNAME || "");
|
|
const biberPassword = decodeDotenvQuotedValue(process.env.BIBER_PASSWORD || "");
|
|
const lamBaseUrl = normalizeBaseUrl(process.env.LAM_BASE_URL || "");
|
|
const lamOauth2Fronted = String(process.env.LAM_OAUTH2_FRONTED || "").toLowerCase() === "true";
|
|
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME || "");
|
|
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD || "");
|
|
const ldapAdminPassword = decodeDotenvQuotedValue(process.env.LDAP_ADMIN_PASSWORD || "");
|
|
const groupDnTemplate = decodeDotenvQuotedValue(process.env.LDAP_RBAC_GROUP_DN_TEMPLATE || "");
|
|
const userDnTemplate = decodeDotenvQuotedValue(process.env.LDAP_USER_DN_TEMPLATE || "");
|
|
|
|
const RBAC_ROLES = ["administrator", "operator", "reader"];
|
|
const PROBE_PATH = `${kvMount}/data/playwright/rbac-probe`;
|
|
const POLICY_PATH = "sys/policies/acl/operator";
|
|
|
|
const groupDn = (role) => groupDnTemplate.replace("<role>", role);
|
|
const biberDn = () => userDnTemplate.replace("<uid>", biberUsername);
|
|
const groupCn = (role) => groupDn(role).replace(/^cn=/, "").split(",")[0];
|
|
const rootSuffix = () => groupDn("administrator").split(",").slice(-2).join(",");
|
|
|
|
test.use({ ignoreHTTPSErrors: true });
|
|
|
|
async function signInToLam(page) {
|
|
await gotoOnion(page, `${lamBaseUrl}/lam/templates/login.php`, { waitUntil: "load" });
|
|
|
|
if (lamOauth2Fronted) {
|
|
const keycloakUsername = page.locator("input[name='username'], input#username").first();
|
|
if (await keycloakUsername.isVisible().catch(() => false)) {
|
|
await keycloakUsername.fill(adminUsername);
|
|
await page.locator("input[name='password'], input#password").first().fill(adminPassword);
|
|
await page
|
|
.locator("button[type='submit'], input[name='login'], input[type='submit']")
|
|
.first()
|
|
.click({ timeout: resolveTimeout(30_000) });
|
|
await page.waitForLoadState("networkidle");
|
|
}
|
|
}
|
|
|
|
const lamPassword = page.locator("input[name='passwd'], input#passwd").first();
|
|
await expect(lamPassword, "LAM must present its bind-password form").toBeVisible({
|
|
timeout: resolveTimeout(30_000),
|
|
});
|
|
await lamPassword.fill(ldapAdminPassword);
|
|
await page
|
|
.locator("button[type='submit'], input[type='submit']")
|
|
.first()
|
|
.click({ timeout: resolveTimeout(30_000) });
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
await expect(
|
|
page.locator("a[href*='list.php?type=user']").first(),
|
|
"the administrator must reach LAM's account menu after signing in",
|
|
).toBeVisible({ timeout: resolveTimeout(30_000) });
|
|
}
|
|
|
|
// LAM's group list only renders posixGroup entries, and the RBAC role groups are
|
|
// groupOfNames under ou=roles, so the members are edited through the Multi edit
|
|
// tool, which applies an attribute operation to every entry matching a filter.
|
|
async function setGroupMembership(page, role, operation) {
|
|
await gotoOnion(page, `${lamBaseUrl}/lam/templates/tools/multiEdit.php`, { waitUntil: "load" });
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
await page.locator("select#suffix").selectOption(rootSuffix());
|
|
await page.locator("input#filter").fill(`(cn=${groupCn(role)})`);
|
|
await page.locator("select#op_0").selectOption(operation);
|
|
await page.locator("input#attr_0").fill("member");
|
|
await page.locator("input#val_0").fill(biberDn());
|
|
|
|
await page.locator("#btn_applyChanges").click({ timeout: resolveTimeout(30_000) });
|
|
}
|
|
|
|
// LAM applies the change asynchronously, so the directory is only consistent
|
|
// some time after the click. Poll the effect rather than the tool's own wording.
|
|
async function expectTierAfterLam(request, role, present) {
|
|
await expect
|
|
.poll(async () => (await loginAsBiber(request)).policies.includes(role), {
|
|
timeout: resolveTimeout(60_000),
|
|
message: present
|
|
? `LAM adding ${biberDn()} to ${groupCn(role)} must grant the '${role}' policy`
|
|
: `LAM removing ${biberDn()} from ${groupCn(role)} must revoke the '${role}' policy`,
|
|
})
|
|
.toBe(present);
|
|
return loginAsBiber(request);
|
|
}
|
|
|
|
async function loginAsBiber(request) {
|
|
const response = await request.post(
|
|
`${baseUrl}/v1/auth/ldap/login/${encodeURIComponent(biberUsername)}`,
|
|
{ data: { password: biberPassword }, failOnStatusCode: false, timeout: resolveTimeout(30_000) },
|
|
);
|
|
expect(
|
|
response.status(),
|
|
`the LDAP auth method must accept ${biberUsername}, got ${response.status()}`,
|
|
).toBe(200);
|
|
const body = await response.json();
|
|
return { policies: body.auth.policies || [], token: body.auth.client_token };
|
|
}
|
|
|
|
const writeProbe = (request, token) =>
|
|
request
|
|
.post(`${baseUrl}/v1/${PROBE_PATH}`, {
|
|
headers: { "X-Vault-Token": token },
|
|
data: { data: { probe: "rbac" } },
|
|
failOnStatusCode: false,
|
|
timeout: resolveTimeout(30_000),
|
|
})
|
|
.then((r) => r.status());
|
|
|
|
const readProbe = (request, token) =>
|
|
request
|
|
.get(`${baseUrl}/v1/${PROBE_PATH}`, {
|
|
headers: { "X-Vault-Token": token },
|
|
failOnStatusCode: false,
|
|
timeout: resolveTimeout(30_000),
|
|
})
|
|
.then((r) => r.status());
|
|
|
|
const readPolicy = (request, token) =>
|
|
request
|
|
.get(`${baseUrl}/v1/${POLICY_PATH}`, {
|
|
headers: { "X-Vault-Token": token },
|
|
failOnStatusCode: false,
|
|
timeout: resolveTimeout(30_000),
|
|
})
|
|
.then((r) => r.status());
|
|
|
|
// What each tier must be able to do, read off templates/policies/*.hcl.j2.
|
|
const TIER_CAPABILITIES = {
|
|
administrator: async (request, token) => {
|
|
expect(
|
|
await readPolicy(request, token),
|
|
"the administrator tier must read a policy definition",
|
|
).toBe(200);
|
|
},
|
|
operator: async (request, token) => {
|
|
expect(
|
|
[200, 204],
|
|
"the operator tier must write an application secret",
|
|
).toContain(await writeProbe(request, token));
|
|
expect(
|
|
await readPolicy(request, token),
|
|
"the operator tier must not read policy definitions",
|
|
).toBe(403);
|
|
},
|
|
reader: async (request, token) => {
|
|
expect(
|
|
await writeProbe(request, token),
|
|
"the reader tier must not write an application secret",
|
|
).toBe(403);
|
|
expect(
|
|
[200, 404],
|
|
"the reader tier must be permitted to read the application path",
|
|
).toContain(await readProbe(request, token));
|
|
},
|
|
};
|
|
|
|
test.describe("rbac: the OpenBao tiers follow LDAP group membership", () => {
|
|
test.describe.configure({ mode: "serial" });
|
|
|
|
test("rbac: biber starts outside every OpenBao role group", async ({ request }) => {
|
|
skipUnlessServiceEnabled("ldap");
|
|
|
|
const { policies, token } = await loginAsBiber(request);
|
|
for (const role of RBAC_ROLES) {
|
|
expect(policies, `biber must hold no '${role}' policy before any group change`).not.toContain(
|
|
role,
|
|
);
|
|
}
|
|
expect(
|
|
await readPolicy(request, token),
|
|
"a member of no role group must not read policy definitions",
|
|
).toBe(403);
|
|
expect(
|
|
await writeProbe(request, token),
|
|
"a member of no role group must not write an application secret",
|
|
).toBe(403);
|
|
});
|
|
|
|
for (const role of RBAC_ROLES) {
|
|
test(`rbac: LAM granting the ${role} group hands biber that tier, and revoking takes it back`, async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
skipUnlessServiceEnabled("ldap");
|
|
test.skip(!isServiceEnabled("lam"), "LAM not deployed (LAM_SERVICE_ENABLED=false)");
|
|
test.setTimeout(resolveTimeout(180_000));
|
|
|
|
await signInToLam(page);
|
|
await setGroupMembership(page, role, "add");
|
|
|
|
const granted = await expectTierAfterLam(request, role, true);
|
|
for (const other of RBAC_ROLES.filter((candidate) => candidate !== role)) {
|
|
expect(
|
|
granted.policies,
|
|
`biber must not receive the '${other}' policy while only in the ${role} group`,
|
|
).not.toContain(other);
|
|
}
|
|
await TIER_CAPABILITIES[role](request, granted.token);
|
|
|
|
await setGroupMembership(page, role, "del");
|
|
|
|
const revoked = await expectTierAfterLam(request, role, false);
|
|
expect(
|
|
await readPolicy(request, revoked.token),
|
|
"a revoked member must not read policy definitions",
|
|
).toBe(403);
|
|
});
|
|
}
|
|
});
|