Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/provision-rls-roles.mjs
T

500 lines
18 KiB
JavaScript

#!/usr/bin/env node
import path from "node:path";
import { fileURLToPath } from "node:url";
import pg from "pg";
export const PROVISION_DATABASE_URL_ENV = "HIVEGRID_RLS_PROVISION_DATABASE_URL";
export const ROLE_PASSWORD_ENV = Object.freeze({
hivegrid_migrator: "HIVEGRID_RLS_MIGRATOR_PASSWORD",
hivegrid_app: "HIVEGRID_RLS_APP_PASSWORD",
hivegrid_portal: "HIVEGRID_RLS_PORTAL_PASSWORD",
hivegrid_worker: "HIVEGRID_RLS_WORKER_PASSWORD",
hivegrid_authentik_jit: "HIVEGRID_RLS_AUTHENTIK_JIT_PASSWORD",
});
export const MANAGED_ROLES = Object.freeze([
"hivegrid_migrator",
"hivegrid_app",
"hivegrid_portal",
"hivegrid_worker",
"hivegrid_authentik_jit",
"hivegrid_rls_definer",
]);
const MINIMUM_PASSWORD_LENGTH = 32;
const EXPECTED_ROLE_ATTRIBUTES = Object.freeze({
hivegrid_migrator: {
canLogin: true,
isSuperuser: false,
canCreateDb: false,
canCreateRole: false,
inherits: false,
replicates: false,
bypassesRls: false,
passwordStateValid: true,
},
hivegrid_app: {
canLogin: true,
isSuperuser: false,
canCreateDb: false,
canCreateRole: false,
inherits: false,
replicates: false,
bypassesRls: false,
passwordStateValid: true,
},
hivegrid_portal: {
canLogin: true,
isSuperuser: false,
canCreateDb: false,
canCreateRole: false,
inherits: false,
replicates: false,
bypassesRls: false,
passwordStateValid: true,
},
hivegrid_worker: {
canLogin: true,
isSuperuser: false,
canCreateDb: false,
canCreateRole: false,
inherits: false,
replicates: false,
bypassesRls: false,
passwordStateValid: true,
},
hivegrid_authentik_jit: {
canLogin: true,
isSuperuser: false,
canCreateDb: false,
canCreateRole: false,
inherits: false,
replicates: false,
bypassesRls: false,
passwordStateValid: true,
},
hivegrid_rls_definer: {
canLogin: false,
isSuperuser: false,
canCreateDb: false,
canCreateRole: false,
inherits: false,
replicates: false,
bypassesRls: true,
passwordStateValid: true,
},
});
export const ROLE_PROVISIONING_SQL = String.raw`
DO $hivegrid_role_provisioning$
DECLARE
membership_edge record;
relation_object record;
routine_object record;
type_object record;
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'hivegrid_migrator') THEN
CREATE ROLE hivegrid_migrator;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'hivegrid_app') THEN
CREATE ROLE hivegrid_app;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'hivegrid_portal') THEN
CREATE ROLE hivegrid_portal;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'hivegrid_worker') THEN
CREATE ROLE hivegrid_worker;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'hivegrid_authentik_jit') THEN
CREATE ROLE hivegrid_authentik_jit;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'hivegrid_rls_definer') THEN
CREATE ROLE hivegrid_rls_definer;
END IF;
ALTER ROLE hivegrid_migrator LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS VALID UNTIL 'infinity';
ALTER ROLE hivegrid_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS VALID UNTIL 'infinity';
ALTER ROLE hivegrid_portal LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS VALID UNTIL 'infinity';
ALTER ROLE hivegrid_worker LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS VALID UNTIL 'infinity';
ALTER ROLE hivegrid_authentik_jit LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS VALID UNTIL 'infinity';
ALTER ROLE hivegrid_rls_definer NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION BYPASSRLS PASSWORD NULL;
EXECUTE format(
'ALTER ROLE hivegrid_migrator PASSWORD %L',
current_setting('hivegrid.provision.migrator_password')
);
EXECUTE format(
'ALTER ROLE hivegrid_app PASSWORD %L',
current_setting('hivegrid.provision.app_password')
);
EXECUTE format(
'ALTER ROLE hivegrid_portal PASSWORD %L',
current_setting('hivegrid.provision.portal_password')
);
EXECUTE format(
'ALTER ROLE hivegrid_worker PASSWORD %L',
current_setting('hivegrid.provision.worker_password')
);
EXECUTE format(
'ALTER ROLE hivegrid_authentik_jit PASSWORD %L',
current_setting('hivegrid.provision.authentik_jit_password')
);
-- Remove every membership edge involving a managed role before restoring
-- the single reviewed edge. This also removes external SET ROLE access to a
-- runtime or definer role.
FOR membership_edge IN
SELECT granted.rolname AS granted_role, member.rolname AS member_role
FROM pg_auth_members AS membership
JOIN pg_roles AS granted ON granted.oid = membership.roleid
JOIN pg_roles AS member ON member.oid = membership.member
WHERE granted.rolname = ANY (ARRAY['hivegrid_migrator', 'hivegrid_app', 'hivegrid_portal', 'hivegrid_worker', 'hivegrid_authentik_jit', 'hivegrid_rls_definer'])
OR member.rolname = ANY (ARRAY['hivegrid_migrator', 'hivegrid_app', 'hivegrid_portal', 'hivegrid_worker', 'hivegrid_authentik_jit', 'hivegrid_rls_definer'])
LOOP
EXECUTE format('REVOKE %I FROM %I', membership_edge.granted_role, membership_edge.member_role);
END LOOP;
GRANT hivegrid_rls_definer TO hivegrid_migrator
WITH ADMIN FALSE, INHERIT FALSE, SET TRUE;
-- The migration principal owns the database namespace and all non-extension
-- application objects. Extension-owned objects must retain their extension
-- owner so upgrades remain valid.
EXECUTE format('ALTER DATABASE %I OWNER TO hivegrid_migrator', current_database());
ALTER SCHEMA public OWNER TO hivegrid_migrator;
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
GRANT USAGE, CREATE ON SCHEMA public TO hivegrid_migrator;
FOR relation_object IN
SELECT namespace.nspname AS schema_name, relation.relname AS object_name, relation.relkind
FROM pg_class AS relation
JOIN pg_namespace AS namespace ON namespace.oid = relation.relnamespace
WHERE namespace.nspname = 'public'
AND relation.relkind IN ('r', 'p', 'S', 'v', 'm', 'f')
AND relation.relowner NOT IN (
SELECT oid FROM pg_roles WHERE rolname IN ('hivegrid_migrator', 'hivegrid_rls_definer')
)
AND NOT EXISTS (
SELECT 1
FROM pg_depend AS dependency
WHERE dependency.classid = 'pg_class'::regclass
AND dependency.objid = relation.oid
AND dependency.deptype = 'e'
)
ORDER BY CASE relation.relkind WHEN 'r' THEN 0 WHEN 'p' THEN 0 ELSE 1 END, relation.oid
LOOP
EXECUTE format(
CASE relation_object.relkind
WHEN 'S' THEN 'ALTER SEQUENCE %I.%I OWNER TO hivegrid_migrator'
WHEN 'v' THEN 'ALTER VIEW %I.%I OWNER TO hivegrid_migrator'
WHEN 'm' THEN 'ALTER MATERIALIZED VIEW %I.%I OWNER TO hivegrid_migrator'
WHEN 'f' THEN 'ALTER FOREIGN TABLE %I.%I OWNER TO hivegrid_migrator'
ELSE 'ALTER TABLE %I.%I OWNER TO hivegrid_migrator'
END,
relation_object.schema_name,
relation_object.object_name
);
END LOOP;
FOR routine_object IN
SELECT
namespace.nspname AS schema_name,
routine.proname AS object_name,
routine.prokind,
pg_get_function_identity_arguments(routine.oid) AS identity_arguments
FROM pg_proc AS routine
JOIN pg_namespace AS namespace ON namespace.oid = routine.pronamespace
WHERE namespace.nspname = 'public'
AND routine.proowner NOT IN (
SELECT oid FROM pg_roles WHERE rolname IN ('hivegrid_migrator', 'hivegrid_rls_definer')
)
AND NOT EXISTS (
SELECT 1
FROM pg_depend AS dependency
WHERE dependency.classid = 'pg_proc'::regclass
AND dependency.objid = routine.oid
AND dependency.deptype = 'e'
)
LOOP
EXECUTE format(
CASE routine_object.prokind
WHEN 'p' THEN 'ALTER PROCEDURE %I.%I(%s) OWNER TO hivegrid_migrator'
WHEN 'a' THEN 'ALTER AGGREGATE %I.%I(%s) OWNER TO hivegrid_migrator'
ELSE 'ALTER FUNCTION %I.%I(%s) OWNER TO hivegrid_migrator'
END,
routine_object.schema_name,
routine_object.object_name,
routine_object.identity_arguments
);
END LOOP;
FOR type_object IN
SELECT namespace.nspname AS schema_name, type_definition.typname AS object_name, type_definition.typtype
FROM pg_type AS type_definition
JOIN pg_namespace AS namespace ON namespace.oid = type_definition.typnamespace
WHERE namespace.nspname = 'public'
AND type_definition.typtype IN ('c', 'd', 'e')
AND type_definition.typrelid = 0
AND type_definition.typowner NOT IN (
SELECT oid FROM pg_roles WHERE rolname IN ('hivegrid_migrator', 'hivegrid_rls_definer')
)
AND NOT EXISTS (
SELECT 1
FROM pg_depend AS dependency
WHERE dependency.classid = 'pg_type'::regclass
AND dependency.objid = type_definition.oid
AND dependency.deptype = 'e'
)
LOOP
EXECUTE format(
CASE type_object.typtype
WHEN 'd' THEN 'ALTER DOMAIN %I.%I OWNER TO hivegrid_migrator'
ELSE 'ALTER TYPE %I.%I OWNER TO hivegrid_migrator'
END,
type_object.schema_name,
type_object.object_name
);
END LOOP;
EXECUTE format('REVOKE CONNECT, CREATE, TEMPORARY ON DATABASE %I FROM PUBLIC', current_database());
EXECUTE format('REVOKE ALL PRIVILEGES ON DATABASE %I FROM hivegrid_app, hivegrid_portal, hivegrid_worker, hivegrid_authentik_jit, hivegrid_rls_definer', current_database());
EXECUTE format('GRANT CONNECT, TEMPORARY ON DATABASE %I TO hivegrid_migrator', current_database());
EXECUTE format('GRANT CONNECT ON DATABASE %I TO hivegrid_app, hivegrid_portal, hivegrid_worker, hivegrid_authentik_jit', current_database());
END
$hivegrid_role_provisioning$;
`;
const ROLE_STATE_QUERY = `
SELECT
rolname AS "roleName",
rolcanlogin AS "canLogin",
rolsuper AS "isSuperuser",
rolcreatedb AS "canCreateDb",
rolcreaterole AS "canCreateRole",
rolinherit AS "inherits",
rolreplication AS "replicates",
rolbypassrls AS "bypassesRls",
CASE
WHEN rolname = 'hivegrid_rls_definer' THEN rolpassword IS NULL
ELSE rolpassword LIKE 'SCRAM-SHA-256$%'
END AS "passwordStateValid"
FROM pg_authid
WHERE rolname = ANY ($1::text[])
ORDER BY rolname;
`;
const MEMBERSHIP_STATE_QUERY = `
SELECT
granted.rolname AS "grantedRole",
member.rolname AS "memberRole",
membership.admin_option AS "adminOption",
membership.inherit_option AS "inheritOption",
membership.set_option AS "setOption"
FROM pg_auth_members AS membership
JOIN pg_roles AS granted ON granted.oid = membership.roleid
JOIN pg_roles AS member ON member.oid = membership.member
WHERE granted.rolname = ANY ($1::text[])
OR member.rolname = ANY ($1::text[])
ORDER BY granted.rolname, member.rolname;
`;
const OWNERSHIP_STATE_QUERY = `
WITH unexpected_owner AS (
SELECT relation.relowner AS owner_id
FROM pg_class AS relation
JOIN pg_namespace AS namespace ON namespace.oid = relation.relnamespace
WHERE namespace.nspname = 'public'
AND relation.relkind IN ('r', 'p', 'S', 'v', 'm', 'f')
AND relation.relowner NOT IN (
SELECT oid FROM pg_roles WHERE rolname IN ('hivegrid_migrator', 'hivegrid_rls_definer')
)
AND NOT EXISTS (
SELECT 1 FROM pg_depend AS dependency
WHERE dependency.classid = 'pg_class'::regclass
AND dependency.objid = relation.oid
AND dependency.deptype = 'e'
)
UNION ALL
SELECT routine.proowner
FROM pg_proc AS routine
JOIN pg_namespace AS namespace ON namespace.oid = routine.pronamespace
WHERE namespace.nspname = 'public'
AND routine.proowner NOT IN (
SELECT oid FROM pg_roles WHERE rolname IN ('hivegrid_migrator', 'hivegrid_rls_definer')
)
AND NOT EXISTS (
SELECT 1 FROM pg_depend AS dependency
WHERE dependency.classid = 'pg_proc'::regclass
AND dependency.objid = routine.oid
AND dependency.deptype = 'e'
)
)
SELECT
pg_get_userbyid(database_definition.datdba) AS "databaseOwner",
pg_get_userbyid(schema_definition.nspowner) AS "schemaOwner",
(SELECT count(*)::integer FROM unexpected_owner) AS "unexpectedOwnerCount"
FROM pg_database AS database_definition
CROSS JOIN pg_namespace AS schema_definition
WHERE database_definition.datname = current_database()
AND schema_definition.nspname = 'public';
`;
export class RlsRoleProvisioningError extends Error {
constructor(message, options) {
super(message, options);
this.name = "RlsRoleProvisioningError";
}
}
function requirePassword(env, roleName) {
const key = ROLE_PASSWORD_ENV[roleName];
const password = env[key];
if (typeof password !== "string" || password.length < MINIMUM_PASSWORD_LENGTH) {
throw new RlsRoleProvisioningError(`${key} must contain at least ${MINIMUM_PASSWORD_LENGTH} characters`);
}
if (/\0|\r|\n/.test(password)) {
throw new RlsRoleProvisioningError(`${key} must not contain NUL or newline characters`);
}
return password;
}
export function readProvisioningConfig(env = process.env) {
const connectionString = env[PROVISION_DATABASE_URL_ENV];
if (!connectionString) {
throw new RlsRoleProvisioningError(`${PROVISION_DATABASE_URL_ENV} is required`);
}
let parsedUrl;
try {
parsedUrl = new URL(connectionString);
} catch {
throw new RlsRoleProvisioningError(`${PROVISION_DATABASE_URL_ENV} must be a valid PostgreSQL URL`);
}
if (!new Set(["postgres:", "postgresql:"]).has(parsedUrl.protocol) || !parsedUrl.username || parsedUrl.pathname === "/") {
throw new RlsRoleProvisioningError(`${PROVISION_DATABASE_URL_ENV} must identify a PostgreSQL user and database`);
}
const passwords = {
hivegrid_migrator: requirePassword(env, "hivegrid_migrator"),
hivegrid_app: requirePassword(env, "hivegrid_app"),
hivegrid_portal: requirePassword(env, "hivegrid_portal"),
hivegrid_worker: requirePassword(env, "hivegrid_worker"),
hivegrid_authentik_jit: requirePassword(env, "hivegrid_authentik_jit"),
};
if (new Set(Object.values(passwords)).size !== Object.keys(passwords).length) {
throw new RlsRoleProvisioningError("Every managed login role must use a distinct password");
}
return { connectionString, passwords };
}
export function verifyProvisioningState({ roles, memberships, ownership }) {
if (roles.length !== MANAGED_ROLES.length) {
throw new RlsRoleProvisioningError("Managed PostgreSQL role verification returned an incomplete role set");
}
for (const roleName of MANAGED_ROLES) {
const actual = roles.find((role) => role.roleName === roleName);
const expected = EXPECTED_ROLE_ATTRIBUTES[roleName];
if (!actual || Object.entries(expected).some(([key, value]) => actual[key] !== value)) {
throw new RlsRoleProvisioningError(`Managed PostgreSQL role ${roleName} does not match the required security contract`);
}
}
if (
memberships.length !== 1 ||
memberships[0].grantedRole !== "hivegrid_rls_definer" ||
memberships[0].memberRole !== "hivegrid_migrator" ||
memberships[0].adminOption !== false ||
memberships[0].inheritOption !== false ||
memberships[0].setOption !== true
) {
throw new RlsRoleProvisioningError("Managed PostgreSQL roles have an unexpected membership edge");
}
if (!ownership || ownership.databaseOwner !== "hivegrid_migrator" || ownership.schemaOwner !== "hivegrid_migrator" || ownership.unexpectedOwnerCount !== 0) {
throw new RlsRoleProvisioningError("HiveGrid database objects are not exclusively owned by the migration contract");
}
}
export async function provisionRlsRoles({
env = process.env,
clientFactory = (connectionString) => new pg.Client({ connectionString, application_name: "hivegrid-rls-role-provisioner" }),
stdout = process.stdout,
} = {}) {
const config = readProvisioningConfig(env);
const client = clientFactory(config.connectionString);
let transactionStarted = false;
try {
await client.connect();
const preflight = await client.query(`
SELECT current_user AS "roleName", rolsuper AS "isSuperuser"
FROM pg_roles
WHERE rolname = current_user;
`);
if (preflight.rows.length !== 1 || preflight.rows[0].isSuperuser !== true) {
throw new RlsRoleProvisioningError(`${PROVISION_DATABASE_URL_ENV} must authenticate as a PostgreSQL superuser`);
}
await client.query("BEGIN");
transactionStarted = true;
await client.query("SET LOCAL password_encryption = 'scram-sha-256'");
await client.query(
`SELECT
set_config('hivegrid.provision.migrator_password', $1, true),
set_config('hivegrid.provision.app_password', $2, true),
set_config('hivegrid.provision.portal_password', $3, true),
set_config('hivegrid.provision.worker_password', $4, true),
set_config('hivegrid.provision.authentik_jit_password', $5, true);`,
[
config.passwords.hivegrid_migrator,
config.passwords.hivegrid_app,
config.passwords.hivegrid_portal,
config.passwords.hivegrid_worker,
config.passwords.hivegrid_authentik_jit,
]
);
await client.query(ROLE_PROVISIONING_SQL);
const roleState = await client.query(ROLE_STATE_QUERY, [MANAGED_ROLES]);
const membershipState = await client.query(MEMBERSHIP_STATE_QUERY, [MANAGED_ROLES]);
const ownershipState = await client.query(OWNERSHIP_STATE_QUERY);
verifyProvisioningState({
roles: roleState.rows,
memberships: membershipState.rows,
ownership: ownershipState.rows[0],
});
await client.query("COMMIT");
transactionStarted = false;
stdout.write("PASS HiveGrid PostgreSQL roles provisioned and verified\n");
} catch (error) {
if (transactionStarted) {
await client.query("ROLLBACK").catch(() => undefined);
}
if (error instanceof RlsRoleProvisioningError) {
throw error;
}
throw new RlsRoleProvisioningError("PostgreSQL role provisioning failed; inspect restricted database logs without copying credentials", { cause: error });
} finally {
await client.end().catch(() => undefined);
}
}
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isCli) {
try {
await provisionRlsRoles();
} catch (error) {
const message = error instanceof RlsRoleProvisioningError ? error.message : "PostgreSQL role provisioning failed without a safe diagnostic";
process.stderr.write(`FAIL ${message}\n`);
process.exitCode = 1;
}
}