@dataroadinc/setup-auth
Version:
CLI tool and programmatic API for automated OAuth setup across cloud platforms
328 lines (327 loc) • 14.8 kB
JavaScript
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(" | "));
});
}