From b5f20aa4cc3f7772ab3f123a3d3bb17e207aca4f Mon Sep 17 00:00:00 2001 From: Reinier Cruz Date: Tue, 26 Nov 2024 12:03:16 -0500 Subject: [PATCH 1/3] graph utility --- src/commands/utils/graph.ts | 231 ++++++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 src/commands/utils/graph.ts diff --git a/src/commands/utils/graph.ts b/src/commands/utils/graph.ts new file mode 100644 index 00000000..6ea03f3a --- /dev/null +++ b/src/commands/utils/graph.ts @@ -0,0 +1,231 @@ +import { AuthenticationProvider, Client } from "@microsoft/microsoft-graph-client"; +import { Errorable, getErrorMessage } from "./errorable"; +import { getDefaultScope, getEnvironment } from "../../auth/azureAuth"; +import { GetAuthSessionOptions, ReadyAzureSessionProvider } from "../../auth/types"; + +const federatedIdentityCredentialIssuer = "https://token.actions.githubusercontent.com"; +const federatedIdentityCredentialAudience = "api://AzureADTokenExchange"; + +type GraphListResult = { + value: T[]; +}; + +export type ApplicationParams = { + displayName: string; +}; + +export type Application = ApplicationParams & { + appId: string; + id: string; +}; + +export type ServicePrincipalParams = { + appId: string; + displayName?: string; +}; + +export type ServicePrincipal = ServicePrincipalParams & { + id: string; + displayName: string; +}; + +export type FederatedIdentityCredentialParams = { + name: string; + subject: string; + issuer: string; + description: string; + audiences: string[]; +}; + +export type FederatedIdentityCredential = FederatedIdentityCredentialParams & { + id: string; +}; + +export function createGraphClient(sessionProvider: ReadyAzureSessionProvider): Client { + // The "Visual Studio Code" application id. + // ClientID seen on auth login for azure sign-in on vscode. + // Referenced here in azure identity source code: https://github.com/Azure/azure-sdk-for-net/blob/bba9347edf324ec3731cb31d5600fd379a76a20c/sdk/identity/Azure.Identity/src/Credentials/VisualStudioCodeCredential.cs#L29 + const applicationClientId = "aebc6443-996d-45c2-90f0-388ff96faa56"; + + const baseUrl = getMicrosoftGraphClientBaseUrl(); + const authProvider: AuthenticationProvider = { + getAccessToken: async (options) => { + const authSessionOptions: GetAuthSessionOptions = { + scopes: options?.scopes || [getDefaultScope(baseUrl)], + applicationClientId, + }; + + const session = await sessionProvider.getAuthSession(authSessionOptions); + return session.succeeded ? session.result.accessToken : ""; + }, + }; + + return Client.initWithMiddleware({ baseUrl, authProvider }); +} + +function getMicrosoftGraphClientBaseUrl(): string { + const environment = getEnvironment(); + // Environments are from here: https://github.com/Azure/ms-rest-azure-env/blob/6fa17ce7f36741af6ce64461735e6c7c0125f0ed/lib/azureEnvironment.ts#L266-L346 + // They do not contain the MS Graph endpoints, whose values are here: + // https://github.com/microsoftgraph/msgraph-sdk-javascript/blob/d365ab1d68f90f2c38c67a5a7c7fe54acfc2584e/src/Constants.ts#L28 + switch (environment.name) { + case "AzureChinaCloud": + return "https://microsoftgraph.chinacloudapi.cn"; + case "AzureUSGovernment": + return "https://graph.microsoft.us"; + case "AzureGermanCloud": + return "https://graph.microsoft.de"; + } + return "https://graph.microsoft.com"; +} + +export async function getCurrentUserId(graphClient: Client): Promise> { + try { + const me = await graphClient.api("/me").get(); + return { succeeded: true, result: me.id }; + } catch (e) { + return { succeeded: false, error: getErrorMessage(e) }; + } +} + +export async function getOwnedApplications(graphClient: Client): Promise> { + try { + const appSearchResults: GraphListResult = await graphClient + .api("/me/ownedObjects/microsoft.graph.application") + .select(["id", "appId", "displayName"]) + .get(); + + return { succeeded: true, result: appSearchResults.value }; + } catch (e) { + return { succeeded: false, error: getErrorMessage(e) }; + } +} + +export async function createApplication(graphClient: Client, applicationName: string): Promise> { + const newApp: ApplicationParams = { + displayName: applicationName, + }; + + try { + const application: Application = await graphClient.api("/applications").post(newApp); + + return { succeeded: true, result: application }; + } catch (e) { + return { succeeded: false, error: getErrorMessage(e) }; + } +} + +export async function deleteApplication(graphClient: Client, applicationObjectId: string): Promise> { + try { + await graphClient.api(`/applications/${applicationObjectId}`).delete(); + return { succeeded: true, result: undefined }; + } catch (e) { + return { succeeded: false, error: getErrorMessage(e) }; + } +} + +export async function createServicePrincipal( + graphClient: Client, + applicationId: string, +): Promise> { + const newServicePrincipal: ServicePrincipalParams = { + appId: applicationId, + }; + + try { + const servicePrincipal: ServicePrincipal = await graphClient + .api("/servicePrincipals") + .post(newServicePrincipal); + return { succeeded: true, result: servicePrincipal }; + } catch (e) { + return { succeeded: false, error: getErrorMessage(e) }; + } +} + +export async function getServicePrincipalsForApp( + graphClient: Client, + appId: string, +): Promise> { + try { + const spSearchResults: GraphListResult = await graphClient + .api("/servicePrincipals") + .filter(`appId eq '${appId}'`) + .get(); + + return { succeeded: true, result: spSearchResults.value }; + } catch (e) { + return { succeeded: false, error: getErrorMessage(e) }; + } +} + +export async function getFederatedIdentityCredentials( + graphClient: Client, + applicationId: string, +): Promise> { + try { + const identityResults: GraphListResult = await graphClient + .api(`/applications/${applicationId}/federatedIdentityCredentials`) + .get(); + + return { succeeded: true, result: identityResults.value }; + } catch (e) { + return { succeeded: false, error: getErrorMessage(e) }; + } +} + +export async function createFederatedIdentityCredential( + graphClient: Client, + applicationId: string, + subject: string, + name: string, + description: string, +): Promise> { + const newCred: FederatedIdentityCredentialParams = { + name, + subject, + issuer: federatedIdentityCredentialIssuer, + description, + audiences: [federatedIdentityCredentialAudience], + }; + + try { + const cred: FederatedIdentityCredential = await graphClient + .api(`/applications/${applicationId}/federatedIdentityCredentials`) + .post(newCred); + + return { succeeded: true, result: cred }; + } catch (e) { + return { succeeded: false, error: getErrorMessage(e) }; + } +} ////////TODO: catch fail logic if the same cred already exists + +export async function createGitHubActionFederatedIdentityCredential( + graphClient: Client, + applicationId: string, + organization: string, + repository: string, + branch: string, +): Promise> { + const subject = `repo:${organization}/${repository}:ref:refs/heads/${branch}`; + return createFederatedIdentityCredential(graphClient, applicationId, subject, "gitHub_actions", ""); +} + +export async function deleteFederatedIdentityCredential( + graphClient: Client, + applicationId: string, + credId: string, +): Promise> { + try { + await graphClient.api(`/applications/${applicationId}/federatedIdentityCredentials/${credId}`).delete(); + return { succeeded: true, result: undefined }; + } catch (e) { + return { succeeded: false, error: getErrorMessage(e) }; + } +} + +export function findFederatedIdentityCredential( + subject: string, + creds: FederatedIdentityCredential[], +): FederatedIdentityCredential | undefined { + return creds.find((c) => c.subject === subject && c.issuer === federatedIdentityCredentialIssuer); +} From 57a1464590e01e36f8a121262a0aa4f3f8e7093c Mon Sep 17 00:00:00 2001 From: Reinier Cruz Date: Tue, 26 Nov 2024 12:07:47 -0500 Subject: [PATCH 2/3] create and delete acr --- src/commands/utils/acrs.ts | 48 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/commands/utils/acrs.ts b/src/commands/utils/acrs.ts index ff821bb1..d87c9533 100644 --- a/src/commands/utils/acrs.ts +++ b/src/commands/utils/acrs.ts @@ -61,6 +61,54 @@ export async function getRepositoryTags( return errmap(propsResult, (props) => props.flatMap((p) => p.tags)); } +export async function createAcr( //TODO: proper name input checking + sessionProvider: ReadyAzureSessionProvider, + subscriptionId: string, + resourceGroup: string, + acrName: string, + location: string, +): Promise> { + const client = getAcrManagementClient(sessionProvider, subscriptionId); + try { + const registry = await client.registries.beginCreateAndWait(resourceGroup, acrName, { + location, + sku: { + name: "Basic", //As quoted by azure doc, Basic SKU is the "cost optimized entry point for developers": https://learn.microsoft.com/en-us/azure/container-registry/container-registry-skus + }, //Future: Can provide users the ability to select their desired SKU + }); + if (isDefinedRegistry(registry)) { + return { succeeded: true, result: registry }; + } + return { + succeeded: false, + error: `Failed to create Azure Container Registry (ACR) "${acrName}" in resource group "${resourceGroup}" under subscription "${subscriptionId}".`, + }; + } catch (error) { + return { + succeeded: false, + error: `An error occurred while creating ACR "${acrName}" in resource group "${resourceGroup}" under subscription "${subscriptionId}": ${getErrorMessage(error)}`, + }; + } +} + +export async function deleteAcr( + sessionProvider: ReadyAzureSessionProvider, + subscriptionId: string, + resourceGroup: string, + acrName: string, +): Promise> { + const client = getAcrManagementClient(sessionProvider, subscriptionId); + try { + await client.registries.beginDeleteAndWait(resourceGroup, acrName); + return { succeeded: true, result: undefined }; + } catch (error) { + return { + succeeded: false, + error: `An error occurred while deleting ACR "${acrName}" in resource group "${resourceGroup}" under subscription "${subscriptionId}": ${getErrorMessage(error)}`, + }; + } +} + function isDefinedRegistry(rg: Registry): rg is DefinedRegistry { return rg.id !== undefined && rg.name !== undefined && rg.location !== undefined && rg.loginServer !== undefined; } From d741c63edc9948497297567f7df04c81a7682515 Mon Sep 17 00:00:00 2001 From: Reinier Cruz Date: Tue, 26 Nov 2024 12:17:28 -0500 Subject: [PATCH 3/3] role assignment arm id with doc reference --- src/commands/utils/roleAssignments.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/commands/utils/roleAssignments.ts b/src/commands/utils/roleAssignments.ts index ed4cf1a1..34c967ee 100644 --- a/src/commands/utils/roleAssignments.ts +++ b/src/commands/utils/roleAssignments.ts @@ -23,9 +23,16 @@ export function getPrincipalRoleAssignmentsForAcr( export function getScopeForAcr(subscriptionId: string, resourceGroup: string, acrName: string): string { // ARM resource ID for ACR + // Doc reference: https://learn.microsoft.com/en-us/azure/templates/microsoft.containerregistry/registries?pivots=deployment-language-arm-template#resource-format-1 return `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/${acrProvider}/registries/${acrName}`; } +export function getScopeForCluster(subscriptionId: string, resourceGroup: string, clusterName: string): string { + // ARM resource ID for AKS + // Doc reference: https://learn.microsoft.com/en-us/azure/templates/microsoft.containerservice/managedclusters?pivots=deployment-language-arm-template#resource-format-1 + return `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.ContainerService/managedClusters/${clusterName}`; +} + // There are several permitted principal types, see: https://learn.microsoft.com/en-us/rest/api/authorization/role-assignments/create?view=rest-authorization-2022-04-01&tabs=HTTP#principaltype // For now, 'ServicePrincipal' and 'User' are the ones we're most likely to use here, // but we can add more as needed.