297 lines
9.7 KiB
JavaScript
297 lines
9.7 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { describe, it } from "vitest";
|
|
|
|
import {
|
|
MANAGED_ROLES,
|
|
PROVISION_DATABASE_URL_ENV,
|
|
ROLE_PASSWORD_ENV,
|
|
ROLE_PROVISIONING_SQL,
|
|
RlsRoleProvisioningError,
|
|
provisionRlsRoles,
|
|
readProvisioningConfig,
|
|
verifyProvisioningState,
|
|
} from "./provision-rls-roles.mjs";
|
|
|
|
const SECRETS = Object.freeze({
|
|
adminUrl: "postgresql://postgres:admin-secret@db.example.test:5432/hivegrid",
|
|
migrator: "migrator-secret-abcdefghijklmnopqrstuvwxyz",
|
|
app: "app-secret-abcdefghijklmnopqrstuvwxyz-1234",
|
|
portal: "portal-secret-abcdefghijklmnopqrstuvwxyz",
|
|
worker: "worker-secret-abcdefghijklmnopqrstuvwxyz",
|
|
authentikJit: "authentik-jit-secret-abcdefghijklmnop",
|
|
});
|
|
|
|
function validEnv(overrides = {}) {
|
|
return {
|
|
[PROVISION_DATABASE_URL_ENV]: SECRETS.adminUrl,
|
|
[ROLE_PASSWORD_ENV.hivegrid_migrator]: SECRETS.migrator,
|
|
[ROLE_PASSWORD_ENV.hivegrid_app]: SECRETS.app,
|
|
[ROLE_PASSWORD_ENV.hivegrid_portal]: SECRETS.portal,
|
|
[ROLE_PASSWORD_ENV.hivegrid_worker]: SECRETS.worker,
|
|
[ROLE_PASSWORD_ENV.hivegrid_authentik_jit]: SECRETS.authentikJit,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function validRoles() {
|
|
return [
|
|
{
|
|
roleName: "hivegrid_app",
|
|
canLogin: true,
|
|
isSuperuser: false,
|
|
canCreateDb: false,
|
|
canCreateRole: false,
|
|
inherits: false,
|
|
replicates: false,
|
|
bypassesRls: false,
|
|
passwordStateValid: true,
|
|
},
|
|
{
|
|
roleName: "hivegrid_authentik_jit",
|
|
canLogin: true,
|
|
isSuperuser: false,
|
|
canCreateDb: false,
|
|
canCreateRole: false,
|
|
inherits: false,
|
|
replicates: false,
|
|
bypassesRls: false,
|
|
passwordStateValid: true,
|
|
},
|
|
{
|
|
roleName: "hivegrid_migrator",
|
|
canLogin: true,
|
|
isSuperuser: false,
|
|
canCreateDb: false,
|
|
canCreateRole: false,
|
|
inherits: false,
|
|
replicates: false,
|
|
bypassesRls: false,
|
|
passwordStateValid: true,
|
|
},
|
|
{
|
|
roleName: "hivegrid_portal",
|
|
canLogin: true,
|
|
isSuperuser: false,
|
|
canCreateDb: false,
|
|
canCreateRole: false,
|
|
inherits: false,
|
|
replicates: false,
|
|
bypassesRls: false,
|
|
passwordStateValid: true,
|
|
},
|
|
{
|
|
roleName: "hivegrid_rls_definer",
|
|
canLogin: false,
|
|
isSuperuser: false,
|
|
canCreateDb: false,
|
|
canCreateRole: false,
|
|
inherits: false,
|
|
replicates: false,
|
|
bypassesRls: true,
|
|
passwordStateValid: true,
|
|
},
|
|
{
|
|
roleName: "hivegrid_worker",
|
|
canLogin: true,
|
|
isSuperuser: false,
|
|
canCreateDb: false,
|
|
canCreateRole: false,
|
|
inherits: false,
|
|
replicates: false,
|
|
bypassesRls: false,
|
|
passwordStateValid: true,
|
|
},
|
|
];
|
|
}
|
|
|
|
function validMemberships() {
|
|
return [
|
|
{
|
|
grantedRole: "hivegrid_rls_definer",
|
|
memberRole: "hivegrid_migrator",
|
|
adminOption: false,
|
|
inheritOption: false,
|
|
setOption: true,
|
|
},
|
|
];
|
|
}
|
|
|
|
class FakeClient {
|
|
constructor({ superuser = true, failProvisioning = false, ownership } = {}) {
|
|
this.superuser = superuser;
|
|
this.failProvisioning = failProvisioning;
|
|
this.ownership = ownership ?? {
|
|
databaseOwner: "hivegrid_migrator",
|
|
schemaOwner: "hivegrid_migrator",
|
|
unexpectedOwnerCount: 0,
|
|
};
|
|
this.calls = [];
|
|
this.connected = false;
|
|
this.ended = false;
|
|
}
|
|
|
|
async connect() {
|
|
this.connected = true;
|
|
}
|
|
|
|
async end() {
|
|
this.ended = true;
|
|
}
|
|
|
|
async query(text, values) {
|
|
this.calls.push({ text, values });
|
|
if (text.includes('current_user AS "roleName"')) {
|
|
return { rows: [{ roleName: "postgres", isSuperuser: this.superuser }] };
|
|
}
|
|
if (text === ROLE_PROVISIONING_SQL && this.failProvisioning) {
|
|
throw new Error(`database rejected ${SECRETS.adminUrl}`);
|
|
}
|
|
if (text.includes('rolname AS "roleName"') && text.includes("FROM pg_authid")) {
|
|
return { rows: validRoles() };
|
|
}
|
|
if (text.includes('granted.rolname AS "grantedRole"')) {
|
|
return { rows: validMemberships() };
|
|
}
|
|
if (text.includes('AS "databaseOwner"')) {
|
|
return { rows: [this.ownership] };
|
|
}
|
|
return { rows: [] };
|
|
}
|
|
}
|
|
|
|
describe("provision-rls-roles", () => {
|
|
it("requires an explicit admin URL and five long, distinct passwords", () => {
|
|
assert.throws(() => readProvisioningConfig({}), new RegExp(PROVISION_DATABASE_URL_ENV));
|
|
assert.throws(() => readProvisioningConfig(validEnv({ [ROLE_PASSWORD_ENV.hivegrid_app]: "short" })), new RegExp(ROLE_PASSWORD_ENV.hivegrid_app));
|
|
assert.throws(() => readProvisioningConfig(validEnv({ [ROLE_PASSWORD_ENV.hivegrid_worker]: SECRETS.app })), /distinct password/);
|
|
assert.throws(() => readProvisioningConfig(validEnv({ [ROLE_PASSWORD_ENV.hivegrid_portal]: "short" })), new RegExp(ROLE_PASSWORD_ENV.hivegrid_portal));
|
|
assert.throws(
|
|
() => readProvisioningConfig(validEnv({ [ROLE_PASSWORD_ENV.hivegrid_authentik_jit]: "short" })),
|
|
new RegExp(ROLE_PASSWORD_ENV.hivegrid_authentik_jit)
|
|
);
|
|
});
|
|
|
|
it("never embeds password inputs in the role DDL", () => {
|
|
for (const secret of [SECRETS.migrator, SECRETS.app, SECRETS.portal, SECRETS.worker, SECRETS.authentikJit]) {
|
|
assert.doesNotMatch(ROLE_PROVISIONING_SQL, new RegExp(secret));
|
|
}
|
|
assert.match(ROLE_PROVISIONING_SQL, /hivegrid_migrator LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS/);
|
|
assert.match(ROLE_PROVISIONING_SQL, /hivegrid_portal LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS/);
|
|
assert.match(ROLE_PROVISIONING_SQL, /hivegrid_authentik_jit LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS/);
|
|
assert.match(ROLE_PROVISIONING_SQL, /hivegrid_rls_definer NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION BYPASSRLS PASSWORD NULL/);
|
|
assert.match(ROLE_PROVISIONING_SQL, /GRANT hivegrid_rls_definer TO hivegrid_migrator/);
|
|
assert.match(ROLE_PROVISIONING_SQL, /WITH ADMIN FALSE, INHERIT FALSE, SET TRUE/);
|
|
});
|
|
|
|
it("provisions atomically, binds passwords as query parameters, and emits only a bounded result", async () => {
|
|
const client = new FakeClient();
|
|
let output = "";
|
|
|
|
await provisionRlsRoles({
|
|
env: validEnv(),
|
|
clientFactory: () => client,
|
|
stdout: { write: (value) => (output += value) },
|
|
});
|
|
|
|
assert.equal(client.connected, true);
|
|
assert.equal(client.ended, true);
|
|
assert.ok(client.calls.some((call) => call.text === "BEGIN"));
|
|
assert.ok(client.calls.some((call) => call.text === "COMMIT"));
|
|
assert.ok(!client.calls.some((call) => call.text === "ROLLBACK"));
|
|
|
|
const passwordBinding = client.calls.find((call) => call.text.includes("hivegrid.provision.migrator_password"));
|
|
assert.deepEqual(passwordBinding.values, [SECRETS.migrator, SECRETS.app, SECRETS.portal, SECRETS.worker, SECRETS.authentikJit]);
|
|
for (const call of client.calls) {
|
|
for (const secret of Object.values(SECRETS)) {
|
|
assert.doesNotMatch(call.text, new RegExp(secret));
|
|
}
|
|
}
|
|
assert.equal(output, "PASS HiveGrid PostgreSQL roles provisioned and verified\n");
|
|
for (const secret of Object.values(SECRETS)) {
|
|
assert.doesNotMatch(output, new RegExp(secret));
|
|
}
|
|
});
|
|
|
|
it("fails before a transaction when the provisioning connection is not a superuser", async () => {
|
|
const client = new FakeClient({ superuser: false });
|
|
|
|
await assert.rejects(
|
|
provisionRlsRoles({ env: validEnv(), clientFactory: () => client }),
|
|
new RegExp(`${PROVISION_DATABASE_URL_ENV} must authenticate as a PostgreSQL superuser`)
|
|
);
|
|
|
|
assert.ok(!client.calls.some((call) => call.text === "BEGIN"));
|
|
assert.equal(client.ended, true);
|
|
});
|
|
|
|
it("rolls back and suppresses database error details", async () => {
|
|
const client = new FakeClient({ failProvisioning: true });
|
|
let error;
|
|
|
|
try {
|
|
await provisionRlsRoles({ env: validEnv(), clientFactory: () => client });
|
|
} catch (caught) {
|
|
error = caught;
|
|
}
|
|
|
|
assert.ok(error instanceof RlsRoleProvisioningError);
|
|
assert.match(error.message, /inspect restricted database logs/);
|
|
assert.doesNotMatch(error.message, /admin-secret|db\.example\.test/);
|
|
assert.ok(client.calls.some((call) => call.text === "ROLLBACK"));
|
|
assert.equal(client.ended, true);
|
|
});
|
|
|
|
it("fails closed on any role, membership, or ownership drift", () => {
|
|
assert.throws(
|
|
() =>
|
|
verifyProvisioningState({
|
|
roles: validRoles().map((role) => (role.roleName === "hivegrid_app" ? { ...role, bypassesRls: true } : role)),
|
|
memberships: validMemberships(),
|
|
ownership: {
|
|
databaseOwner: "hivegrid_migrator",
|
|
schemaOwner: "hivegrid_migrator",
|
|
unexpectedOwnerCount: 0,
|
|
},
|
|
}),
|
|
/hivegrid_app does not match/
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
verifyProvisioningState({
|
|
roles: validRoles(),
|
|
memberships: [...validMemberships(), { grantedRole: "admin", memberRole: "hivegrid_app" }],
|
|
ownership: {
|
|
databaseOwner: "hivegrid_migrator",
|
|
schemaOwner: "hivegrid_migrator",
|
|
unexpectedOwnerCount: 0,
|
|
},
|
|
}),
|
|
/unexpected membership/
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
verifyProvisioningState({
|
|
roles: validRoles(),
|
|
memberships: validMemberships(),
|
|
ownership: {
|
|
databaseOwner: "postgres",
|
|
schemaOwner: "hivegrid_migrator",
|
|
unexpectedOwnerCount: 1,
|
|
},
|
|
}),
|
|
/not exclusively owned/
|
|
);
|
|
});
|
|
|
|
it("defines exactly the six managed role names", () => {
|
|
assert.deepEqual(MANAGED_ROLES, [
|
|
"hivegrid_migrator",
|
|
"hivegrid_app",
|
|
"hivegrid_portal",
|
|
"hivegrid_worker",
|
|
"hivegrid_authentik_jit",
|
|
"hivegrid_rls_definer",
|
|
]);
|
|
});
|
|
});
|