UNPKG

@dataroadinc/setup-auth

Version:

CLI tool and programmatic API for automated OAuth setup across cloud platforms

328 lines (327 loc) 14.8 kB
import { SetupAuthError } from "../../../utils/error.js"; import { sleep, waitForIamPropagation } from "../../../utils/sleep.js"; import { superJoin } from "../../../utils/string.js"; import { ProjectsClient } from "@google-cloud/resource-manager"; import { ServiceUsageClient } from "@google-cloud/service-usage"; import { backOff } from "exponential-backoff"; import { GcpProjectManager } from "../project/index.js"; import { BACKOFF_OPTIONS, BaseGcpIamManager } from "./base-iam.js"; import { PROJECT_PERMISSIONS, PROJECT_ROLES, PUBLIC_SERVICES, REQUIRED_SERVICES, } from "./constants.js"; export class GcpProjectIamManager extends BaseGcpIamManager { constructor(identity, organizationId, projectId) { super(identity); this.projectId = projectId; this.projectManager = new GcpProjectManager(identity, organizationId); } async initializeSpecific() { await super.initialize(); this.authClient = await this.identity.getGaxAuthClient(); this.projectsClient = new ProjectsClient({ auth: this.authClient, }); this.serviceUsageClient = new ServiceUsageClient({ auth: this.authClient, }); await this.projectManager.initialize(); } formatResourceName(projectId) { return `projects/${projectId}`; } async checkPermissions() { await this.initialize(); if (!this.projectsClient) { throw new SetupAuthError("ProjectsClient not initialized"); } console.log("Checking project-level permissions..."); try { const [response] = await this.projectsClient.testIamPermissions({ resource: this.formatResourceName(this.projectId), permissions: Object.values(PROJECT_PERMISSIONS), }); const grantedPermissions = new Set(response.permissions || []); const missingPermissions = Object.values(PROJECT_PERMISSIONS).filter(permission => !grantedPermissions.has(permission)); return { missingPermissions }; } catch (error) { throw new SetupAuthError("Failed to test project IAM permissions. Cannot verify current state.", { cause: error }); } } async getIamPolicy() { await this.initialize(); if (!this.projectsClient) { throw new SetupAuthError("ProjectsClient not initialized"); } const [policy] = await this.projectsClient.getIamPolicy({ resource: this.formatResourceName(this.projectId), options: { requestedPolicyVersion: 3 }, }); if (!policy) { throw new SetupAuthError("Could not get project IAM policy"); } let base64Etag = undefined; if (policy.etag) { if (typeof policy.etag === "string") { base64Etag = policy.etag; console.warn("Received etag as string from SDK, assuming base64."); } else if (Buffer.isBuffer(policy.etag)) { base64Etag = policy.etag.toString("base64"); } else if (policy.etag instanceof Uint8Array) { base64Etag = Buffer.from(policy.etag).toString("base64"); } else { console.warn("Unrecognized etag type received from SDK:", typeof policy.etag, policy.etag); } } return { version: policy.version ?? 3, bindings: policy.bindings?.map(binding => ({ role: binding.role || "", members: binding.members || [], })) || [], etag: base64Etag, }; } async setIamPolicy(policy) { await this.initialize(); if (!this.projectsClient) { throw new SetupAuthError("ProjectsClient not initialized"); } const etagBytes = policy.etag ? Buffer.from(policy.etag, "base64") : undefined; const sdkPolicy = { version: policy.version, bindings: policy.bindings, etag: etagBytes, }; console.log("--- DEBUG: Policy object being sent to setIamPolicy (SDK format) ---"); console.log(JSON.stringify({ ...sdkPolicy, etag: policy.etag ? "<etag provided>" : "<no etag>" }, null, 2)); console.log("---------------------------------------------------------------------"); try { if (sdkPolicy.etag && !sdkPolicy.version) { console.warn("Policy etag provided but version is missing, defaulting to 3."); sdkPolicy.version = 3; } await this.projectsClient.setIamPolicy({ resource: this.formatResourceName(this.projectId), policy: sdkPolicy, }); } catch (error) { console.error("--- DEBUG: Error during projectsClient.setIamPolicy ---"); console.error("Policy Sent (SDK format):", JSON.stringify({ ...sdkPolicy, etag: policy.etag ? "<etag provided>" : "<no etag>" }, null, 2)); console.error("Error Object:", error); console.error("------------------------------------------------------"); throw new SetupAuthError("Failed to set project IAM policy via SDK call", { cause: error }); } } async addRoles(userId, roles) { const policy = await this.getIamPolicy(); const member = `user:${userId}`; if (!policy.bindings) { policy.bindings = []; } let policyModified = false; for (const role of roles) { const existingBinding = policy.bindings.find((b) => b.role === role); if (existingBinding) { if (!existingBinding.members.includes(member)) { existingBinding.members.push(member); policyModified = true; } } else { policy.bindings.push({ role, members: [member], }); policyModified = true; } } if (policyModified) { console.log(`Policy modified, calling setIamPolicy for roles: ${superJoin(roles)}`); await this.setIamPolicy(policy); return true; } else { console.log(`Policy already contains user ${userId} for roles: ${superJoin(roles)}. Skipping setIamPolicy.`); return false; } } async checkServicesEnabled() { await this.initialize(); if (!this.serviceUsageClient) { throw new SetupAuthError("ServiceUsageClient not initialized"); } try { const [services] = await this.serviceUsageClient.listServices({ parent: this.formatResourceName(this.projectId), filter: "state:ENABLED", }); const enabledServices = new Set(services.map(service => service.name?.split("/").pop() || "")); for (const publicService of PUBLIC_SERVICES) { enabledServices.add(publicService); } return Object.values(REQUIRED_SERVICES).every(service => enabledServices.has(service)); } catch (error) { console.warn("Failed to check service status:", error); return false; } } async enableRequiredServices() { await this.initialize(); if (!this.serviceUsageClient) { throw new SetupAuthError("ServiceUsageClient not initialized"); } const requiredServices = Object.values(REQUIRED_SERVICES); for (const service of requiredServices) { if (PUBLIC_SERVICES.includes(service)) { continue; } try { console.log(`Enabling service: ${service}`); await backOff(() => this.serviceUsageClient.enableService({ name: this.formatResourceName(this.projectId) + "/services/" + service, }), BACKOFF_OPTIONS); await sleep(1000); } catch (error) { if (error instanceof Error && error.message.includes("already enabled")) { console.log(`Service ${service} is already enabled`); continue; } console.error(`Failed to enable service '${service}'. Full Error Object:`); console.error(error); let code; let details; let message; if (error && typeof error === "object") { if ("code" in error) code = error.code; if ("details" in error) details = error.details; if ("message" in error) message = error.message; } throw new SetupAuthError(`Failed to enable service '${service}'. Code: ${code || "N/A"}, Details: ${details || "N/A"}. Message: ${message || String(error)}`, { cause: error }); } } } async ensurePermissions() { await this.initialize(); const projectExists = await this.projectManager.projectExists(this.projectId); if (!projectExists) { throw new SetupAuthError(`Project ${this.projectId} does not exist. Cannot grant permissions on non-existent project.`); } let checkAttempt = 1; const maxCheckAttempts = 3; let requiredPermissionsGranted = false; while (checkAttempt <= maxCheckAttempts && !requiredPermissionsGranted) { console.log(`Permission Check Attempt ${checkAttempt}/${maxCheckAttempts}...`); const { missingPermissions } = await this.checkPermissions(); if (missingPermissions.length === 0) { console.log("All required project permissions appear to be present based on initial check."); requiredPermissionsGranted = true; break; } console.log(`Missing project permissions: ${missingPermissions.join(", ")}`); const rolesToEnsure = [ PROJECT_ROLES.OWNER, PROJECT_ROLES.SERVICE_USAGE_ADMIN, ]; console.log(`Attempting to ensure required roles: ${superJoin(rolesToEnsure)}`); try { await this.addRoles(this.userEmail, rolesToEnsure); await waitForIamPropagation(async () => { const { missingPermissions: afterGrant } = await this.checkPermissions(); return afterGrant.length === 0; }, { timeoutMs: 30000, intervalMs: 2000, description: "project IAM propagation (role assignment)", }); requiredPermissionsGranted = true; } catch (error) { console.error(`Failed to add/ensure roles (${superJoin(rolesToEnsure)}):`, error); throw new SetupAuthError(`Failed to grant necessary project roles. Please check organization-level permissions allowing role assignments.`, { cause: error, }); } checkAttempt++; } const { missingPermissions: finalMissing } = await this.checkPermissions(); if (finalMissing.length > 0) { console.error("Final permission check failed. Missing permissions:", finalMissing); throw new SetupAuthError(`Could not ensure all required project permissions (${finalMissing.join(", ")}) even after attempting specific role grants. ` + "Verify the executing user has sufficient permissions at the Organization level (e.g., roles/resourcemanager.projectIamAdmin or similar) to grant project roles.", { cause: finalMissing.length > 0 ? new Error(finalMissing.join(", ")) : undefined, }); } else { console.log("Successfully verified all required project permissions are present based on testIamPermissions."); } const rolesToEnsureEnablement = [ PROJECT_ROLES.OWNER, PROJECT_ROLES.SERVICE_USAGE_ADMIN, ]; console.log(`Proactively attempting to ensure roles for service enablement: ${superJoin(rolesToEnsureEnablement)}`); try { const rolesWereAdded = await this.addRoles(this.userEmail, rolesToEnsureEnablement); if (rolesWereAdded) { await waitForIamPropagation(async () => { const { missingPermissions: afterGrant } = await this.checkPermissions(); return afterGrant.length === 0; }, { timeoutMs: 30000, intervalMs: 2000, description: "project IAM propagation (proactive role grant)", }); console.log("All required project permissions granted after proactive role assignment."); } else { console.log("Skipping wait as roles were already present."); } } catch (error) { console.error(`Failed to proactively grant roles (${superJoin(rolesToEnsureEnablement)}) needed for service enablement:`, error); throw new SetupAuthError(`Failed to proactively grant necessary roles ('${superJoin(rolesToEnsureEnablement)}') required for enabling services. ` + "Please check organization-level permissions allowing role assignments.", { cause: error }); } const servicesEnabled = await this.checkServicesEnabled(); if (!servicesEnabled) { console.log("Enabling required services..."); await this.enableRequiredServices(); } else { console.log("All required services are already enabled."); } } } export function displayTable(data) { if (data.length === 0) { console.log("No data to display"); return; } const columns = Object.keys(data[0]); const columnWidths = columns.map(col => { const maxWidth = Math.max(col.length, ...data.map(row => String(row[col] ?? "").length)); return maxWidth + 2; }); const separator = columnWidths.map(width => "-".repeat(width)).join("-+-"); console.log(columns.map((col, i) => col.padEnd(columnWidths[i])).join(" | ")); console.log(separator); data.forEach(row => { console.log(columns .map((col, i) => String(row[col] ?? "").padEnd(columnWidths[i])) .join(" | ")); }); }