Ctrl K
ComunidadGuías…

scripts/provision_roles.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
require("dotenv").config();

const { Client } = require("pg");

/**
 * Declarative provisioning for the Postgres roles the analytics backend uses.
 *
 * This manifest is the single source of truth for which role may touch which table.
 *
 * `grants` lists per-table privileges in the public schema. `schemaGrants` lists privileges
 * on every table of a schema, present and future; use it for schemas whose tables are created
 * at runtime by the application rather than by Prisma migrations (pg-boss).
 */
const ROLES = [
  {
    name: "siteconfig_ro",
    passwordEnv: "POSTGRES_SITECONFIG_RO_PASSWORD",
    // Read-only: the backend's site-config cache.
    grants: {
      SiteConfig: ["SELECT"],
      Dashboard: ["SELECT"],
      DashboardIntegration: ["SELECT"],
    },
  },
  {
    name: "monitoring_ro",
    passwordEnv: "POSTGRES_MONITORING_RO_PASSWORD",
    // Read-only: uptime monitor configuration.
    grants: {
      MonitorCheck: ["SELECT"],
      Dashboard: ["SELECT"],
    },
  },
  {
    name: "salts_rw",
    passwordEnv: "POSTGRES_SALTS_RW_PASSWORD",
    // Read-write: secret fingerprint salts.
    grants: {
      AnalyticsSalt: ["SELECT", "INSERT", "DELETE"],
    },
  },
  {
    name: "jobqueue_rw",
    passwordEnv: "POSTGRES_JOBQUEUE_RW_PASSWORD",
    // pg-boss migrations (scripts/migrate_pgboss.js) and queue creation on worker start add
    // tables over time, so the grant covers the whole schema including future tables.
    grants: {},
    schemaGrants: {
      pgboss: ["SELECT", "INSERT"],
    },
  },
];

// Postgres has no parameter binding for identifiers or for DDL like CREATE ROLE, so the
// few dynamic values are quoted by hand.
function quoteIdent(name) {
  return `"${String(name).replace(/"/g, '""')}"`;
}

function quoteLiteral(value) {
  return `'${String(value).replace(/'/g, "''")}'`;
}

async function provisionRole(client, dbName, role) {
  const password = process.env[role.passwordEnv];
  if (!password) {
    throw new Error(
      `${role.passwordEnv} must be set for role '${role.name}' (no fallback to POSTGRES_PASSWORD).`
    );
  }

  const roleIdent = quoteIdent(role.name);

  await client.query("BEGIN");
  try {
    const exists = await client.query(
      "SELECT 1 FROM pg_roles WHERE rolname = $1",
      [role.name]
    );

    if (exists.rowCount === 0) {
      await client.query(
        `CREATE ROLE ${roleIdent} LOGIN PASSWORD ${quoteLiteral(password)}`
      );
    } else {
      await client.query(
        `ALTER ROLE ${roleIdent} WITH LOGIN PASSWORD ${quoteLiteral(password)}`
      );
    }

    await client.query(
      `GRANT CONNECT ON DATABASE ${quoteIdent(dbName)} TO ${roleIdent}`
    );
    await client.query(`GRANT USAGE ON SCHEMA public TO ${roleIdent}`);

    await client.query(
      `REVOKE ALL ON ALL TABLES IN SCHEMA public FROM ${roleIdent}`
    );
    for (const [table, privileges] of Object.entries(role.grants ?? {})) {
      await client.query(
        `GRANT ${privileges.join(", ")} ON TABLE ${quoteIdent(table)} TO ${roleIdent}`
      );
    }

    for (const [schema, privileges] of Object.entries(role.schemaGrants ?? {})) {
      const schemaIdent = quoteIdent(schema);
      const privs = privileges.join(", ");
      // Tables added later are created as the POSTGRES_URL user (the same user running this
      // script), so the default privileges declared here apply to them too.
      await client.query(`GRANT USAGE ON SCHEMA ${schemaIdent} TO ${roleIdent}`);
      await client.query(
        `GRANT ${privs} ON ALL TABLES IN SCHEMA ${schemaIdent} TO ${roleIdent}`
      );
      await client.query(
        `ALTER DEFAULT PRIVILEGES IN SCHEMA ${schemaIdent} GRANT ${privs} ON TABLES TO ${roleIdent}`
      );
    }

    await client.query("COMMIT");
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  }

  const summary = [
    ...Object.entries(role.grants ?? {}).map(
      ([table, privileges]) => `${table}(${privileges.join("/")})`
    ),
    ...Object.entries(role.schemaGrants ?? {}).map(
      ([schema, privileges]) => `schema ${schema}.*(${privileges.join("/")})`
    ),
  ].join(", ");

  console.log(`provision_roles: '${role.name}' -> ${summary}`);
}

async function main() {
  const databaseUrl = process.env.POSTGRES_URL;
  if (!databaseUrl) {
    console.error("provision_roles: POSTGRES_URL must be set in the environment.");
    process.exit(1);
  }

  let dbName;
  try {
    const url = new URL(databaseUrl);
    dbName = url.pathname.replace(/^\//, "").split("/")[0];
  } catch (error) {
    console.error(
      "provision_roles: Failed to parse POSTGRES_URL:",
      error instanceof Error ? error.message : String(error)
    );
    process.exit(1);
  }

  if (!dbName) {
    console.error(
      "provision_roles: Could not determine database name from POSTGRES_URL."
    );
    process.exit(1);
  }

  const client = new Client({ connectionString: databaseUrl });
  let hadError = false;

  try {
    await client.connect();
    for (const role of ROLES) {
      try {
        await provisionRole(client, dbName, role);
      } catch (error) {
        hadError = true;
        console.error(
          `provision_roles: failed for role '${role.name}':`,
          error instanceof Error ? error.message : String(error)
        );
      }
    }
  } catch (error) {
    console.error(
      "provision_roles: fatal error:",
      error instanceof Error ? error.message : String(error)
    );
    process.exit(1);
  } finally {
    try {
      await client.end();
    } catch {}
  }

  if (hadError) {
    process.exit(1);
  }
  console.log("provision_roles: all roles provisioned successfully.");
}

main();