@pagopa/dx-cli
Version:
A CLI useful to manage DX tools.
256 lines (255 loc) • 12.7 kB
JavaScript
import { AuthorizationManagementClient } from "@azure/arm-authorization";
import { ManagedServiceIdentityClient } from "@azure/arm-msi";
import { ResourceGraphClient } from "@azure/arm-resourcegraph";
import { ResourceManagementClient } from "@azure/arm-resources";
import { StorageManagementClient } from "@azure/arm-storage";
import { BlobServiceClient } from "@azure/storage-blob";
import { getLogger } from "@logtape/logtape";
import { Client } from "@microsoft/microsoft-graph-client";
import * as assert from "node:assert/strict";
import { z } from "zod/v4";
import { environmentShort, } from "../../domain/environment.js";
import { terraformBackendSchema, } from "../../domain/remote-backend.js";
import { isAzureLocation, locations, locationShort } from "./locations.js";
// We are only interested in these properties for now;
// the actual result structure contains the full cloud resource object
export const resourceGraphDataSchema = z.object({
location: z.enum(locations),
name: z.string(),
resourceGroup: z.string(),
});
const graphUserResponseSchema = z.object({
id: z.string(),
});
const graphGroupMembershipItemSchema = z.object({
id: z.string(),
});
const graphGroupMembershipResponseSchema = z.object({
"@odata.nextLink": z.string().optional(),
value: z.array(graphGroupMembershipItemSchema),
});
export class AzureCloudAccountService {
#credential;
#resourceGraphClient;
constructor(credential) {
this.#resourceGraphClient = new ResourceGraphClient(credential);
this.#credential = credential;
}
async getTerraformBackend(cloudAccountId, { name, prefix }) {
const allLocations = Object.values(locationShort).join("|");
const shortEnv = environmentShort[name];
// Check if a storage account with the expected name exists
// $prefix + environment short + location + "tfstatest" + suffix (e.g., "dxpitntfstatest01")
// it can return multiple results (e.g. for different location or instance number)
const resourceName = `${prefix}${shortEnv}(${allLocations})tfstatest(0[1-9]|[1-9]\\d)`;
const query = `resources
| where type == 'microsoft.storage/storageaccounts'
| where name matches regex @'${resourceName}'
`;
const result = await this.#resourceGraphClient.resources({
query,
subscriptions: [cloudAccountId],
});
if (result.totalRecords === 0) {
return undefined;
}
const storageAccounts = z.array(resourceGraphDataSchema).parse(result.data);
// on multiple results, rank storage accounts by location priority and instance number
if (storageAccounts.length > 0) {
storageAccounts.sort((a, b) => {
// compare locations priority
const locationComparison = locations.indexOf(a.location) - locations.indexOf(b.location);
if (locationComparison === 0) {
// same location, compare by name (to get the highest instance number)
return b.name.localeCompare(a.name);
}
return locationComparison;
});
}
return terraformBackendSchema.parse({
resourceGroupName: storageAccounts[0].resourceGroup,
storageAccountName: storageAccounts[0].name,
subscriptionId: cloudAccountId,
type: "azurerm",
});
}
async hasUserPermissionToInitialize(cloudAccountId) {
try {
// All principal IDs to check (user + all groups)
const allPrincipalIds = await this.#getCurrentPrincipalIds();
// Get role assignments for the subscription
const authClient = new AuthorizationManagementClient(this.#credential, cloudAccountId);
const requiredRoles = [
"8e3af657-a8ff-443c-a75c-2fe8c4bcb635", // Owner
"ba92f5b4-2d11-453d-a403-e96b0029c9fe", // Storage Blob Data Contributor
];
const scope = `/subscriptions/${cloudAccountId}`;
// Collect all role definition IDs assigned to the user or their groups
const assignedRoleDefinitionIds = new Set();
for await (const assignment of authClient.roleAssignments.listForScope(scope)) {
// Check if this assignment is for the user or any of their groups
if (assignment.principalId &&
allPrincipalIds.has(assignment.principalId)) {
// Extract role definition ID from the full resource ID
// Format: /subscriptions/{sub}/providers/Microsoft.Authorization/roleDefinitions/{roleId}
const roleDefId = assignment.roleDefinitionId?.split("/").pop();
if (roleDefId) {
assignedRoleDefinitionIds.add(roleDefId);
}
}
// Short-circuit: stop if all required roles have been found
if (requiredRoles.every((requiredRole) => assignedRoleDefinitionIds.has(requiredRole))) {
return true;
}
}
// Check if all required roles are present
const hasAllRoles = requiredRoles.every((requiredRole) => assignedRoleDefinitionIds.has(requiredRole));
return hasAllRoles;
}
catch (error) {
// Handle authorization errors (403 Forbidden) - user lacks permissions
if (error &&
typeof error === "object" &&
"statusCode" in error &&
error.statusCode === 403) {
return false;
}
// Re-throw other errors
throw error;
}
}
async initialize(cloudAccount, { name, prefix }, tags = {}) {
assert.equal(cloudAccount.csp, "azure", "Cloud account must be Azure");
assert.ok(isAzureLocation(cloudAccount.defaultLocation), "The default location of the cloud account is not a valid Azure location");
const logger = getLogger(["gen", "env"]);
const resourceManagementClient = new ResourceManagementClient(this.#credential, cloudAccount.id);
const short = {
env: environmentShort[name],
location: locationShort[cloudAccount.defaultLocation],
};
const resourceGroupName = `${prefix}-${short.env}-${short.location}-bootstrap-rg-01`;
const parameters = {
location: cloudAccount.defaultLocation,
tags: {
Environment: name,
...tags,
},
};
await resourceManagementClient.resourceGroups.createOrUpdate(resourceGroupName, parameters);
logger.debug("Created resource group {resourceGroupName} in subscription {subscriptionId}", { resourceGroupName, subscriptionId: cloudAccount.id });
const msiClient = new ManagedServiceIdentityClient(this.#credential, cloudAccount.id);
const identityName = `${prefix}-${short.env}-${short.location}-bootstrap-id-01`;
await msiClient.userAssignedIdentities.createOrUpdate(resourceGroupName, identityName, parameters);
logger.debug("Created identity {identityName} in subscription {subscriptionId}", { identityName, subscriptionId: cloudAccount.id });
}
async isInitialized(cloudAccountId, { name, prefix }) {
const allLocations = Object.values(locationShort).join("|");
const shortEnv = environmentShort[name];
const resourceName = `${prefix}-${shortEnv}-(${allLocations})-bootstrap-id-(0[1-9]|[1-9]\\d)`;
const query = `resources
| where type == 'microsoft.managedidentity/userassignedidentities'
| where name matches regex @'${resourceName}'
`;
const result = await this.#resourceGraphClient.resources({
query,
subscriptions: [cloudAccountId],
});
const initialized = result.totalRecords > 0;
const logger = getLogger(["gen", "env"]);
logger.debug("subscription {subscriptionId} initialized: {initialized}", {
initialized,
subscriptionId: cloudAccountId,
});
return initialized;
}
async provisionTerraformBackend(cloudAccount, { name, prefix }, tags = {}) {
assert.equal(cloudAccount.csp, "azure", "Cloud account must be Azure");
assert.ok(isAzureLocation(cloudAccount.defaultLocation), "The default location of the cloud account is not a valid Azure location");
const logger = getLogger(["gen", "env"]);
const resourceManagementClient = new ResourceManagementClient(this.#credential, cloudAccount.id);
const short = {
env: environmentShort[name],
location: locationShort[cloudAccount.defaultLocation],
};
const parameters = {
location: cloudAccount.defaultLocation,
tags: {
Environment: name,
...tags,
},
};
const resourceGroupName = `${prefix}-${short.env}-${short.location}-tfstate-rg-01`;
await resourceManagementClient.resourceGroups.createOrUpdate(resourceGroupName, parameters);
logger.debug("Created resource group {resourceGroupName} in subscription {subscriptionId}", { resourceGroupName, subscriptionId: cloudAccount.id });
const storageManagementClient = new StorageManagementClient(this.#credential, cloudAccount.id);
const storageAccount = await storageManagementClient.storageAccounts.beginCreateAndWait(resourceGroupName, `${prefix}${short.env}${short.location}tfstatest01`, {
kind: "StorageV2",
sku: {
name: "Standard_LRS",
tier: "Standard",
},
...parameters,
});
assert.ok(storageAccount.primaryEndpoints?.blob, "Storage account blob endpoint is undefined");
assert.ok(storageAccount.name, "Storage account name is undefined");
logger.debug("Created storage account {storageAccountName} in subscription {subscriptionId}", {
storageAccountName: storageAccount.name,
subscriptionId: cloudAccount.id,
});
const blobServiceClient = new BlobServiceClient(storageAccount.primaryEndpoints?.blob, this.#credential);
const containerClient = blobServiceClient.getContainerClient("terraform-state");
try {
await containerClient.create();
}
catch (e) {
// Cleanup resource group if blob container creation fails
// resource group deletion also deletes all contained resources
await resourceManagementClient.resourceGroups.beginDeleteAndWait(resourceGroupName);
throw new Error(`Error during the creation of the blob container`, {
cause: e,
});
}
return terraformBackendSchema.parse({
resourceGroupName,
storageAccountName: storageAccount.name,
subscriptionId: cloudAccount.id,
type: "azurerm",
});
}
async #getCurrentPrincipalIds() {
// Create Graph client with custom auth provider that fetches fresh tokens
const graphClient = Client.init({
authProvider: async (done) => {
try {
const tokenResponse = await this.#credential.getToken("https://graph.microsoft.com/.default");
if (!tokenResponse) {
done(new Error("Failed to acquire token for Microsoft Graph"), null);
return;
}
done(null, tokenResponse.token);
}
catch (error) {
done(error, null);
}
},
});
// Get current user's info
const meResponse = await graphClient.api("/me").get();
const me = graphUserResponseSchema.parse(meResponse);
const userObjectId = me.id;
// Get all group memberships (transitive - includes nested groups)
const groupIds = [];
let nextLink = "/me/transitiveMemberOf?$select=id";
while (nextLink) {
const rawResponse = await graphClient.api(nextLink).get();
const response = graphGroupMembershipResponseSchema.parse(rawResponse);
for (const item of response.value) {
groupIds.push(item.id);
}
nextLink = response["@odata.nextLink"];
}
// All principal IDs to check (user + all groups)
const allPrincipalIds = new Set([userObjectId, ...groupIds]);
return allPrincipalIds;
}
}