diff --git a/CHANGELOG.md b/CHANGELOG.md index 8485c0ee9a3..31942b69ef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,2 @@ - Fix webframeworks deployments when using `site` in `firebase.json`. (#8295) +- Add support for brownfield project onboard `dataconnect:sql:setup` (#8150) diff --git a/src/commands/dataconnect-sql-grant.ts b/src/commands/dataconnect-sql-grant.ts index 95899de25b3..4fb77014086 100644 --- a/src/commands/dataconnect-sql-grant.ts +++ b/src/commands/dataconnect-sql-grant.ts @@ -7,7 +7,7 @@ import { pickService } from "../dataconnect/fileUtils"; import { grantRoleToUserInSchema } from "../dataconnect/schemaMigration"; import { requireAuth } from "../requireAuth"; import { FirebaseError } from "../error"; -import { fdcSqlRoleMap } from "../gcp/cloudsql/permissions"; +import { fdcSqlRoleMap } from "../gcp/cloudsql/permissions_setup"; const allowedRoles = Object.keys(fdcSqlRoleMap); diff --git a/src/commands/dataconnect-sql-setup.ts b/src/commands/dataconnect-sql-setup.ts new file mode 100644 index 00000000000..48c3e16500e --- /dev/null +++ b/src/commands/dataconnect-sql-setup.ts @@ -0,0 +1,38 @@ +import { Command } from "../command"; +import { Options } from "../options"; +import { needProjectId } from "../projectUtils"; +import { pickService } from "../dataconnect/fileUtils"; +import { FirebaseError } from "../error"; +import { requireAuth } from "../requireAuth"; +import { requirePermissions } from "../requirePermissions"; +import { ensureApis } from "../dataconnect/ensureApis"; +import { setupSQLPermissions, getSchemaMetadata } from "../gcp/cloudsql/permissions_setup"; +import { DEFAULT_SCHEMA } from "../gcp/cloudsql/permissions"; +import { getIdentifiers } from "../dataconnect/schemaMigration"; + +export const command = new Command("dataconnect:sql:setup [serviceId]") + .description("Setup your CloudSQL database") + .before(requirePermissions, [ + "firebasedataconnect.services.list", + "firebasedataconnect.schemas.list", + "firebasedataconnect.schemas.update", + "cloudsql.instances.connect", + ]) + .before(requireAuth) + .action(async (serviceId: string, options: Options) => { + const projectId = needProjectId(options); + await ensureApis(projectId); + const serviceInfo = await pickService(projectId, options.config, serviceId); + const instanceId = + serviceInfo.dataConnectYaml.schema.datasource.postgresql?.cloudSql.instanceId; + if (!instanceId) { + throw new FirebaseError( + "dataconnect.yaml is missing field schema.datasource.postgresql.cloudsql.instanceId", + ); + } + + const { databaseId } = getIdentifiers(serviceInfo.schema); + + const schemaInfo = await getSchemaMetadata(instanceId, databaseId, DEFAULT_SCHEMA, options); + await setupSQLPermissions(instanceId, databaseId, schemaInfo, options); + }); diff --git a/src/commands/index.ts b/src/commands/index.ts index 7e0f87bc29f..fbeed19b645 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -221,6 +221,7 @@ export function load(client: any): any { client.dataconnect.services.list = loadCommand("dataconnect-services-list"); client.dataconnect.sql = {}; client.dataconnect.sql.diff = loadCommand("dataconnect-sql-diff"); + client.dataconnect.sql.setup = loadCommand("dataconnect-sql-setup"); client.dataconnect.sql.migrate = loadCommand("dataconnect-sql-migrate"); client.dataconnect.sql.grant = loadCommand("dataconnect-sql-grant"); client.dataconnect.sql.shell = loadCommand("dataconnect-sql-shell"); diff --git a/src/dataconnect/schemaMigration.ts b/src/dataconnect/schemaMigration.ts index c1d6f133dda..aec95f30a70 100644 --- a/src/dataconnect/schemaMigration.ts +++ b/src/dataconnect/schemaMigration.ts @@ -4,26 +4,29 @@ import { format } from "sql-formatter"; import { IncompatibleSqlSchemaError, Diff, SCHEMA_ID, SchemaValidation } from "./types"; import { getSchema, upsertSchema, deleteConnector } from "./client"; import { - setupIAMUsers, getIAMUser, executeSqlCmdsAsIamUser, executeSqlCmdsAsSuperUser, toDatabaseUser, } from "../gcp/cloudsql/connect"; +import { needProjectId } from "../projectUtils"; import { - firebaseowner, - iamUserIsCSQLAdmin, checkSQLRoleIsGranted, fdcSqlRoleMap, -} from "../gcp/cloudsql/permissions"; -import * as cloudSqlAdminClient from "../gcp/cloudsql/cloudsqladmin"; -import { needProjectId } from "../projectUtils"; + setupSQLPermissions, + getSchemaMetadata, + SchemaSetupStatus, +} from "../gcp/cloudsql/permissions_setup"; +import { DEFAULT_SCHEMA, firebaseowner } from "../gcp/cloudsql/permissions"; import { promptOnce, confirm } from "../prompt"; import { logger } from "../logger"; import { Schema } from "./types"; import { Options } from "../options"; import { FirebaseError } from "../error"; import { logLabeledBullet, logLabeledWarning, logLabeledSuccess } from "../utils"; +import { iamUserIsCSQLAdmin } from "../gcp/cloudsql/cloudsqladmin"; +import * as cloudSqlAdminClient from "../gcp/cloudsql/cloudsqladmin"; + import * as errors from "./errors"; export async function diffSchema( @@ -236,10 +239,34 @@ export async function grantRoleToUserInSchema(options: Options, schema: Schema) ); } - // Run the database roles setup. This should be idempotent. - await setupIAMUsers(instanceId, databaseId, options); + // Make sure we have the right setup for the requested role grant. + const schemaInfo = await getSchemaMetadata(instanceId, databaseId, DEFAULT_SCHEMA, options); + let isGreenfieldSetup = schemaInfo.setupStatus === SchemaSetupStatus.GreenField; + switch (schemaInfo.setupStatus) { + case SchemaSetupStatus.NotSetup: + case SchemaSetupStatus.NotFound: + const newSetupStatus = await setupSQLPermissions(instanceId, databaseId, schemaInfo, options); + isGreenfieldSetup = newSetupStatus === SchemaSetupStatus.GreenField; + break; + default: + logger.info( + `Detected schema "${schemaInfo.name}" is setup in ${schemaInfo.setupStatus} mode. Skipping Setup.`, + ); + break; + } + + // Edge case: we can't grant firebase owner unless database is greenfield. + if (!isGreenfieldSetup && fdcSqlRole === firebaseowner(databaseId, DEFAULT_SCHEMA)) { + const newSetupStatus = await setupSQLPermissions(instanceId, databaseId, schemaInfo, options); - // Upsert user account into the database. + if (newSetupStatus !== SchemaSetupStatus.GreenField) { + throw new FirebaseError( + `Can't grant owner rule for brownfield databases. Consider fully migrating your database to FDC using 'firebase dataconnect:sql:setup'`, + ); + } + } + + // Upsert new user account into the database. await cloudSqlAdminClient.createUser(projectId, instanceId, mode, user); // Grant the role to the user. @@ -351,10 +378,21 @@ async function handleIncompatibleSchemaError(args: { ${commandsToExecuteBySuperUser.join("\n")}`); } - // TODO (tammam-g): at some point we would want to only run this after notifying the admin but - // until we confirm stability it's ok to run it on every migration by admin user. - if (userIsCSQLAdmin) { - await setupIAMUsers(instanceId, databaseId, options); + const schemaInfo = await getSchemaMetadata(instanceId, databaseId, DEFAULT_SCHEMA, options); + if (schemaInfo.setupStatus !== SchemaSetupStatus.GreenField) { + const newSetupStatus = await setupSQLPermissions( + instanceId, + databaseId, + schemaInfo, + options, + /* silent=*/ true, + ); + + if (newSetupStatus !== SchemaSetupStatus.GreenField) { + throw new FirebaseError( + `Can't migrate brownfield databases. Consider fully migrating your database to FDC using 'firebase dataconnect:sql:setup'`, + ); + } } // Test if iam user has access to the roles required for this migration diff --git a/src/gcp/cloudsql/cloudsqladmin.ts b/src/gcp/cloudsql/cloudsqladmin.ts index c6460fe2eae..fbe1195e7ef 100755 --- a/src/gcp/cloudsql/cloudsqladmin.ts +++ b/src/gcp/cloudsql/cloudsqladmin.ts @@ -2,6 +2,10 @@ import { Client, ClientResponse } from "../../apiv2"; import { cloudSQLAdminOrigin } from "../../api"; import * as operationPoller from "../../operation-poller"; import { Instance, Database, User, UserType, DatabaseFlag } from "./types"; +import { needProjectId } from "../../projectUtils"; +import { Options } from "../../options"; +import { logger } from "../../logger"; +import { testIamPermissions } from "../iam"; import { FirebaseError } from "../../error"; const API_VERSION = "v1"; @@ -16,6 +20,24 @@ interface Operation { name: string; } +export async function iamUserIsCSQLAdmin(options: Options): Promise { + const projectId = needProjectId(options); + const requiredPermissions = [ + "cloudsql.instances.connect", + "cloudsql.instances.get", + "cloudsql.users.create", + "cloudsql.users.update", + ]; + + try { + const iamResult = await testIamPermissions(projectId, requiredPermissions); + return iamResult.passed; + } catch (err: any) { + logger.debug(`[iam] error while checking permissions, command may fail: ${err}`); + return false; + } +} + export async function listInstances(projectId: string): Promise { const res = await client.get<{ items: Instance[] }>(`projects/${projectId}/instances`); return res.body.items ?? []; diff --git a/src/gcp/cloudsql/connect.ts b/src/gcp/cloudsql/connect.ts index 1db5fd6269c..f2e864f7b78 100644 --- a/src/gcp/cloudsql/connect.ts +++ b/src/gcp/cloudsql/connect.ts @@ -11,7 +11,6 @@ import { logger } from "../../logger"; import { FirebaseError } from "../../error"; import { Options } from "../../options"; import { FBToolsAuthClient } from "./fbToolsAuthClient"; -import { setupSQLPermissions, firebaseowner, firebasewriter } from "./permissions"; export async function execute( sqlStatements: string[], @@ -23,7 +22,7 @@ export async function execute( password?: string; silent?: boolean; }, -) { +): Promise { const logFn = opts.silent ? logger.debug : logger.info; const instance = await cloudSqlAdminClient.getInstance(opts.projectId, opts.instanceId); const user = await cloudSqlAdminClient.getUser(opts.projectId, opts.instanceId, opts.username); @@ -92,11 +91,12 @@ export async function execute( } const conn = await pool.connect(); + const results: pg.QueryResult[] = []; logFn(`Logged in as ${opts.username}`); for (const s of sqlStatements) { logFn(`Executing: '${s}'`); try { - await conn.query(s); + results.push(await conn.query(s)); } catch (err) { throw new FirebaseError(`Error executing ${err}`); } @@ -105,6 +105,7 @@ export async function execute( conn.release(); await pool.end(); connector.close(); + return results; } export async function executeSqlCmdsAsIamUser( @@ -113,7 +114,7 @@ export async function executeSqlCmdsAsIamUser( databaseId: string, cmds: string[], silent = false, -): Promise { +): Promise { const projectId = needProjectId(options); const { user: iamUser } = await getIAMUser(options); @@ -135,7 +136,7 @@ export async function executeSqlCmdsAsSuperUser( databaseId: string, cmds: string[], silent = false, -) { +): Promise { const projectId = needProjectId(options); // 1. Create a temporary builtin user const superuser = "firebasesuperuser"; @@ -148,7 +149,7 @@ export async function executeSqlCmdsAsSuperUser( temporaryPassword, ); - return await execute([`SET ROLE = cloudsqlsuperuser`, ...cmds], { + return await execute([`SET ROLE = '${superuser}'`, ...cmds], { projectId, instanceId, databaseId, @@ -177,8 +178,6 @@ export async function getIAMUser(options: Options): Promise<{ user: string; mode // Steps: // 1. Create an IAM user for the current identity // 2. Create an IAM user for FDC P4SA -// 3. Run setupSQLPermissions to setup the SQL database roles and permissions. -// 4. Connect to the DB as the temporary user and run the necessary grants export async function setupIAMUsers( instanceId: string, databaseId: string, @@ -200,18 +199,6 @@ export async function setupIAMUsers( ); await cloudSqlAdminClient.createUser(projectId, instanceId, fdcP4SAmode, fdcP4SAUser); - // 3. Setup FDC required SQL roles and permissions. - await setupSQLPermissions(instanceId, databaseId, options, true); - - // 4. Apply necessary grants. - const grants = [ - // Grant firebaseowner role to the current IAM user. - `GRANT "${firebaseowner(databaseId)}" TO "${user}"`, - // Grant firebaswriter to the FDC P4SA user - `GRANT "${firebasewriter(databaseId)}" TO "${fdcP4SAUser}"`, - ]; - - await executeSqlCmdsAsSuperUser(options, instanceId, databaseId, grants, /** silent=*/ true); return user; } diff --git a/src/gcp/cloudsql/permissions.ts b/src/gcp/cloudsql/permissions.ts index d1853bb9c06..5d2374db001 100644 --- a/src/gcp/cloudsql/permissions.ts +++ b/src/gcp/cloudsql/permissions.ts @@ -1,99 +1,25 @@ -import { Options } from "../../options"; -import { needProjectId } from "../../projectUtils"; -import { executeSqlCmdsAsIamUser, executeSqlCmdsAsSuperUser } from "./connect"; -import { testIamPermissions } from "../iam"; -import { logger } from "../../logger"; -import { concat } from "lodash"; -import { FirebaseError } from "../../error"; - -export function firebaseowner(databaseId: string) { - return `firebaseowner_${databaseId}_public`; -} +export const DEFAULT_SCHEMA = "public"; +export const FIREBASE_SUPER_USER = "firebasesuperuser"; -export function firebasereader(databaseId: string) { - return `firebasereader_${databaseId}_public`; +export function firebaseowner(databaseId: string, schema: string = DEFAULT_SCHEMA) { + return `firebaseowner_${databaseId}_${schema}`; } -export function firebasewriter(databaseId: string) { - return `firebasewriter_${databaseId}_public`; +export function firebasereader(databaseId: string, schema: string = DEFAULT_SCHEMA) { + return `firebasereader_${databaseId}_${schema}`; } -export const fdcSqlRoleMap = { - owner: firebaseowner, - writer: firebasewriter, - reader: firebasereader, -}; - -// Returns true if "grantedRole" is granted to "granteeRole" and false otherwise. -// Throw an error if commands fails due to another reason like connection issues. -export async function checkSQLRoleIsGranted( - options: Options, - instanceId: string, - databaseId: string, - grantedRole: string, - granteeRole: string, -): Promise { - const checkCmd = ` - DO $$ - DECLARE - role_count INTEGER; - BEGIN - -- Count the number of rows matching the criteria - SELECT COUNT(*) - INTO role_count - FROM - pg_auth_members m - JOIN - pg_roles grantee ON grantee.oid = m.member - JOIN - pg_roles granted ON granted.oid = m.roleid - JOIN - pg_roles grantor ON grantor.oid = m.grantor - WHERE - granted.rolname = '${grantedRole}' - AND grantee.rolname = '${granteeRole}'; - - -- If no rows were found, raise an exception - IF role_count = 0 THEN - RAISE EXCEPTION 'Role "%", is not granted to role "%".', '${grantedRole}', '${granteeRole}'; - END IF; - END $$; -`; - try { - await executeSqlCmdsAsIamUser(options, instanceId, databaseId, [checkCmd], /** silent=*/ true); - return true; - } catch (e) { - // We only return false after we confirm the error is indeed because the role isn't granted. - // Otherwise we propagate the error. - if (e instanceof FirebaseError && e.message.includes("not granted to role")) { - return false; - } - logger.error(`Role Check Failed: ${e}`); - throw e; - } -} - -export async function iamUserIsCSQLAdmin(options: Options): Promise { - const projectId = needProjectId(options); - const requiredPermissions = [ - "cloudsql.instances.connect", - "cloudsql.instances.get", - "cloudsql.users.create", - "cloudsql.users.update", - ]; - - try { - const iamResult = await testIamPermissions(projectId, requiredPermissions); - return iamResult.passed; - } catch (err: any) { - logger.debug(`[iam] error while checking permissions, command may fail: ${err}`); - return false; - } +export function firebasewriter(databaseId: string, schema: string = DEFAULT_SCHEMA) { + return `firebasewriter_${databaseId}_${schema}`; } // Creates the owner role, modifies schema owner to firebaseowner. -function ownerRolePermissions(databaseId: string, superuser: string, schema: string): string[] { - const firebaseOwnerRole = firebaseowner(databaseId); +export function ownerRolePermissions( + databaseId: string, + superuser: string, + schema: string, +): string[] { + const firebaseOwnerRole = firebaseowner(databaseId, schema); return [ `do $$ @@ -119,8 +45,12 @@ function ownerRolePermissions(databaseId: string, superuser: string, schema: str // The SQL permissions required for a role to read/write the FDC databases. // Requires the firebase_owner_* role to be the owner of the schema for default permissions. -function writerRolePermissions(databaseId: string, superuser: string, schema: string): string[] { - const firebaseWriterRole = firebasewriter(databaseId); +export function writerRolePermissions( + databaseId: string, + superuser: string, + schema: string, +): string[] { + const firebaseWriterRole = firebasewriter(databaseId, schema); return [ `do $$ @@ -146,20 +76,17 @@ function writerRolePermissions(databaseId: string, superuser: string, schema: st // Grant execution on function which could be needed by some extensions. `GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA "${schema}" TO "${firebaseWriterRole}"`, - - // Set reader defaults for new tables - `SET ROLE = '${firebaseowner(databaseId)}';`, - `ALTER DEFAULT PRIVILEGES IN SCHEMA "${schema}" GRANT SELECT, INSERT, UPDATE, DELETE, TRUNCATE ON TABLES TO "${firebaseWriterRole}";`, - `ALTER DEFAULT PRIVILEGES IN SCHEMA "${schema}" GRANT USAGE ON SEQUENCES TO "${firebaseWriterRole}";`, - `ALTER DEFAULT PRIVILEGES IN SCHEMA "${schema}" GRANT EXECUTE ON FUNCTIONS TO "${firebaseWriterRole}"`, - `SET ROLE = cloudsqlsuperuser`, ]; } // The SQL permissions required for a role to read the FDC databases. // Requires the firebase_owner_* role to be the owner of the schema for default permissions. -function readerRolePermissions(databaseId: string, superuser: string, schema: string): string[] { - const firebaseReaderRole = firebasereader(databaseId); +export function readerRolePermissions( + databaseId: string, + superuser: string, + schema: string, +): string[] { + const firebaseReaderRole = firebasereader(databaseId, schema); return [ `do $$ @@ -183,60 +110,42 @@ function readerRolePermissions(databaseId: string, superuser: string, schema: st // Grant execution on function which could be needed by some extensions. `GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA "${schema}" TO "${firebaseReaderRole}"`, - - // Set reader defaults for new tables. - // Only the owner of the schema can set defaults. - `SET ROLE = '${firebaseowner(databaseId)}';`, - `ALTER DEFAULT PRIVILEGES IN SCHEMA "${schema}" GRANT SELECT ON TABLES TO "${firebaseReaderRole}";`, - `ALTER DEFAULT PRIVILEGES IN SCHEMA "${schema}" GRANT SELECT, USAGE ON SEQUENCES TO "${firebaseReaderRole}";`, - `ALTER DEFAULT PRIVILEGES IN SCHEMA "${schema}" GRANT EXECUTE ON FUNCTIONS TO "${firebaseReaderRole}"`, - `SET ROLE = cloudsqlsuperuser`, ]; } -// Sets up all FDC roles (owner, writer, and reader). -// Granting roles to users is done by the caller. -export async function setupSQLPermissions( - instanceId: string, - databaseId: string, - options: Options, - silent: boolean = false, -) { - const superuser = "firebasesuperuser"; - - // Detect the minimal necessary revokes to avoid errors for users who used the old sql permissions setup. - const revokes = []; - if ( - await checkSQLRoleIsGranted( - options, - instanceId, - databaseId, - "cloudsqlsuperuser", - firebaseowner(databaseId), - ) - ) { - logger.warn( - "Detected cloudsqlsuperuser was previously given to firebase owner, revoking to improve database security.", - ); - revokes.push(`REVOKE "cloudsqlsuperuser" FROM "${firebaseowner(databaseId)}"`); - } - - const sqlRoleSetupCmds = concat( - // For backward compatibality we sometimes need to revoke some roles. - revokes, - - // We shoud make sure schema exists since this setup runs prior to executing the diffs. - [`CREATE SCHEMA IF NOT EXISTS "public"`], - - // Create and setup the owner role permissions. - ownerRolePermissions(databaseId, superuser, "public"), - - // Create and setup writer role permissions. - writerRolePermissions(databaseId, superuser, "public"), - - // Create and setup reader role permissions. - readerRolePermissions(databaseId, superuser, "public"), - ); - - return executeSqlCmdsAsSuperUser(options, instanceId, databaseId, sqlRoleSetupCmds, silent); +// Gives firebase reader and writer roles ability to see tables created by other owners in a given schema. +export function defaultPermissions(databaseId: string, schema: string, ownerRole: string) { + const firebaseWriterRole = firebasewriter(databaseId, schema); + const firebaseReaderRole = firebasereader(databaseId, schema); + return [ + `ALTER DEFAULT PRIVILEGES + FOR ROLE "${ownerRole}" + IN SCHEMA "${schema}" + GRANT SELECT, INSERT, UPDATE, DELETE, TRUNCATE ON TABLES TO "${firebaseWriterRole}";`, + + `ALTER DEFAULT PRIVILEGES + FOR ROLE "${ownerRole}" + IN SCHEMA "${schema}" + GRANT USAGE ON SEQUENCES TO "${firebaseWriterRole}";`, + + `ALTER DEFAULT PRIVILEGES + FOR ROLE "${ownerRole}" + IN SCHEMA "${schema}" + GRANT EXECUTE ON FUNCTIONS TO "${firebaseWriterRole}";`, + + `ALTER DEFAULT PRIVILEGES + FOR ROLE "${ownerRole}" + IN SCHEMA "${schema}" + GRANT SELECT ON TABLES TO "${firebaseReaderRole}";`, + + `ALTER DEFAULT PRIVILEGES + FOR ROLE "${ownerRole}" + IN SCHEMA "${schema}" + GRANT USAGE ON SEQUENCES TO "${firebaseReaderRole}";`, + + `ALTER DEFAULT PRIVILEGES + FOR ROLE "${ownerRole}" + IN SCHEMA "${schema}" + GRANT EXECUTE ON FUNCTIONS TO "${firebaseReaderRole}";`, + ]; } diff --git a/src/gcp/cloudsql/permissions_setup.ts b/src/gcp/cloudsql/permissions_setup.ts new file mode 100644 index 00000000000..82718e0be3d --- /dev/null +++ b/src/gcp/cloudsql/permissions_setup.ts @@ -0,0 +1,385 @@ +import { Options } from "../../options"; +import { + firebaseowner, + firebasewriter, + firebasereader, + ownerRolePermissions, + writerRolePermissions, + readerRolePermissions, + defaultPermissions, + FIREBASE_SUPER_USER, +} from "./permissions"; +import { iamUserIsCSQLAdmin } from "./cloudsqladmin"; +import { setupIAMUsers } from "./connect"; +import { logger } from "../../logger"; +import { confirm } from "../../prompt"; +import * as clc from "colorette"; +import { FirebaseError } from "../../error"; +import { needProjectNumber } from "../../projectUtils"; +import { executeSqlCmdsAsIamUser, executeSqlCmdsAsSuperUser, getIAMUser } from "./connect"; +import { concat } from "lodash"; +import { getDataConnectP4SA, toDatabaseUser } from "./connect"; + +export type TableMetadata = { + name: string; + owner: string; +}; + +export enum SchemaSetupStatus { + NotSetup = "not-setup", + GreenField = "greenfield", + BrownField = "brownfield", + NotFound = "not-found", // Schema not found +} + +export type SchemaMetadata = { + name: string; + owner: string | null; + tables: TableMetadata[]; + setupStatus: SchemaSetupStatus; +}; + +export const fdcSqlRoleMap = { + owner: firebaseowner, + writer: firebasewriter, + reader: firebasereader, +}; + +// Returns true if "grantedRole" is granted to "granteeRole" and false otherwise. +// Throw an error if commands fails due to another reason like connection issues. +export async function checkSQLRoleIsGranted( + options: Options, + instanceId: string, + databaseId: string, + grantedRole: string, + granteeRole: string, +): Promise { + const checkCmd = ` + DO $$ + DECLARE + role_count INTEGER; + BEGIN + -- Count the number of rows matching the criteria + SELECT COUNT(*) + INTO role_count + FROM + pg_auth_members m + JOIN + pg_roles grantee ON grantee.oid = m.member + JOIN + pg_roles granted ON granted.oid = m.roleid + JOIN + pg_roles grantor ON grantor.oid = m.grantor + WHERE + granted.rolname = '${grantedRole}' + AND grantee.rolname = '${granteeRole}'; + + -- If no rows were found, raise an exception + IF role_count = 0 THEN + RAISE EXCEPTION 'Role "%", is not granted to role "%".', '${grantedRole}', '${granteeRole}'; + END IF; + END $$; +`; + try { + await executeSqlCmdsAsIamUser(options, instanceId, databaseId, [checkCmd], /** silent=*/ true); + return true; + } catch (e) { + // We only return false after we confirm the error is indeed because the role isn't granted. + // Otherwise we propagate the error. + if (e instanceof FirebaseError && e.message.includes("not granted to role")) { + return false; + } + logger.error(`Role Check Failed: ${e}`); + throw e; + } +} + +// Sets up all FDC roles (owner, writer, and reader). +// Granting roles to users is done by the caller. +export async function setupSQLPermissions( + instanceId: string, + databaseId: string, + schemaInfo: SchemaMetadata, + options: Options, + silent: boolean = false, +): Promise { + const schema = schemaInfo.name; + // Step 0: Check current user can run setup and upsert IAM / P4SA users + logger.info(`Attempting to Setup SQL schema "${schema}".`); + const userIsCSQLAdmin = await iamUserIsCSQLAdmin(options); + if (!userIsCSQLAdmin) { + throw new FirebaseError( + `Missing required IAM permission to setup SQL schemas. SQL schema setup requires 'roles/cloudsql.admin' or an equivalent role.`, + ); + } + await setupIAMUsers(instanceId, databaseId, options); + + if (schemaInfo.setupStatus === SchemaSetupStatus.GreenField) { + logger.info( + `Database ${databaseId} has already been setup. Rerunning setup to repair any missing permissions.`, + ); + await greenFieldSchemaSetup(instanceId, databaseId, schema, options, silent); + return SchemaSetupStatus.GreenField; + } else { + logger.info(`Detected schema "${schema}" setup status is ${schemaInfo.setupStatus}.`); + } + + // We need to setup the database + if (schemaInfo.tables.length === 0) { + logger.info(`Found no tables in schema "${schema}", assuming greenfield project.`); + await greenFieldSchemaSetup(instanceId, databaseId, schema, options, silent); + logger.info(clc.green("Database setup complete.")); + return SchemaSetupStatus.GreenField; + } + + if (options.nonInteractive || options.force) { + throw new FirebaseError( + `Schema "${schema}" isn't set up and can only be set up in interactive mode.`, + ); + } + const currentTablesOwners = [...new Set(schemaInfo.tables.map((t) => t.owner))]; + logger.info( + `We found some existing object owners [${currentTablesOwners.join(", ")}] in your cloudsql "${schema}" schema.`, + ); + + const shouldSetupGreenfield = await confirm({ + message: clc.yellow( + "Would you like FDC to handle SQL migrations for you moving forward?\n" + + `This means we will transfer schema and tables ownership to ${firebaseowner(databaseId, schema)}\n` + + "Note: your existing migration tools/roles may lose access.", + ), + default: false, + }); + + if (shouldSetupGreenfield) { + await setupBrownfieldAsGreenfield(instanceId, databaseId, schemaInfo, options, silent); + return SchemaSetupStatus.GreenField; + } else { + logger.info( + clc.yellow( + "Setting up database in brownfield mode.\n" + + `Note: SQL migrations can't be done through ${clc.bold("firebase dataconnect:sql:migrate")} in this mode.`, + ), + ); + await brownfieldSqlSetup(instanceId, databaseId, schemaInfo, options, silent); + logger.info(clc.green("Brownfield database setup complete.")); + return SchemaSetupStatus.BrownField; + } +} + +export async function greenFieldSchemaSetup( + instanceId: string, + databaseId: string, + schema: string, + options: Options, + silent: boolean = false, +) { + // Detect the minimal necessary revokes to avoid errors for users who used the old sql permissions setup. + const revokes = []; + if ( + await checkSQLRoleIsGranted( + options, + instanceId, + databaseId, + "cloudsqlsuperuser", + firebaseowner(databaseId), + ) + ) { + logger.warn( + "Detected cloudsqlsuperuser was previously given to firebase owner, revoking to improve database security.", + ); + revokes.push(`REVOKE "cloudsqlsuperuser" FROM "${firebaseowner(databaseId)}"`); + } + + const user = (await getIAMUser(options)).user; + const projectNumber = await needProjectNumber(options); + const { user: fdcP4SAUser } = toDatabaseUser(getDataConnectP4SA(projectNumber)); + + const sqlRoleSetupCmds = concat( + // For backward compatibality we sometimes need to revoke some roles. + revokes, + + // We shoud make sure schema exists since this setup runs prior to executing the diffs. + [`CREATE SCHEMA IF NOT EXISTS "${schema}"`], + + // Create and setup the owner role permissions. + ownerRolePermissions(databaseId, FIREBASE_SUPER_USER, schema), + + // Create and setup writer role permissions. + writerRolePermissions(databaseId, FIREBASE_SUPER_USER, schema), + + // Create and setup reader role permissions. + readerRolePermissions(databaseId, FIREBASE_SUPER_USER, schema), + + // Grant firebaseowner role to the current IAM user. + `GRANT "${firebaseowner(databaseId, schema)}" TO "${user}"`, + // Grant firebaswriter to the FDC P4SA user + `GRANT "${firebasewriter(databaseId, schema)}" TO "${fdcP4SAUser}"`, + + defaultPermissions(databaseId, schema, firebaseowner(databaseId, schema)), + ); + + await executeSqlCmdsAsSuperUser(options, instanceId, databaseId, sqlRoleSetupCmds, silent); +} + +export async function getSchemaMetadata( + instanceId: string, + databaseId: string, + schema: string, + options: Options, +): Promise { + // Check if schema exists + const checkSchemaExists = await executeSqlCmdsAsIamUser( + options, + instanceId, + databaseId, + /** cmd=*/ [ + `SELECT pg_get_userbyid(nspowner) + FROM pg_namespace + WHERE nspname = '${schema}';`, + ], + /** silent=*/ true, + ); + if (!checkSchemaExists[0].rows[0]) { + return { + name: schema, + owner: null, + setupStatus: SchemaSetupStatus.NotFound, + tables: [], + }; + } + const schemaOwner = checkSchemaExists[0].rows[0].pg_get_userbyid; + + // Get schema tables + const cmd = `SELECT tablename, tableowner FROM pg_tables WHERE schemaname='${schema}'`; + const res = await executeSqlCmdsAsIamUser( + options, + instanceId, + databaseId, + [cmd], + /** silent=*/ true, + ); + const tables = res[0].rows.map((row) => { + return { + name: row.tablename, + owner: row.tableowner, + }; + }); + + // If firebase writer role doesn't exist -> Schema not setup + const checkRoleExists = async (role: string): Promise => { + const cmd = [`SELECT to_regrole('"${role}"') IS NOT NULL AS exists;`]; + const result = await executeSqlCmdsAsIamUser( + options, + instanceId, + databaseId, + cmd, + /** silent=*/ true, + ); + return result[0].rows[0].exists; + }; + + let setupStatus; + if (!(await checkRoleExists(firebasewriter(databaseId, schema)))) { + setupStatus = SchemaSetupStatus.NotSetup; + } else if ( + tables.every((table) => table.owner === firebaseowner(databaseId, schema)) && + schemaOwner === firebaseowner(databaseId, schema) + ) { + // If schema owner and all table owners are firebaseowner -> Greenfield + setupStatus = SchemaSetupStatus.GreenField; + } else { + // We have determined firebase writer exists but schema/table owner isn't firebaseowner -> Brownfield + setupStatus = SchemaSetupStatus.BrownField; + } + + return { + name: schema, + owner: schemaOwner, + setupStatus, + tables: tables, + }; +} + +export async function setupBrownfieldAsGreenfield( + instanceId: string, + databaseId: string, + schemaInfo: SchemaMetadata, + options: Options, + silent: boolean = false, +) { + const schema = schemaInfo.name; + + // Step 1: Run our usual setup which creates necessary roles, transfers schema ownership, and gives nessary grants. + await greenFieldSchemaSetup(instanceId, databaseId, schema, options, silent); + + // Step 2: Grant non firebase owners the writer role before changing the table owners. + const firebaseOwnerRole = firebaseowner(databaseId, schema); + const nonFirebasetablesOwners = [...new Set(schemaInfo.tables.map((t) => t.owner))].filter( + (owner) => owner !== firebaseOwnerRole, + ); + const grantCmds = nonFirebasetablesOwners.map( + (owner) => `GRANT "${firebasewriter(databaseId, schema)}" TO "${owner}"`, + ); + + // Step 3: Alter table owners permissions + const alterTableCmds = schemaInfo.tables.map( + (table) => `ALTER TABLE "${schema}"."${table.name}" OWNER TO "${firebaseOwnerRole}";`, + ); + + // Run sql commands + await executeSqlCmdsAsSuperUser( + options, + instanceId, + databaseId, + [...grantCmds, ...alterTableCmds], + silent, + ); +} + +export async function brownfieldSqlSetup( + instanceId: string, + databaseId: string, + schemaInfo: SchemaMetadata, + options: Options, + silent: boolean = false, +) { + const schema = schemaInfo.name; + + // Step 1: Grant firebasesuperuser access to the original owner + const uniqueTablesOwners = [...new Set(schemaInfo.tables.map((t) => t.owner))]; + const grantOwnersToFirebasesuperuser = uniqueTablesOwners.map( + (owner) => `GRANT ${owner} TO ${FIREBASE_SUPER_USER}`, + ); + + // Step 2: Using firebasesuperuser, setup reader and writer permissions on existing tables and setup default permissions for future tables. + const iamUser = (await getIAMUser(options)).user; + const projectNumber = await needProjectNumber(options); + const { user: fdcP4SAUser } = toDatabaseUser(getDataConnectP4SA(projectNumber)); + + // Step 3: Grant firebase reader and writer roles access to any new tables created by found owner. + const firebaseDefaultPermissions = uniqueTablesOwners.flatMap((owner) => + defaultPermissions(databaseId, schema, owner), + ); + + // Batch execute the previous steps commands + const brownfieldSetupCmds = [ + // Firebase superuser grants + ...grantOwnersToFirebasesuperuser, + // Create and setup writer role permissions. + ...writerRolePermissions(databaseId, FIREBASE_SUPER_USER, schema), + + // Create and setup reader role permissions. + ...readerRolePermissions(databaseId, FIREBASE_SUPER_USER, schema), + + // Grant firebasewriter role to the current IAM user. + `GRANT "${firebasewriter(databaseId, schema)}" TO "${iamUser}"`, + // Grant firebaswriter to the FDC P4SA user + `GRANT "${firebasewriter(databaseId, schema)}" TO "${fdcP4SAUser}"`, + + // Insures firebase roles have access to future tables + ...firebaseDefaultPermissions, + ]; + + await executeSqlCmdsAsSuperUser(options, instanceId, databaseId, brownfieldSetupCmds, silent); +}