UNPKG

@dataroadinc/setup-auth

Version:

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

361 lines (336 loc) 13.9 kB
import { SetupAuthError } from "../../../utils/error.js" // Import constants from central file import { superJoin } from "../../../utils/string.js" import { OrgPolicyClient, protos } from "@google-cloud/org-policy" import { OrganizationsClient } from "@google-cloud/resource-manager" // Import for injection import { backOff } from "exponential-backoff" import { GcpAuthenticatedIdentity } from "../creds/identity.js" import { BACKOFF_OPTIONS } from "../iam/base-iam.js" // Reuse backoff options only import { ORGANIZATION_PERMISSIONS } from "../iam/constants.js" const SERVICE_USAGE_CONSTRAINT = "constraints/serviceuser.services" type IPolicy = protos.google.cloud.orgpolicy.v2.IPolicy export class GcpOrgPolicyManager { // No inheritance private identity: GcpAuthenticatedIdentity private orgPolicyClient: OrgPolicyClient | undefined private organizationsClient: OrganizationsClient // Injected private orgResourceName: string private userEmail: string | undefined private hasGetPermission: boolean | undefined = undefined private hasSetPermission: boolean | undefined = undefined private constraintsListed: boolean = false // Flag to list only once constructor( identity: GcpAuthenticatedIdentity, organizationId: string, organizationsClient: OrganizationsClient ) { this.identity = identity this.organizationsClient = organizationsClient // Store injected client this.orgResourceName = `organizations/${organizationId}` } // Separate initialization for this manager's specific client async initialize(): Promise<void> { if (!this.orgPolicyClient) { const auth = await this.identity.getGaxAuthClient() this.orgPolicyClient = new OrgPolicyClient({ auth }) } if (!this.userEmail) { // Use the correct method to get email this.userEmail = await this.identity.getCurrentUserEmail() if (!this.userEmail) { throw new SetupAuthError("Could not retrieve user email from identity.") } } // --- DEBUG: List available constraints (only once) --- if (this.orgPolicyClient && !this.constraintsListed) { try { console.log(`DEBUG: Listing constraints for ${this.orgResourceName}...`) const [constraintsList] = await this.orgPolicyClient.listConstraints({ parent: this.orgResourceName, pageSize: 200, // Ensure we get a good number if > default page size }) console.log(`DEBUG: Found ${constraintsList.length} constraints.`) // Log the first few constraint names to verify format and existence console.log("DEBUG: Sample constraint names:") constraintsList.slice(0, 10).forEach(c => console.log(` - ${c.name}`)) // Check if the specific constraint exists in the list const targetConstraint = `${this.orgResourceName}/constraints/serviceuser.services` const foundTarget = constraintsList.some( c => c.name === targetConstraint ) console.log( `DEBUG: Does list contain '${targetConstraint}'? ${foundTarget}` ) this.constraintsListed = true // Set flag after successful listing } catch (listError) { console.error( `DEBUG: Failed to list constraints for ${this.orgResourceName}:`, listError ) } } // --- END DEBUG --- } /** * Checks if the executing user has specific Org Policy permissions. * Caches results. */ private async checkOrgPolicyPermissions(): Promise<{ canGet: boolean canSet: boolean }> { await this.initialize() if ( this.hasGetPermission !== undefined && this.hasSetPermission !== undefined ) { return { canGet: this.hasGetPermission, canSet: this.hasSetPermission } } if (!this.userEmail) { throw new SetupAuthError("User email is required for permission check.") } // Use imported constants const permissionsToCheck = [ ORGANIZATION_PERMISSIONS.ORG_POLICY_GET, ORGANIZATION_PERMISSIONS.ORG_POLICY_SET, ] console.log( `Checking if user ${this.userEmail} has the following Org Policy permissions on ${this.orgResourceName}:${superJoin(permissionsToCheck)}` ) try { const [response] = await backOff( () => this.organizationsClient.testIamPermissions({ resource: this.orgResourceName, permissions: permissionsToCheck, }), BACKOFF_OPTIONS ) const granted = new Set(response.permissions || []) // Use imported constants this.hasGetPermission = granted.has( ORGANIZATION_PERMISSIONS.ORG_POLICY_GET ) this.hasSetPermission = granted.has( ORGANIZATION_PERMISSIONS.ORG_POLICY_SET ) console.log( `Permission check results: GET=${this.hasGetPermission}, SET=${this.hasSetPermission}` ) return { canGet: this.hasGetPermission, canSet: this.hasSetPermission } } catch (error) { console.error( `Failed to check Org Policy permissions [${permissionsToCheck.join(", ")}]:`, error ) this.hasGetPermission = false this.hasSetPermission = false return { canGet: false, canSet: false } } } /** * Gets the organization policy for a given constraint. */ private async getOrgLevelPolicy(constraint: string): Promise<IPolicy | null> { const { canGet } = await this.checkOrgPolicyPermissions() if (!canGet) { // Use imported constant throw new SetupAuthError( `User ${this.userEmail} lacks permission '${ORGANIZATION_PERMISSIONS.ORG_POLICY_GET}' on ${this.orgResourceName}. Cannot fetch Org Policy.` ) } await this.initialize() if (!this.orgPolicyClient) throw new SetupAuthError("OrgPolicyClient not initialized") const name = `${this.orgResourceName}/policies/${constraint}` console.log( `DEBUG: Attempting to fetch Org Policy with resource name: "${name}"` ) try { const [policy] = await backOff( () => this.orgPolicyClient!.getPolicy({ name }), BACKOFF_OPTIONS ) return policy } catch (error: unknown) { let code: number | undefined let message: string | undefined if (error && typeof error === "object") { if ("code" in error) code = error.code as number if ("message" in error) message = error.message as string } // Handle NOT_FOUND (Code 5) if (code === 5) { console.log( `No policy found directly on ${this.orgResourceName} for constraint ${constraint}.` ) return null } // WORKAROUND: Handle specific INVALID_ARGUMENT (Code 3) ONLY for serviceuser.services if (code === 3 && constraint === SERVICE_USAGE_CONSTRAINT) { console.warn( `WORKAROUND: Received INVALID_ARGUMENT (Code 3) fetching policy for ${SERVICE_USAGE_CONSTRAINT}, ` + `but proceeding as GET permission exists and effective policy is likely allowAll. Check GCP console if issues persist.` ) return null // Treat as if no policy is set, allowing default behavior } // Handle unexpected INVALID_ARGUMENT (Code 3) for OTHER constraints if (code === 3) { console.error( `Received INVALID_ARGUMENT (Code 3) fetching policy ${name}, despite having GET permission. This is unexpected.` ) throw new SetupAuthError( `Failed to fetch organization policy for ${constraint} due to unexpected INVALID_ARGUMENT (Code: 3), even though GET permission exists. ` + `This might indicate an issue with the constraint configuration or the API/client library. Please check the constraint '${constraint}' in the GCP console for potential issues. Original error: ${message || String(error)}`, { cause: error instanceof Error ? error : new Error(message || String(error)), } ) } // Handle other errors console.error(`Error fetching policy ${name}:`, error) const errorCodeMessage = code ? ` (Code: ${code})` : "" throw new SetupAuthError( `Failed to fetch organization policy for ${constraint}${errorCodeMessage}`, { cause: error instanceof Error ? error : new Error(message || String(error)), } ) } } /** * Ensures a specific service is allowed by the 'serviceuser.services' constraint * at the organization level, modifying the policy if necessary and permitted. */ async ensureServiceAllowedAtOrgLevel(serviceName: string): Promise<void> { // Fetches policy (and implicitly checks GET permission via getOrgLevelPolicy) const policy = await this.getOrgLevelPolicy(SERVICE_USAGE_CONSTRAINT) let isAllowed = true let policyNeedsUpdate = false let modifiedPolicy: IPolicy | null = policy ? JSON.parse(JSON.stringify(policy)) : null if (modifiedPolicy && !modifiedPolicy.spec) modifiedPolicy.spec = {} if (modifiedPolicy?.spec && !modifiedPolicy.spec.rules) modifiedPolicy.spec.rules = [] if (modifiedPolicy?.spec?.rules && modifiedPolicy.spec.rules.length > 0) { let relevantRuleFound = false for (let i = 0; i < modifiedPolicy.spec.rules.length; i++) { // Use 'any' for rule and values as requested, silencing linter // eslint-disable-next-line @typescript-eslint/no-explicit-any const rule: any = modifiedPolicy.spec.rules[i] if (!rule.values) rule.values = {} // eslint-disable-next-line @typescript-eslint/no-explicit-any const ruleValues: any = rule.values if (ruleValues.deniedValues?.includes(serviceName)) { isAllowed = false relevantRuleFound = true ruleValues.deniedValues = ruleValues.deniedValues.filter( (v: string) => v !== serviceName ) policyNeedsUpdate = true console.log(`Removed ${serviceName} from deny list.`) } else if (ruleValues.allowedValues) { relevantRuleFound = true const isAllowListEnforced = !rule.allowAll && !rule.denyAll if ( isAllowListEnforced && !ruleValues.allowedValues.includes(serviceName) ) { isAllowed = false ruleValues.allowedValues.push(serviceName) policyNeedsUpdate = true isAllowed = true console.log(`Added ${serviceName} to allow list.`) } else if (ruleValues.allowedValues.includes(serviceName)) { isAllowed = true policyNeedsUpdate = false break } } else if (rule.denyAll) { isAllowed = false relevantRuleFound = true policyNeedsUpdate = false console.log(`Service blocked by denyAll rule.`) break } else if (rule.allowAll) { isAllowed = true relevantRuleFound = true policyNeedsUpdate = false break } } if (!relevantRuleFound) isAllowed = true } else { isAllowed = true } if (!isAllowed && !policyNeedsUpdate) { throw new SetupAuthError( `Service ${serviceName} is blocked by Organization Policy (${SERVICE_USAGE_CONSTRAINT}), and automatic modification was not possible or safe. Please review the policy on ${this.orgResourceName}.` ) } if (policyNeedsUpdate) { console.log( `Organization policy for ${SERVICE_USAGE_CONSTRAINT} needs update to allow ${serviceName}.` ) // Check SET permission specifically before attempting update const { canSet } = await this.checkOrgPolicyPermissions() if (!canSet) { // Use imported constant throw new SetupAuthError( `Executing user ${this.userEmail} lacks permission '${ORGANIZATION_PERMISSIONS.ORG_POLICY_SET}' on ${this.orgResourceName}. ` + `Cannot automatically modify Organization Policy to allow service '${serviceName}'. Please grant the permission or modify the policy manually.` ) } if ( !modifiedPolicy?.name || !modifiedPolicy?.etag || !modifiedPolicy?.spec ) { throw new SetupAuthError( "Cannot update policy: missing name, etag, or spec." ) } console.log( `Attempting to update policy ${modifiedPolicy.name} (etag: ${modifiedPolicy.etag})...` ) try { const updateRequest = { policy: modifiedPolicy, updateMask: { paths: ["spec"] }, } await backOff( () => this.orgPolicyClient!.updatePolicy(updateRequest), BACKOFF_OPTIONS ) console.log( `Successfully updated Org Policy ${modifiedPolicy.name} to allow ${serviceName}.` ) // No unconditional wait here; if a check is possible, do it, otherwise proceed // TODO: If possible, check if the policy is effective before proceeding. Only wait if needed. } catch (error) { console.error(`Error updating policy ${modifiedPolicy.name}:`, error) throw new SetupAuthError( `Failed to update Organization Policy for ${SERVICE_USAGE_CONSTRAINT} to allow ${serviceName}.`, { cause: error instanceof Error ? error : new Error(String(error)), } ) } } else if (!isAllowed) { throw new SetupAuthError( `Service ${serviceName} appears blocked by Organization Policy (${SERVICE_USAGE_CONSTRAINT}), but no modification was attempted. Please review the policy on ${this.orgResourceName}.` ) } else { console.log( `Service ${serviceName} appears to be allowed by Organization Policy ${SERVICE_USAGE_CONSTRAINT}.` ) } } }