UNPKG

@dataroadinc/setup-auth

Version:

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

563 lines (505 loc) 17.7 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 { GoogleAuth as GaxGoogleAuth } from "google-gax" import { GcpAuthenticatedIdentity } from "../creds/identity.js" import { GcpProjectManager } from "../project/index.js" import { BACKOFF_OPTIONS, BaseGcpIamManager, IamPolicy } from "./base-iam.js" import { PROJECT_PERMISSIONS, PROJECT_ROLES, ProjectPermission, ProjectRole, PUBLIC_SERVICES, REQUIRED_SERVICES, } from "./constants.js" /** * Manages GCP project-level IAM operations */ export class GcpProjectIamManager extends BaseGcpIamManager { private projectsClient: ProjectsClient | undefined private serviceUsageClient: ServiceUsageClient | undefined private authClient: GaxGoogleAuth | undefined private projectId: string private readonly projectManager: GcpProjectManager constructor( identity: GcpAuthenticatedIdentity, organizationId: string, projectId: string ) { super(identity) this.projectId = projectId this.projectManager = new GcpProjectManager(identity, organizationId) } /** * Initialize specific implementation for project manager */ protected async initializeSpecific(): Promise<void> { 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() } /** * Format a resource name according to GCP service requirements */ protected formatResourceName(projectId: string): string { return `projects/${projectId}` } /** * Check if all required permissions are present */ async checkPermissions(): Promise<{ missingPermissions: ProjectPermission[] }> { await this.initialize() if (!this.projectsClient) { throw new SetupAuthError("ProjectsClient not initialized") } console.log("Checking project-level permissions...") try { // Test permissions directly without checking project existence 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) ) as ProjectPermission[] return { missingPermissions } } catch (error) { throw new SetupAuthError( "Failed to test project IAM permissions. Cannot verify current state.", { cause: error } ) } } /** * Get the project's IAM policy */ async getIamPolicy(): Promise<IamPolicy> { 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") } // Explicitly handle potential types of policy.etag from SDK let base64Etag: string | undefined = undefined if (policy.etag) { if (typeof policy.etag === "string") { // If SDK gives a string, assume it's base64 already (or handle if it's not) base64Etag = policy.etag console.warn("Received etag as string from SDK, assuming base64.") } else if (Buffer.isBuffer(policy.etag)) { // If it's a Buffer, convert to base64 string base64Etag = policy.etag.toString("base64") } else if (policy.etag instanceof Uint8Array) { // If it's a Uint8Array, convert to Buffer then base64 string base64Etag = Buffer.from(policy.etag).toString("base64") } else { // Log unexpected types 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, // Ensure this is string | undefined } } /** * Set the project's IAM policy */ async setIamPolicy(policy: IamPolicy): Promise<void> { await this.initialize() if (!this.projectsClient) { throw new SetupAuthError("ProjectsClient not initialized") } // Convert base64 string etag back to Buffer for the SDK call // Since getIamPolicy now guarantees policy.etag is string | undefined, this should be safe const etagBytes = policy.etag ? Buffer.from(policy.etag, "base64") : undefined // Construct the policy object expected by the SDK const sdkPolicy = { version: policy.version, bindings: policy.bindings, etag: etagBytes, } // --- DEBUGGING LOG --- 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( "---------------------------------------------------------------------" ) // --- END DEBUGGING 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 } ) } } /** * Add roles to the user in the project * @returns {Promise<boolean>} - True if the policy was modified, false otherwise. */ async addRoles(userId: string, roles: ProjectRole[]): Promise<boolean> { 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: { role: string }) => 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 // Indicate modification } else { console.log( `Policy already contains user ${userId} for roles: ${superJoin(roles)}. Skipping setIamPolicy.` ) return false // Indicate no modification } } /** * Check if required services are enabled */ async checkServicesEnabled(): Promise<boolean> { 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() || "") ) // Add public services that are always enabled 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 } } /** * Enable required services */ async enableRequiredServices(): Promise<void> { await this.initialize() if (!this.serviceUsageClient) { throw new SetupAuthError("ServiceUsageClient not initialized") } const requiredServices = Object.values(REQUIRED_SERVICES) for (const service of requiredServices) { // Skip enabling public services as they are always enabled if ( PUBLIC_SERVICES.includes(service as typeof REQUIRED_SERVICES.OAUTH2) ) { continue } try { console.log(`Enabling service: ${service}`) await backOff( () => this.serviceUsageClient!.enableService({ name: this.formatResourceName(this.projectId) + "/services/" + service, }), BACKOFF_OPTIONS ) await sleep(1000) // Wait a bit between enabling services } catch (error) { // If the service is already enabled, that's fine if ( error instanceof Error && error.message.includes("already enabled") ) { console.log(`Service ${service} is already enabled`) continue } // Log more details for other errors console.error( `Failed to enable service '${service}'. Full Error Object:` ) console.error(error) // Log the full error object // Extract common properties if they exist for the SetupAuthError message let code: number | string | undefined let details: string | undefined let message: string | undefined if (error && typeof error === "object") { if ("code" in error) code = error.code as number | string if ("details" in error) details = error.details as string if ("message" in error) message = error.message as string } // Re-throw other errors (like genuine PERMISSION_DENIED for service usage) throw new SetupAuthError( `Failed to enable service '${service}'. Code: ${code || "N/A"}, Details: ${details || "N/A"}. Message: ${message || String(error)}`, { cause: error } ) } } } /** * Attempt to grant all necessary project permissions and enable required services */ async ensurePermissions(): Promise<void> { 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.` ) } // --- Refined Permission Check and Grant Logic --- let checkAttempt = 1 const maxCheckAttempts = 3 // Try a few times in case of propagation races 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 // Exit loop, permissions seem good } console.log( `Missing project permissions: ${missingPermissions.join(", ")}` ) // Use imported constants with UPPER_SNAKE_CASE keys const rolesToEnsure: ProjectRole[] = [ PROJECT_ROLES.OWNER, PROJECT_ROLES.SERVICE_USAGE_ADMIN, ] console.log( `Attempting to ensure required roles: ${superJoin(rolesToEnsure)}` ) try { await this.addRoles(this.userEmail!, rolesToEnsure) // Wait for IAM propagation: retry checkPermissions until all required permissions are present 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 // No need to break; loop will exit naturally } 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++ } // Final verification check after potential role grants 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." ) } // --- End Permission Grant Logic (Initial Check) --- // --- Proactive Role Grant before Enabling Services --- const rolesToEnsureEnablement: ProjectRole[] = [ 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) { // Wait for IAM propagation: retry checkPermissions until all required permissions are present 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 } ) } // --- End Proactive Role Grant --- // Check and enable required services const servicesEnabled = await this.checkServicesEnabled() if (!servicesEnabled) { console.log("Enabling required services...") await this.enableRequiredServices() } else { console.log("All required services are already enabled.") } } } interface TableRow { [key: string]: string | boolean | number | null | undefined } export function displayTable(data: TableRow[]): void { if (data.length === 0) { console.log("No data to display") return } // Get column names from the first row const columns = Object.keys(data[0]) // Calculate column widths const columnWidths = columns.map(col => { const maxWidth = Math.max( col.length, ...data.map(row => String(row[col] ?? "").length) ) return maxWidth + 2 // Add padding }) // Create separator line const separator = columnWidths.map(width => "-".repeat(width)).join("-+-") // Print header console.log(columns.map((col, i) => col.padEnd(columnWidths[i])).join(" | ")) console.log(separator) // Print rows data.forEach(row => { console.log( columns .map((col, i) => String(row[col] ?? "").padEnd(columnWidths[i])) .join(" | ") ) }) }