sqlauthz
Version:
Declarative permission management for PostgreSQL
646 lines • 28.8 kB
JavaScript
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import url from "node:url";
import { ValidationError, evaluateClause, isTrueClause, simpleEvaluator, } from "./clause.js";
import { VERSION } from "./constants.js";
import { SQLRowLevelSecurityPolicyPrivileges, } from "./sql.js";
import { valueToSqlLiteral } from "./utils.js";
const ProjectDir = url.fileURLToPath(new URL(".", import.meta.url));
const SqlDir = path.join(ProjectDir, "sql/pg");
export class PostgresBackend {
client;
constructor(client) {
this.client = client;
}
async fetchEntities() {
const getUsers = () => this.client.query(`
SELECT
usename as "name",
usesysid as "id"
FROM
pg_catalog.pg_user
WHERE NOT usesuper
`);
const getGroups = () => this.client.query(`
SELECT
groname as "name",
grolist as "userIds",
grosysid as "id"
FROM
pg_catalog.pg_group
WHERE NOT groname LIKE 'pg_%'
`);
const getTables = () => this.client.query(`
SELECT
schemaname as "schema",
tablename as "name",
rowsecurity as "rlsEnabled"
FROM
pg_tables
WHERE
schemaname != 'information_schema'
AND schemaname != 'pg_catalog'
AND schemaname != 'pg_toast'
`);
const getTableColumns = () => this.client.query(`
SELECT
table_schema as "schema",
table_name as "table",
column_name as "name"
FROM
information_schema.columns
WHERE
table_schema != 'information_schema'
AND table_schema != 'pg_catalog'
AND table_schema != 'pg_toast'
`);
const getSchemas = () => this.client.query(`
SELECT
schema_name as "name"
FROM
information_schema.schemata
WHERE
schema_name != 'information_schema'
AND schema_name != 'pg_catalog'
AND schema_name != 'pg_toast'
`);
const getViews = () => this.client.query(`
SELECT
table_schema as "schema",
table_name as "name"
FROM
information_schema.views
WHERE
table_schema != 'information_schema'
AND table_schema != 'pg_catalog'
AND table_schema != 'pg_toast'
`);
const getPolicies = () => this.client.query(`
SELECT
schemaname as "schema",
tablename as "table",
policyname as "name",
permissive,
cmd,
roles as "users"
FROM
pg_policies
WHERE
schemaname != 'information_schema'
AND schemaname != 'pg_catalog'
AND schemaname != 'pg_toast'
`);
const getFunctionsAndProcedures = () => this.client.query(`
SELECT
n.nspname as "schema",
p.proname as "name",
p.prokind = 'p' as "isProcedure",
n.nspname = 'pg_catalog' as "builtin"
FROM
pg_catalog.pg_proc p
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
WHERE
n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
OR pg_catalog.pg_function_is_visible(p.oid);
`);
const getSequences = () => this.client.query(`
SELECT
sequence_name as "name",
sequence_schema as "schema"
FROM
information_schema.sequences s
WHERE
sequence_schema != 'information_schema'
AND sequence_schema != 'pg_catalog'
AND sequence_schema != 'pg_toast'
`);
const [users, groups, tables, tableColumns, schemas, views, policies, functionsAndProcedures, sequences,] = await Promise.all([
getUsers(),
getGroups(),
getTables(),
getTableColumns(),
getSchemas(),
getViews(),
getPolicies(),
getFunctionsAndProcedures(),
getSequences(),
]);
const tableItems = {};
for (const table of tables.rows) {
const fullName = `${table.schema}.${table.name}`;
tableItems[fullName] = {
type: "table-metadata",
table: { type: "table", name: table.name, schema: table.schema },
rlsEnabled: table.rlsEnabled,
columns: [],
};
}
for (const row of tableColumns.rows) {
const fullName = `${row.schema}.${row.table}`;
if (tableItems[fullName]) {
tableItems[fullName].columns.push(row.name);
}
}
const parseArray = (value) => {
return value.slice(1, -1).split(",");
};
const functions = [];
const procedures = [];
for (const { schema, name, builtin, isProcedure, } of functionsAndProcedures.rows) {
if (isProcedure) {
procedures.push({
type: "procedure",
name,
schema,
builtin,
});
}
else {
functions.push({
type: "function",
name,
schema,
builtin,
});
}
}
const usersById = Object.fromEntries(users.rows.map((row) => [row.id, { type: "user", name: row.name }]));
const usersByName = Object.fromEntries(Object.values(usersById).map((user) => [user.name, user]));
const groupsByName = {};
for (const group of groups.rows) {
const users = group.userIds.flatMap((userId) => usersById[userId] ? [usersById[userId]] : []);
groupsByName[group.name] = { type: "group", name: group.name, users };
}
const rlsPolicies = [];
for (const row of policies.rows) {
const users = [];
const groups = [];
let isDefault = false;
for (const role of parseArray(row.users)) {
if (role === "public") {
isDefault = true;
continue;
}
if (groupsByName[role]) {
groups.push(groupsByName[role]);
}
if (usersByName[role]) {
users.push(usersByName[role]);
}
}
let privileges;
if (row.cmd === "ALL") {
privileges = new Set(SQLRowLevelSecurityPolicyPrivileges);
}
else {
privileges = new Set([row.cmd]);
}
rlsPolicies.push({
type: "rls-policy",
name: row.name,
isDefault,
table: { type: "table", schema: row.schema, name: row.table },
permissive: row.permissive,
privileges,
users,
groups,
});
}
return {
users: Object.values(usersById),
groups: Object.values(groupsByName),
schemas: schemas.rows.map((row) => ({ type: "schema", name: row.name })),
views: views.rows.map((row) => ({
type: "view",
schema: row.schema,
name: row.name,
})),
tables: Object.values(tableItems),
rlsPolicies,
functions,
procedures,
sequences: sequences.rows.map((row) => ({ type: "sequence", ...row })),
};
}
quoteIdentifier(identifier) {
return JSON.stringify(identifier);
}
quoteTopLevelName(schema) {
return this.quoteIdentifier(schema.name);
}
quoteQualifiedName(table) {
return [
this.quoteIdentifier(table.schema),
this.quoteIdentifier(table.name),
].join(".");
}
async loadSqlFile(name, variables, debug) {
const filePath = path.join(SqlDir, name);
let content = await fs.promises.readFile(filePath, { encoding: "utf8" });
for (const [key, value] of Object.entries(variables)) {
content = content.replaceAll(`{{${key}}}`, value);
}
if (!debug) {
// Strip comments
content = content.replace(/\s--\s.+$/gm, "");
// Trim whitespace
content = content.replace(/\s+/gm, " ").trim();
// Add pointer to original source code
const baseUrl = `https://github.com/cfeenstra67/sqlauthz/blob/v${VERSION}/src/sql/pg`;
content += ` -- Formatted version: ${baseUrl}/${name}`;
}
return content;
}
async getContext(entities) {
let tmpSchema = "";
const tries = 0;
const schemaNames = new Set(entities.schemas.map((schema) => schema.name));
while (tries < 100 && !tmpSchema) {
const newName = `tmp_${crypto.randomInt(10000)}`;
if (!schemaNames.has(newName)) {
tmpSchema = newName;
}
}
if (!tmpSchema) {
throw new Error("Unable to choose a temporary schema name");
}
const setupQuery = [
`CREATE SCHEMA ${this.quoteIdentifier(tmpSchema)};`,
await this.loadSqlFile("revoke_all_from_role.sql", { tmpSchema }),
].join("\n");
const teardownQuery = `DROP SCHEMA ${this.quoteIdentifier(tmpSchema)} CASCADE;`;
return {
setupQuery,
teardownQuery,
transactionStartQuery: "BEGIN;",
transactionCommitQuery: "COMMIT;",
removeAllPermissionsFromActorsQueries: (users, entities) => {
const revokeQueries = users.map((user) => `SELECT ${tmpSchema}.revoke_all_from_role('${user.name}');`);
const userNames = new Set(users.map((user) => user.name));
const policiesToDrop = entities.rlsPolicies.filter((policy) => policy.permissive === "RESTRICTIVE" &&
policy.users.some((user) => userNames.has(user.name)));
const dropQueries = policiesToDrop.map((policy) => `DROP POLICY ${this.quoteIdentifier(policy.name)} ` +
`ON ${this.quoteQualifiedName(policy.table)};`);
return revokeQueries.concat(dropQueries);
},
compileGrantQueries: (permissions, entities) => {
const metaByTable = Object.fromEntries(entities.tables.map((table) => [
this.quoteQualifiedName(table.table),
table,
]));
const tablesWithDefaultPermissivePolicies = {};
const tablesWithPermissivePolicies = {};
for (const policy of entities.rlsPolicies) {
if (policy.permissive === "PERMISSIVE") {
const tableName = this.quoteQualifiedName(policy.table);
if (policy.isDefault) {
tablesWithDefaultPermissivePolicies[tableName] ??= new Set();
const perms = tablesWithDefaultPermissivePolicies[tableName];
for (const perm of policy.privileges) {
perms.add(perm);
}
continue;
}
tablesWithPermissivePolicies[tableName] ??= {};
const users = tablesWithPermissivePolicies[tableName];
const policyUsers = [...policy.users];
for (const group of policy.groups) {
users[group.name] ??= new Set();
const groupPerms = users[group.name];
for (const perm of policy.privileges) {
groupPerms.add(perm);
}
policyUsers.push(...group.users);
}
for (const user of policyUsers) {
users[user.name] ??= new Set();
const userPerms = users[user.name];
for (const perm of policy.privileges) {
userPerms.add(perm);
}
}
}
}
const tablesToAddRlsTo = new Set();
for (const perm of permissions) {
if (perm.type !== "table") {
continue;
}
if (isTrueClause(perm.rowClause)) {
continue;
}
if (!SQLRowLevelSecurityPolicyPrivileges.includes(perm.privilege)) {
continue;
}
const tableName = this.quoteQualifiedName(perm.table);
const table = metaByTable[tableName];
if (!table) {
continue;
}
if (!table.rlsEnabled) {
tablesToAddRlsTo.add(tableName);
}
}
const defaultPoliciesToCreate = {};
for (const perm of permissions) {
if (perm.type !== "table") {
continue;
}
if (!SQLRowLevelSecurityPolicyPrivileges.includes(perm.privilege)) {
continue;
}
const tableName = this.quoteQualifiedName(perm.table);
const table = metaByTable[tableName];
if (!table) {
continue;
}
if (!table.rlsEnabled || tablesToAddRlsTo.has(tableName)) {
continue;
}
const usersWithPolicies = tablesWithPermissivePolicies[tableName]?.[perm.user.name];
const tableDefaultPerms = tablesWithDefaultPermissivePolicies[tableName];
const missingPerms = new Set();
for (const perm of SQLRowLevelSecurityPolicyPrivileges) {
if (!usersWithPolicies?.has(perm) &&
!tableDefaultPerms?.has(perm)) {
missingPerms.add(perm);
}
}
if (missingPerms.size === 0) {
continue;
}
defaultPoliciesToCreate[tableName] ??= {};
defaultPoliciesToCreate[tableName][perm.user.name] = missingPerms;
}
const enableRlsQueries = Array.from(tablesToAddRlsTo).flatMap((tableName) => [
`ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`,
// biome-ignore lint: best way to do this
`CREATE POLICY "default_access" ON ${tableName} AS PERMISSIVE FOR ` +
"ALL TO PUBLIC USING (true);",
]);
const addDefaultPolicyQueries = Object.entries(defaultPoliciesToCreate).flatMap(([tableName, userPerms]) => Object.entries(userPerms).flatMap(([userName, perms]) => {
const getQuery = (perm) => {
const extra = [];
if (perm === "ALL" ||
perm === "DELETE" ||
perm === "SELECT" ||
perm === "UPDATE") {
extra.push("USING (true)");
}
if (perm === "ALL" || perm === "INSERT" || perm === "UPDATE") {
extra.push("WITH CHECK (true)");
}
const name = `${userName}_${perm.toLowerCase()}`;
return (`CREATE POLICY ${this.quoteIdentifier(name)} ` +
`ON ${tableName} AS PERMISSIVE FOR ${perm} TO ` +
`${this.quoteIdentifier(userName)} ${extra.join(" ")};`);
};
if (perms.size === SQLRowLevelSecurityPolicyPrivileges.length) {
return [getQuery("ALL")];
}
return Array.from(perms).map((perm) => getQuery(perm));
}));
const rlsQueries = enableRlsQueries.concat(addDefaultPolicyQueries);
const individualGrantQueries = permissions.flatMap((perm) => this.compileGrantQuery(perm, entities));
return rlsQueries.concat(individualGrantQueries);
},
};
}
evalColumnQuery(clause, column) {
const evaluate = simpleEvaluator({
variableName: "col",
errorVariableName: "col",
getValue: (value) => {
if (value.type === "function-call") {
throw new ValidationError("col: invalid function call");
}
if (value.type === "value") {
return value.value;
}
if (value.value === "col") {
return column;
}
throw new ValidationError(`col: invalid clause value: ${value.value}`);
},
});
const result = evaluateClause({ clause, evaluate });
return result.type === "success" && result.result;
}
clauseToSql(clause) {
if (clause.type === "and" || clause.type === "or") {
const subClauses = clause.clauses.map((subClause) => this.clauseToSql(subClause));
return `(${subClauses.join(` ${clause.type} `)})`;
}
if (clause.type === "not") {
const subClause = this.clauseToSql(clause.clause);
return `not ${subClause}`;
}
if (clause.type === "expression") {
const values = clause.values.map((value) => this.clauseToSql(value));
let operator;
switch (clause.operator) {
case "Eq":
operator = "=";
break;
case "Gt":
operator = ">";
break;
case "Lt":
operator = "<";
break;
case "Geq":
operator = ">=";
break;
case "Leq":
operator = "<=";
break;
case "Neq":
operator = "!=";
break;
default:
throw new Error(`Unhandled operator: ${clause.operator}`);
}
return values.join(` ${operator} `);
}
if (clause.type === "column") {
return this.quoteIdentifier(clause.value);
}
if (clause.type === "function-call") {
if (clause.schema) {
const name = `${this.quoteIdentifier(clause.schema)}.${this.quoteIdentifier(clause.name)}`;
const args = clause.args.map((arg) => this.clauseToSql(arg));
return `${name}(${args.join(", ")})`;
}
if (clause.name === "cast") {
const arg = this.clauseToSql(clause.args[0]);
return `CAST(${arg} AS ${clause.args[1].value})`;
}
throw new Error(`Unrecognized function: ${clause.name}`);
}
if (typeof clause.value === "string") {
return `'${clause.value}'`;
}
return valueToSqlLiteral(clause.value);
}
compileGrantQuery(permission, entities) {
switch (permission.type) {
case "schema":
switch (permission.privilege) {
case "USAGE":
case "CREATE":
return [
`GRANT ${permission.privilege} ON SCHEMA ${this.quoteTopLevelName(permission.schema)} TO ${this.quoteTopLevelName(permission.user)};`,
];
default: {
const _ = permission;
throw new Error(`Invalid schema privilege: ${permission.privilege};`);
}
}
case "table": {
let columnPart = "";
if (!isTrueClause(permission.columnClause)) {
const table = entities.tables.filter((table) => table.table.schema === permission.table.schema &&
table.table.name === permission.table.name)[0];
const columnNames = table.columns.filter((column) => this.evalColumnQuery(permission.columnClause, column));
const colNameList = columnNames.map((col) => this.quoteIdentifier(col));
columnPart = ` (${colNameList.join(", ")})`;
}
switch (permission.privilege) {
case "SELECT": {
const out = [
`GRANT SELECT${columnPart} ON ${this.quoteQualifiedName(permission.table)} TO ${this.quoteTopLevelName(permission.user)};`,
];
if (!isTrueClause(permission.rowClause)) {
const policyName = [permission.privilege, permission.user.name]
.join("_")
.toLowerCase();
out.push(`CREATE POLICY ${this.quoteIdentifier(policyName)} ON ${this.quoteQualifiedName(permission.table)} AS RESTRICTIVE FOR SELECT TO ${this.quoteTopLevelName(permission.user)} USING (${this.clauseToSql(permission.rowClause)});`);
}
return out;
}
case "INSERT": {
const out = [
`GRANT INSERT${columnPart} ON ${this.quoteQualifiedName(permission.table)} TO ${this.quoteTopLevelName(permission.user)};`,
];
if (!isTrueClause(permission.rowClause)) {
const policyName = [permission.privilege, permission.user.name]
.join("_")
.toLowerCase();
out.push(`CREATE POLICY ${this.quoteIdentifier(policyName)} ON ${this.quoteQualifiedName(permission.table)} AS RESTRICTIVE FOR INSERT TO ${this.quoteTopLevelName(permission.user)} WITH CHECK (${this.clauseToSql(permission.rowClause)});`);
}
return out;
}
case "UPDATE": {
const out = [
`GRANT UPDATE${columnPart} ON ${this.quoteQualifiedName(permission.table)} TO ${this.quoteTopLevelName(permission.user)};`,
];
if (!isTrueClause(permission.rowClause)) {
const policyName = [permission.privilege, permission.user.name]
.join("_")
.toLowerCase();
const rowClauseSql = this.clauseToSql(permission.rowClause);
out.push(`CREATE POLICY ${this.quoteIdentifier(policyName)} ON ${this.quoteQualifiedName(permission.table)} AS RESTRICTIVE FOR UPDATE TO ${this.quoteTopLevelName(permission.user)} USING (${rowClauseSql}) WITH CHECK (${rowClauseSql});`);
}
return out;
}
case "DELETE": {
const out = [
`GRANT DELETE ON ${this.quoteQualifiedName(permission.table)} ` +
`TO ${this.quoteTopLevelName(permission.user)};`,
];
if (!isTrueClause(permission.rowClause)) {
const policyName = [permission.privilege, permission.user.name]
.join("_")
.toLowerCase();
out.push(`CREATE POLICY ${this.quoteIdentifier(policyName)} ON ${this.quoteQualifiedName(permission.table)} AS RESTRICTIVE FOR DELETE TO ${this.quoteTopLevelName(permission.user)} USING (${this.clauseToSql(permission.rowClause)});`);
}
return out;
}
case "TRUNCATE":
return [
`GRANT TRUNCATE ON ${this.quoteQualifiedName(permission.table)} TO ${this.quoteTopLevelName(permission.user)};`,
];
case "TRIGGER":
return [
`GRANT TRIGGER ON ${this.quoteQualifiedName(permission.table)} ` +
`TO ${this.quoteTopLevelName(permission.user)};`,
];
case "REFERENCES":
return [
`GRANT REFERENCES ON ${this.quoteQualifiedName(permission.table)} TO ${this.quoteTopLevelName(permission.user)};`,
];
default: {
const _ = permission;
throw new Error(`Invalid table privilege: ${permission.privilege}`);
}
}
}
case "view": {
switch (permission.privilege) {
case "DELETE":
case "INSERT":
case "SELECT":
case "TRIGGER":
case "UPDATE":
return [
`GRANT ${permission.privilege} ON ${this.quoteQualifiedName(permission.view)} TO ${this.quoteTopLevelName(permission.user)};`,
];
default: {
const _ = permission;
throw new Error(`Invalid view privilege: ${permission.privilege}`);
}
}
}
case "function": {
switch (permission.privilege) {
case "EXECUTE":
return [
`GRANT ${permission.privilege} ON FUNCTION ` +
`${this.quoteQualifiedName(permission.function)} ` +
`TO ${this.quoteTopLevelName(permission.user)};`,
];
default: {
const _ = permission;
throw new Error(`Invalid function privilege: ${permission.privilege}`);
}
}
}
case "procedure": {
switch (permission.privilege) {
case "EXECUTE":
return [
`GRANT ${permission.privilege} ON PROCEDURE ` +
`${this.quoteQualifiedName(permission.procedure)} ` +
`TO ${this.quoteTopLevelName(permission.user)};`,
];
default: {
const _ = permission;
throw new Error(`Invalid procedure privilege: ${permission.privilege}`);
}
}
}
case "sequence": {
switch (permission.privilege) {
case "USAGE":
case "SELECT":
case "UPDATE":
return [
`GRANT ${permission.privilege} ON SEQUENCE ` +
`${this.quoteQualifiedName(permission.sequence)} ` +
`TO ${this.quoteTopLevelName(permission.user)};`,
];
default: {
const _ = permission;
throw new Error(`Invalid sequence privilege: ${permission.privilege}`);
}
}
}
default: {
const _ = permission;
throw new Error(`Invalid permission: ${permission.type}`);
}
}
}
}
//# sourceMappingURL=pg-backend.js.map