Skip to content
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

Add blocking trigger type #4395

Merged
merged 36 commits into from Apr 12, 2022
Merged

Add blocking trigger type #4395

merged 36 commits into from Apr 12, 2022

Conversation

colerogers
Copy link
Contributor

@colerogers colerogers commented Apr 3, 2022

Adds deployment code for blocking trigger types

firebase deploy --only functions

Requirements:

  • allows at most one blocking event type in a deployment
  • reduces the trigger options of all triggers to the OR of each trigger
  • does not overwrite identity platform options on function updates
  • does not overwrite identity platform URIs on delete if endpoint.uri !== resource uri
  • sets the invoker to allUsers on create & update

@colerogers colerogers requested review from taeold and inlined and removed request for taeold April 3, 2022 20:50
@colerogers colerogers marked this pull request as ready for review April 3, 2022 20:51
src/deploy/functions/backend.ts Outdated Show resolved Hide resolved
src/deploy/functions/release/fabricator.ts Show resolved Hide resolved
src/deploy/functions/release/fabricator.ts Outdated Show resolved Hide resolved
src/deploy/functions/release/fabricator.ts Outdated Show resolved Hide resolved
src/deploy/functions/release/fabricator.ts Outdated Show resolved Hide resolved
src/deploy/functions/backend.ts Show resolved Hide resolved
src/deploy/functions/prepare.ts Outdated Show resolved Hide resolved
Comment on lines 56 to 57
ensureTriggerIsValid: noop,
copyResourceOptionsToEndpoint: noop,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rant alert - i don't know how to improve this code, but something smells here.

All these noops are making me nervious whether this is a good abstraction.

It's starting to feel like we are stuffing any "do something if this kind of endpoint is given" into this code, and I'm not sure if this is making things clearer for me to understand.

I do like that we have one place to look, but things are starting to look too "abstract" e.g. copyResourceOptionsToEndpoint didn't really mean anything to me until I read the implementation for Auth blocking function, and I can't think of a reason why this would be relevant to say pubsub function in the future.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After moving the resource options code into it's own function, I was able to remove this Service function property. We're only left with ensureTriggerIsValid which seems like an okay function to have for every service.

src/deploy/functions/prepare.ts Outdated Show resolved Hide resolved
src/gcp/identityPlatform.ts Outdated Show resolved Hide resolved
Comment on lines 139 to 141
accessToken?: boolean;
idToken?: boolean;
refreshToken?: boolean;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have no suggestion on how to fix this, but I'm worried that this is not scalable. These should be blocking functions, not auth functions. Maybe we should have a map of features or something?

Copy link
Contributor Author

@colerogers colerogers Apr 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@inlined How about we go with a record structure for options and for auth blocking triggers we'd assume they'd use these three options. So the trigger will now be

export interface BlockingTrigger {
  eventType:
    | typeof events.v1.AUTH_BLOCKING_EVENTS[number]
    | typeof events.v2.AUTH_BLOCKING_EVENTS[number];
  options: Record<string, any>;
}

What do you think? The biggest thing is we'd lose out on is type completion in ts

src/deploy/functions/backend.ts Outdated Show resolved Hide resolved
@@ -271,3 +272,29 @@ function maybeCopyTriggerRegion(wantE: backend.Endpoint, haveE: backend.Endpoint
}
wantE.eventTrigger.region = haveE.eventTrigger.region;
}

/** Figures out the blocking endpoint options by taking the OR of every trigger option and reassigning that value back to the endpoint. */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this the correct behavior? If I have a beforeUserCreated function I must have the same security options as a beforeUserLoggedIn function?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's part of the trigger proposal - http://go/cf3-auth-blocking-triggers

Since these are resource level options, they must be the same. Instead of erroring if a user sets the beforeCreate & beforeSignIn options to different values, we just take the OR of them. This will need to be made very clear in our docs

src/deploy/functions/release/fabricator.ts Outdated Show resolved Hide resolved
// Blocking functions should always be public
await this.executor
.run(async () => {
await gcf.setInvokerCreate(endpoint.project, backend.functionName(endpoint), ["public"]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here and elsewhere, it's public because it's an auth blocking function, not because it's a blocking function. Please make the code forwards compatible to other blocking trigger types.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a check

if (backend.isBlockingTriggered(endpoint) && AUTH_BLOCKING_EVENTS.includes(endpoint.blockingTrigger.eventType)) { ... }

src/deploy/functions/validate.ts Outdated Show resolved Hide resolved
Comment on lines 12 to 16
export const BEFORE_CREATE_EVENT = "providers/cloud.auth/eventTypes/user.beforeCreate";

export const BEFORE_SIGN_IN_EVENT = "providers/cloud.auth/eventTypes/user.beforeSignIn";

export const AUTH_BLOCKING_EVENTS = [BEFORE_CREATE_EVENT, BEFORE_SIGN_IN_EVENT] as const;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IDK if I really love having this defined as a separate event type. I saw some places where only one type is handled and this also gets in the way of GCIP offering a new v2 api (not that they're super inclined to anyway).

In reality, events.v1 and events.v2 isn't about GCF versions as much as it is "eventflow" or "eventarc". It's OK that these event types will be handled by a v2 function and it's possible that we'll be forced to add support for all of events.v1 in v2 functions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you recommend just dropping v1 events entirely?

project: string,
blockingConfig: BlockingFunctionsConfig
): Promise<BlockingFunctionsConfig> {
const config =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Would this be easier if you just used proto.fieldMasks to create your field mask instead of setting it to "blockingFunctions"? Then you wouldn't need to merge both beforeCreate and beforeSignIn before setting it.

Copy link
Contributor Author

@colerogers colerogers Apr 6, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So funny thing about their API... if you try and use a field mask that has the inner property that changed, like blockingFunctions.triggers.beforeCreate.functionUri, it will not actually pickup the changes. That's why I went and set it to blockingFunctions

};
}

if (!update) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't we modify on updates? Is that not allowed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm preserving out-of-band resource changes. That seemed to fit with our current model, let me know if you disagree

src/test/deploy/functions/services/authBlocking.spec.ts Outdated Show resolved Hide resolved
Copy link
Member

@inlined inlined left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking really good! I'd personally make the trigger queue more narrowly scoped (e.g. inside auth) so we can parallelize as much as possible and it's not clear to me why we don't update options when the trigger is updated. Otherwise LGTM.

Comment on lines 563 to 567
this.triggerQueue = this.triggerQueue.then(async () => {
await this.executor
.run(() => services.serviceForEndpoint(endpoint).registerTrigger(endpoint, update))
.catch(rethrowAs(endpoint, "register blocking trigger"));
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't we make the triggerQueue be a feature of the Auth service? That way we run auth trigger updates in serial but can run other trigger updates in parallel.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying to avoid defining a class for the Auth Blocking service, but parallelizing makes a lot of sense here, I'll make the change

@@ -201,18 +201,9 @@ function parseEndpoints(
requireKeys(prefix + ".blockingTrigger", ep.blockingTrigger, "eventType");
assertKeyTypes(prefix + ".blockingTrigger", ep.blockingTrigger, {
eventType: "string",
accessToken: "boolean",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Depending how thorough you want to be, you can detect the eventType is an auth blocking event type and then do the assertKeyTypes for the inner options object

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ended up taking out the explicit eventType per your last review. The reason why I didn't compare each option is because other blockingTriggers will have a different set of options

@@ -83,9 +82,9 @@ export async function registerTrigger(

if (!update) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still don't think I understand why we only set these fields on function create instead of update.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying to preserve out-of-band changes, but I guess if we're re-registering the trigger on updates then we should probably re-register the options too

@colerogers colerogers requested a review from taeold April 12, 2022 18:39
@colerogers colerogers enabled auto-merge (squash) April 12, 2022 18:43
Copy link
Contributor

@taeold taeold left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Few nitpicks, but overall looks good.

@@ -3,6 +3,7 @@ import * as gcf from "../../gcp/cloudfunctions";
import * as gcfV2 from "../../gcp/cloudfunctionsv2";
import * as utils from "../../utils";
import * as runtimes from "./runtimes";
import * as events from "../../functions/events";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit* remove events per linter

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in #4443


/** Figures out the blocking endpoint options by taking the OR of every trigger option and reassigning that value back to the endpoint. */
export function inferBlockingDetails(want: backend.Backend): void {
const authBlockingEndpoints = backend
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit* technically blockingEndpoints 😛

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually here I do a check in filter to see if the eventType is an Auth Blocking Trigger event

endpoint: backend.Endpoint & backend.BlockingTriggered,
update: boolean
): Promise<void> {
this.triggerQueue = this.triggerQueue.then(async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: where do we await on this triggerQueue? I was expecting something to wait until the triggerQueue is cleared in the fabricator.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're checking out an old piece of code, but we're just adding the next task in the queue to the then function. When the current task is fulfilled, the next task will be executed.

@@ -68,6 +68,10 @@ export interface TriggerAnnotation {
};
invoker?: string[];
};
blockingTrigger?: {
eventType: string;
options?: Record<string, any>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit* I've been preferring unknown over any to have the caller assert the type as needed. Is doing so here going to cause a whole bunch of work?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #4443

ensureTriggerRegion: (ep: backend.Endpoint & backend.EventTriggered) => Promise<void>;
validateTrigger: (
ep: backend.Endpoint & backend.BlockingTriggered,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit* This seems unnecessarily strict - in theory, we expect other services to define validateTrigger no?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #4443

* @param ep the blocking endpoint
*/
registerTrigger(ep: backend.Endpoint & backend.BlockingTriggered): Promise<void> {
this.triggerQueue = this.triggerQueue.then(() => this.registerTriggerLocked(ep));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So.. this is ensuring that we don't have concurrent update auth policy. We can have at most 2 requests, but the second one would short-circuit b/c policy would not have been changed?

I think we could have the same effect by just keeping a variable that says "haveUpdated" or something to make sure we the update IAM policy function just once? But that's a small optimization to save 1 unneeded IAM policy call, so I think this impl is fine.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed Cole to this implementation. The problem is that we update functions concurrently and it's possible that we update the beforeCreate function at the same time as beforeSignIn. Since the process of updating involves reading a whole config, changing a field, and saving a whole config, it's possible that two racing updates will read the same original config, change one field, and only one write will win.

By using a promise chain we guarantee that the racing updates will happen sequentially. Think of it as running inside a mutex.

@@ -198,6 +199,101 @@ describe("validate", () => {
/they have fewer than 2GB memory/
);
});

it("Disallows multiple beforeCreate blocking", () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit - prefer lowercase disallow (same comment for tests below).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in #4443

beforeEach(() => {
getConfig = sinon
.stub(identityPlatform, "getBlockingFunctionsConfig")
.throws("Unexpected call to getBlockingFunctionsConfig");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit* for async functions, you'd use reject. I guess this works since await is forgiving 🤷🏼

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in #4443


await authBlockingService.registerTrigger(ep);

expect(blockingConfig).to.deep.equal(newBlockingConfig);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

huh I'm having a brain freeze, but how does this pass? blockingConfig and newBlockingConfig clearly looks different to me.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I think this is happening because we're mutating return values of blockingConfig? Which seems wrong here... Maybe setConfig should resolve w/o any return value (unless we depend on it) and we shoudl be verifying that setConfig was called with newBlockingConfig.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in #4443, we no longer check for a modification to the object

@colerogers colerogers merged commit 0ea9629 into master Apr 12, 2022
Comment on lines 96 to 99
newBlockingConfig.forwardInboundCredentials = {
idToken: endpoint.blockingTrigger.options?.idToken || false,
accessToken: endpoint.blockingTrigger.options?.accessToken || false,
refreshToken: endpoint.blockingTrigger.options?.refreshToken || false,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like your intention to preserve OOB changes. I think what I would do is say something like

newBlockingConfig.forwardInboundCredentials = { ...oldBlockingConfig.forwardInboundCredentials, ...endpoint.blockingTrigger.options }

This would keep options that were set out of band unless it was specified in the customer's code. The || false you have should generally be unnecessary because the API server will automatically fill zero values for any missing field.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in #4443

* @param ep the blocking endpoint
*/
registerTrigger(ep: backend.Endpoint & backend.BlockingTriggered): Promise<void> {
this.triggerQueue = this.triggerQueue.then(() => this.registerTriggerLocked(ep));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed Cole to this implementation. The problem is that we update functions concurrently and it's possible that we update the beforeCreate function at the same time as beforeSignIn. Since the process of updating involves reading a whole config, changing a field, and saving a whole config, it's possible that two racing updates will read the same original config, change one field, and only one write will win.

By using a promise chain we guarantee that the racing updates will happen sequentially. Think of it as running inside a mutex.

name: Name;
api: string;
triggerQueue: Promise<void>;
unregisterQueue: Promise<void>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: unused var

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in #4443

Comment on lines 84 to 91
"google.cloud.pubsub.topic.v1.messagePublished": PubSubService,
"google.cloud.storage.object.v1.finalized": StorageService,
"google.cloud.storage.object.v1.archived": StorageService,
"google.cloud.storage.object.v1.deleted": StorageService,
"google.cloud.storage.object.v1.metadataUpdated": StorageService,
"google.firebase.firebasealerts.alerts.v1.published": FirebaseAlertsService,
"providers/cloud.auth/eventTypes/user.beforeCreate": AuthBlockingService,
"providers/cloud.auth/eventTypes/user.beforeSignIn": AuthBlockingService,
"providers/cloud.auth/eventTypes/user.beforeCreate": authBlockingService,
"providers/cloud.auth/eventTypes/user.beforeSignIn": authBlockingService,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the capitalization here confuses me. Are these Classes or variables?


await authBlockingService.registerTrigger(ep);

expect(blockingConfig).to.deep.equal(newBlockingConfig);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I think this is happening because we're mutating return values of blockingConfig? Which seems wrong here... Maybe setConfig should resolve w/o any return value (unless we depend on it) and we shoudl be verifying that setConfig was called with newBlockingConfig.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants