-
Notifications
You must be signed in to change notification settings - Fork 902
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Enhance the IAM role binding process #4511
Changes from 2 commits
25cd034
1f0f79f
99ef5d7
3216dd1
ca6a614
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -115,6 +115,16 @@ export async function checkHttpIam( | |
logger.debug("[functions] found setIamPolicy permission, proceeding with deploy"); | ||
} | ||
|
||
/** obtain the pubsub service agent */ | ||
function getPubsubServiceAgent(projectNumber: string): string { | ||
return `serviceAccount:service-${projectNumber}@gcp-sa-pubsub.iam.gserviceaccount.com`; | ||
} | ||
|
||
/** obtain the default compute service agent */ | ||
function getDefaultComputeServiceAgent(projectNumber: string): string { | ||
return `serviceAccount:${projectNumber}-compute@developer.gserviceaccount.com`; | ||
} | ||
|
||
/** Callback reducer function */ | ||
function reduceEventsToServices(services: Array<Service>, endpoint: backend.Endpoint) { | ||
const service = serviceForEndpoint(endpoint); | ||
|
@@ -124,43 +134,18 @@ function reduceEventsToServices(services: Array<Service>, endpoint: backend.Endp | |
return services; | ||
} | ||
|
||
/** | ||
* Returns the IAM bindings that grants the role to the service account | ||
* @param existingPolicy the project level IAM policy | ||
* @param serviceAccount the IAM service account | ||
* @param role the role you want to grant | ||
* @return the correct IAM binding | ||
*/ | ||
export function obtainBinding( | ||
existingPolicy: iam.Policy, | ||
serviceAccount: string, | ||
role: string | ||
): iam.Binding { | ||
let binding = existingPolicy.bindings.find((b) => b.role === role); | ||
if (!binding) { | ||
binding = { | ||
role, | ||
members: [], | ||
}; | ||
} | ||
if (!binding.members.find((m) => m === serviceAccount)) { | ||
binding.members.push(serviceAccount); | ||
} | ||
return binding; | ||
} | ||
|
||
/** | ||
* Finds the required project level IAM bindings for the Pub/Sub service agent. | ||
* If the user enabled Pub/Sub on or before April 8, 2021, then we must enable the token creator role. | ||
* @param projectNumber project number | ||
* @param existingPolicy the project level IAM policy | ||
*/ | ||
export function obtainPubSubServiceAgentBindings( | ||
projectNumber: string, | ||
existingPolicy: iam.Policy | ||
): iam.Binding[] { | ||
const pubsubServiceAgent = `serviceAccount:service-${projectNumber}@gcp-sa-pubsub.iam.gserviceaccount.com`; | ||
return [obtainBinding(existingPolicy, pubsubServiceAgent, SERVICE_ACCOUNT_TOKEN_CREATOR_ROLE)]; | ||
export function obtainPubSubServiceAgentBindings(projectNumber: string): iam.Binding[] { | ||
const serviceAccountTokenCreatorBinding: iam.Binding = { | ||
role: SERVICE_ACCOUNT_TOKEN_CREATOR_ROLE, | ||
members: [getPubsubServiceAgent(projectNumber)], | ||
}; | ||
return [serviceAccountTokenCreatorBinding]; | ||
} | ||
|
||
/** | ||
|
@@ -169,57 +154,77 @@ export function obtainPubSubServiceAgentBindings( | |
* @param projectNumber project number | ||
* @param existingPolicy the project level IAM policy | ||
*/ | ||
export function obtainDefaultComputeServiceAgentBindings( | ||
projectNumber: string, | ||
existingPolicy: iam.Policy | ||
): iam.Binding[] { | ||
const defaultComputeServiceAgent = `serviceAccount:${projectNumber}-compute@developer.gserviceaccount.com`; | ||
const invokerBinding = obtainBinding( | ||
existingPolicy, | ||
defaultComputeServiceAgent, | ||
RUN_INVOKER_ROLE | ||
); | ||
const eventReceiverBinding = obtainBinding( | ||
existingPolicy, | ||
defaultComputeServiceAgent, | ||
EVENTARC_EVENT_RECEIVER_ROLE | ||
); | ||
return [invokerBinding, eventReceiverBinding]; | ||
export function obtainDefaultComputeServiceAgentBindings(projectNumber: string): iam.Binding[] { | ||
const defaultComputeServiceAgent = getDefaultComputeServiceAgent(projectNumber); | ||
const runInvokerBinding: iam.Binding = { | ||
role: RUN_INVOKER_ROLE, | ||
members: [defaultComputeServiceAgent], | ||
}; | ||
const eventarcEventReceiverBinding: iam.Binding = { | ||
role: EVENTARC_EVENT_RECEIVER_ROLE, | ||
members: [defaultComputeServiceAgent], | ||
}; | ||
return [runInvokerBinding, eventarcEventReceiverBinding]; | ||
} | ||
|
||
/** Helper to merge all required bindings into the IAM policy */ | ||
export function mergeBindings(policy: iam.Policy, allRequiredBindings: iam.Binding[][]) { | ||
for (const requiredBindings of allRequiredBindings) { | ||
if (requiredBindings.length === 0) { | ||
/** Helper to merge all required bindings into the IAM policy, returns boolean if the policy has been updated */ | ||
export function mergeBindings(policy: iam.Policy, requiredBindings: iam.Binding[]): boolean { | ||
let updated = false; | ||
for (const requiredBinding of requiredBindings) { | ||
const match = policy.bindings.find((b) => b.role === requiredBinding.role); | ||
if (!match) { | ||
updated = true; | ||
policy.bindings.push(requiredBinding); | ||
continue; | ||
} | ||
for (const requiredBinding of requiredBindings) { | ||
const ndx = policy.bindings.findIndex( | ||
(policyBinding) => policyBinding.role === requiredBinding.role | ||
); | ||
if (ndx === -1) { | ||
policy.bindings.push(requiredBinding); | ||
continue; | ||
for (const requiredMember of requiredBinding.members) { | ||
if (!match.members.find((m) => m === requiredMember)) { | ||
updated = true; | ||
match.members.push(requiredMember); | ||
} | ||
requiredBinding.members.forEach((updatedMember) => { | ||
if (!policy.bindings[ndx].members.find((member) => member === updatedMember)) { | ||
policy.bindings[ndx].members.push(updatedMember); | ||
} | ||
}); | ||
} | ||
} | ||
return updated; | ||
} | ||
|
||
/** Utility to print the required binding commands */ | ||
function printManualIamConfig(requiredBindings: iam.Binding[], projectId: string) { | ||
utils.logLabeledBullet( | ||
"functions", | ||
"Failed to verify the project has the correct IAM bindings for a successful deployment.", | ||
"warn" | ||
); | ||
utils.logLabeledBullet( | ||
"functions", | ||
"You can either re-run `firebase deploy` as a project owner or manually run the following set of `gcloud` commands:", | ||
"warn" | ||
); | ||
for (const binding of requiredBindings) { | ||
for (const member of binding.members) { | ||
utils.logLabeledBullet( | ||
"functions", | ||
`\`gcloud projects add-iam-policy-binding ${projectId} ` + | ||
`--member=${member} ` + | ||
`--role=${binding.role}\``, | ||
"warn" | ||
); | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Checks and sets the roles for specific resource service agents | ||
* @param projectId human readable project id | ||
* @param projectNumber project number | ||
* @param want backend that we want to deploy | ||
* @param have backend that we have currently deployed | ||
*/ | ||
export async function ensureServiceAgentRoles( | ||
projectId: string, | ||
projectNumber: string, | ||
want: backend.Backend, | ||
have: backend.Backend | ||
have: backend.Backend, | ||
skipSleep = false | ||
): Promise<void> { | ||
// find new services | ||
const wantServices = backend.allEndpoints(want).reduce(reduceEventsToServices, []); | ||
|
@@ -230,11 +235,31 @@ export async function ensureServiceAgentRoles( | |
if (newServices.length === 0) { | ||
return; | ||
} | ||
|
||
// obtain all the bindings we need to have active in the project | ||
const requiredBindingsPromises: Array<Promise<Array<iam.Binding>>> = []; | ||
for (const service of newServices) { | ||
requiredBindingsPromises.push(service.requiredProjectBindings!(projectNumber)); | ||
} | ||
const nestedRequiredBindings = await Promise.all(requiredBindingsPromises); | ||
const requiredBindings = nestedRequiredBindings.reduce((requiredBindings, binding) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this not just There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤯 🤯 🤯 just realizing that we have this module! |
||
requiredBindings.push(...binding); | ||
return requiredBindings; | ||
}, []); | ||
if (haveServices.length === 0) { | ||
requiredBindings.push(...obtainPubSubServiceAgentBindings(projectNumber)); | ||
requiredBindings.push(...obtainDefaultComputeServiceAgentBindings(projectNumber)); | ||
} | ||
if (requiredBindings.length === 0) { | ||
return; | ||
} | ||
|
||
// get the full project iam policy | ||
let policy: iam.Policy; | ||
try { | ||
policy = await getIamPolicy(projectNumber); | ||
} catch (err: any) { | ||
printManualIamConfig(requiredBindings, projectId); | ||
utils.logLabeledBullet( | ||
"functions", | ||
"Could not verify the necessary IAM configuration for the following newly-integrated services: " + | ||
|
@@ -244,29 +269,27 @@ export async function ensureServiceAgentRoles( | |
); | ||
return; | ||
} | ||
// run in parallel all the missingProjectBindings jobs | ||
const findRequiredBindings: Array<Promise<Array<iam.Binding>>> = []; | ||
newServices.forEach((service) => | ||
findRequiredBindings.push(service.requiredProjectBindings!(projectNumber, policy)) | ||
); | ||
const allRequiredBindings = await Promise.all(findRequiredBindings); | ||
if (haveServices.length === 0) { | ||
allRequiredBindings.push(obtainPubSubServiceAgentBindings(projectNumber, policy)); | ||
allRequiredBindings.push(obtainDefaultComputeServiceAgentBindings(projectNumber, policy)); | ||
} | ||
if (!allRequiredBindings.find((bindings) => bindings.length > 0)) { | ||
const hasUpdatedBindings = mergeBindings(policy, requiredBindings); | ||
if (!hasUpdatedBindings) { | ||
return; | ||
} | ||
mergeBindings(policy, allRequiredBindings); | ||
|
||
// set the updated policy | ||
try { | ||
await setIamPolicy(projectNumber, policy, "bindings"); | ||
} catch (err: any) { | ||
printManualIamConfig(requiredBindings, projectId); | ||
throw new FirebaseError( | ||
"We failed to modify the IAM policy for the project. The functions " + | ||
"deployment requires specific roles to be granted to service agents," + | ||
" otherwise the deployment will fail.", | ||
{ original: err } | ||
); | ||
} | ||
|
||
if (!skipSleep) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added this in for unit testing. After playing around with personal accounts, I opted to remove this sleep since we aren't guaranteed to have everything setup in this timeframe. Looking deeper, there are some corner cases where we must trigger eventarc through a |
||
// sleep for 60 seconds to give time for role grants & enablement to propagate | ||
utils.logLabeledBullet("functions", "Waiting for newly enabled services to catch up..."); | ||
await new Promise((r) => setTimeout(r, 60000)); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
import { bold } from "cli-color"; | ||
import { serviceUsageOrigin } from "../api"; | ||
import { Client } from "../apiv2"; | ||
import { FirebaseError } from "../error"; | ||
import * as utils from "../utils"; | ||
|
||
const apiClient = new Client({ | ||
urlPrefix: serviceUsageOrigin, | ||
apiVersion: "v1beta1", | ||
}); | ||
|
||
/** | ||
* Generate the service account for the service. Note: not every service uses the endpoint. | ||
* @param projectNumber gcp project number | ||
* @param service the service api (ex~ pubsub.googleapis.com) | ||
* @returns | ||
*/ | ||
export async function generateServiceIdentity( | ||
projectNumber: string, | ||
service: string, | ||
prefix: string | ||
) { | ||
utils.logLabeledBullet(prefix, `generating the service identity for ${bold(service)}...`); | ||
try { | ||
return await apiClient.post<unknown, unknown>( | ||
`projects/${projectNumber}/services/${service}:generateServiceIdentity` | ||
); | ||
} catch (err: unknown) { | ||
throw new FirebaseError(`Error generating the service identity for ${service}.`, { | ||
original: err as Error, | ||
}); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤯 dedication to DevX!