-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Added deferred provisioning check for Storage and Authentication during extension install #3497
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
Changes from all commits
f5e4e95
e2a9d68
dd93757
a8a69c8
f869a5d
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 |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| import * as marked from "marked"; | ||
|
|
||
| import * as extensionsApi from "./extensionsApi"; | ||
| import * as api from "../api"; | ||
| import { FirebaseError } from "../error"; | ||
|
|
||
| /** Product for which provisioning can be (or is) deferred */ | ||
| export enum DeferredProduct { | ||
| STORAGE, | ||
| AUTH, | ||
| } | ||
|
|
||
| /** | ||
| * Checks whether products used by the extension require provisioning. | ||
| * | ||
| * @param spec extension spec | ||
| */ | ||
| export async function checkProductsProvisioned( | ||
| projectId: string, | ||
| spec: extensionsApi.ExtensionSpec | ||
| ): Promise<void> { | ||
| const usedProducts = getUsedProducts(spec); | ||
| const needProvisioning = [] as DeferredProduct[]; | ||
| let isStorageProvisionedPromise; | ||
|
Contributor
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. Optional: I feel like we can simplify this chunk a bit: IMO, this is a little more readable since we don't need to pass around promises quite as much
Collaborator
Author
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. This is actually on purpose. I want storage and auth checks to be running in parallel (if both are used), to speed up the check.
Contributor
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. That seems like a good enough reason to me! |
||
| let isAuthProvisionedPromise; | ||
| if (usedProducts.includes(DeferredProduct.STORAGE)) { | ||
| isStorageProvisionedPromise = isStorageProvisioned(projectId); | ||
| } | ||
| if (usedProducts.includes(DeferredProduct.AUTH)) { | ||
| isAuthProvisionedPromise = isAuthProvisioned(projectId); | ||
| } | ||
|
|
||
| if (isStorageProvisionedPromise && !(await isStorageProvisionedPromise)) { | ||
| needProvisioning.push(DeferredProduct.STORAGE); | ||
| } | ||
| if (isAuthProvisionedPromise && !(await isAuthProvisionedPromise)) { | ||
| needProvisioning.push(DeferredProduct.AUTH); | ||
| } | ||
|
|
||
| if (needProvisioning.length > 0) { | ||
| let errorMessage = | ||
| "Some services used by this extension have not been set up on your " + | ||
| "Firebase project. To ensure this extension works as intended, you must enable these " + | ||
| "services by following the provided links, then retry installing the extension\n\n"; | ||
| if (needProvisioning.includes(DeferredProduct.STORAGE)) { | ||
| errorMessage += | ||
| " - Firebase Storage: store and retrieve user-generated files like images, audio, and " + | ||
| "video without server-side code.\n"; | ||
| errorMessage += ` https://console.firebase.google.com/project/${projectId}/storage`; | ||
| errorMessage += "\n"; | ||
| } | ||
| if (needProvisioning.includes(DeferredProduct.AUTH)) { | ||
| errorMessage += | ||
| " - Firebase Authentication: authenticate and manage users from a variety of providers " + | ||
| "without server-side code.\n"; | ||
| errorMessage += ` https://console.firebase.google.com/project/${projectId}/authentication/users`; | ||
| } | ||
| throw new FirebaseError(marked(errorMessage), { exit: 2 }); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * From the spec determines which products are used by the extension and | ||
| * returns the list. | ||
| */ | ||
| export function getUsedProducts(spec: extensionsApi.ExtensionSpec): DeferredProduct[] { | ||
| const usedProducts: DeferredProduct[] = []; | ||
| const usedApis = spec.apis?.map((api) => api.apiName); | ||
| const usedRoles = spec.roles?.map((r) => r.role.split(".")[0]); | ||
| const usedTriggers = spec.resources.map((r) => getTriggerType(r.propertiesYaml)); | ||
| if ( | ||
| usedApis?.includes("storage-component.googleapis.com") || | ||
| usedRoles?.includes("storage") || | ||
| usedTriggers.find((t) => t?.startsWith("google.storage.")) | ||
| ) { | ||
| usedProducts.push(DeferredProduct.STORAGE); | ||
| } | ||
| if ( | ||
| usedApis?.includes("identitytoolkit.googleapis.com") || | ||
| usedRoles?.includes("firebaseauth") || | ||
| usedTriggers.find((t) => t?.startsWith("providers/firebase.auth/")) | ||
| ) { | ||
| usedProducts.push(DeferredProduct.AUTH); | ||
| } | ||
| return usedProducts; | ||
| } | ||
|
|
||
| /** | ||
| * Parses out trigger eventType from the propertiesYaml. | ||
| */ | ||
| function getTriggerType(propertiesYaml: string | undefined) { | ||
| return propertiesYaml?.match(/eventType:\ ([\S]+)/)?.[1]; | ||
| } | ||
|
|
||
| async function isStorageProvisioned(projectId: string): Promise<boolean> { | ||
| const resp = await api.request("GET", `/v1beta/projects/${projectId}/buckets`, { | ||
| auth: true, | ||
| origin: api.firebaseStorageOrigin, | ||
| }); | ||
| return !!resp.body?.buckets?.find((bucket: any) => { | ||
| const bucketResourceName = bucket.name; | ||
| // Bucket resource name looks like: projects/PROJECT_NUMBER/buckets/BUCKET_NAME | ||
| // and we just need the BUCKET_NAME part. | ||
| const bucketResourceNameTokens = bucketResourceName.split("/"); | ||
| const pattern = "^" + projectId + "(.[[a-z0-9]+)*.appspot.com$"; | ||
| return new RegExp(pattern).test(bucketResourceNameTokens[bucketResourceNameTokens.length - 1]); | ||
| }); | ||
| } | ||
|
|
||
| async function isAuthProvisioned(projectId: string): Promise<boolean> { | ||
| const resp = await api.request("GET", `/v1/projects/${projectId}/products`, { | ||
| auth: true, | ||
| origin: api.firedataOrigin, | ||
| }); | ||
| return !!resp.body?.activation?.map((a: any) => a.service).includes("FIREBASE_AUTH"); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,207 @@ | ||
| import * as nock from "nock"; | ||
| import { expect } from "chai"; | ||
|
|
||
| import * as api from "../../api"; | ||
joehan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| import * as provisioningHelper from "../../extensions/provisioningHelper"; | ||
| import * as extensionsApi from "../../extensions/extensionsApi"; | ||
| import { FirebaseError } from "../../error"; | ||
|
|
||
| const TEST_INSTANCES_RESPONSE = {}; | ||
| const PROJECT_ID = "test-project"; | ||
| const SPEC_WITH_STORAGE_AND_AUTH = { | ||
| apis: [ | ||
| { | ||
| apiName: "storage-component.googleapis.com", | ||
| }, | ||
| { | ||
| apiName: "identitytoolkit.googleapis.com", | ||
| }, | ||
| ] as extensionsApi.Api[], | ||
| resources: [] as extensionsApi.Resource[], | ||
| } as extensionsApi.ExtensionSpec; | ||
|
|
||
| const FIREDATA_AUTH_ACTIVATED_RESPONSE = { | ||
| activation: [ | ||
| { | ||
| service: "FIREBASE_AUTH", | ||
| }, | ||
| ], | ||
| }; | ||
|
|
||
| const FIREBASE_STORAGE_DEFAULT_BUCKET_LINKED_RESPONSE = { | ||
| buckets: [ | ||
| { | ||
| name: `projects/12345/bucket/${PROJECT_ID}.appspot.com`, | ||
| }, | ||
| ], | ||
| }; | ||
|
|
||
| describe("provisioningHelper", () => { | ||
| afterEach(() => { | ||
| nock.cleanAll(); | ||
| }); | ||
|
|
||
| describe("getUsedProducts", () => { | ||
| let testSpec: extensionsApi.ExtensionSpec; | ||
|
|
||
| beforeEach(() => { | ||
| testSpec = { | ||
| apis: [ | ||
| { | ||
| apiName: "unrelated.googleapis.com", | ||
| }, | ||
| ] as extensionsApi.Api[], | ||
| roles: [ | ||
| { | ||
| role: "unrelated.role", | ||
| }, | ||
| ] as extensionsApi.Role[], | ||
| resources: [ | ||
| { | ||
| propertiesYaml: | ||
| "availableMemoryMb: 1024\neventTrigger:\n eventType: providers/unrelates.service/eventTypes/something.do\n resource: projects/_/buckets/${param:IMG_BUCKET}\nlocation: ${param:LOCATION}\nruntime: nodejs10\n", | ||
| }, | ||
| ] as extensionsApi.Resource[], | ||
| } as extensionsApi.ExtensionSpec; | ||
| }); | ||
|
|
||
| it("returns empty array when nothing is used", () => { | ||
| expect(provisioningHelper.getUsedProducts(testSpec)).to.be.empty; | ||
| }); | ||
joehan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| it("returns STORAGE when Storage API is used", () => { | ||
| testSpec.apis?.push({ | ||
| apiName: "storage-component.googleapis.com", | ||
| reason: "whatever", | ||
| }); | ||
| expect(provisioningHelper.getUsedProducts(testSpec)).to.be.deep.eq([ | ||
| provisioningHelper.DeferredProduct.STORAGE, | ||
| ]); | ||
| }); | ||
|
|
||
| it("returns STORAGE when Storage Role is used", () => { | ||
| testSpec.roles?.push({ | ||
| role: "storage.object.admin", | ||
| reason: "whatever", | ||
| }); | ||
| expect(provisioningHelper.getUsedProducts(testSpec)).to.be.deep.eq([ | ||
| provisioningHelper.DeferredProduct.STORAGE, | ||
| ]); | ||
| }); | ||
|
|
||
| it("returns STORAGE when Storage trigger is used", () => { | ||
| testSpec.resources?.push({ | ||
| propertiesYaml: | ||
| "availableMemoryMb: 1024\neventTrigger:\n eventType: google.storage.object.finalize\n resource: projects/_/buckets/${param:IMG_BUCKET}\nlocation: ${param:LOCATION}\nruntime: nodejs10\n", | ||
| } as extensionsApi.Resource); | ||
| expect(provisioningHelper.getUsedProducts(testSpec)).to.be.deep.eq([ | ||
| provisioningHelper.DeferredProduct.STORAGE, | ||
| ]); | ||
| }); | ||
|
|
||
| it("returns AUTH when Authentication API is used", () => { | ||
| testSpec.apis?.push({ | ||
| apiName: "identitytoolkit.googleapis.com", | ||
| reason: "whatever", | ||
| }); | ||
| expect(provisioningHelper.getUsedProducts(testSpec)).to.be.deep.eq([ | ||
| provisioningHelper.DeferredProduct.AUTH, | ||
| ]); | ||
| }); | ||
|
|
||
| it("returns AUTH when Authentication Role is used", () => { | ||
| testSpec.roles?.push({ | ||
| role: "firebaseauth.user.admin", | ||
| reason: "whatever", | ||
| }); | ||
| expect(provisioningHelper.getUsedProducts(testSpec)).to.be.deep.eq([ | ||
| provisioningHelper.DeferredProduct.AUTH, | ||
| ]); | ||
| }); | ||
|
|
||
| it("returns AUTH when Auth trigger is used", () => { | ||
| testSpec.resources?.push({ | ||
| propertiesYaml: | ||
| "availableMemoryMb: 1024\neventTrigger:\n eventType: providers/firebase.auth/eventTypes/user.create\n resource: projects/_/buckets/${param:IMG_BUCKET}\nlocation: ${param:LOCATION}\nruntime: nodejs10\n", | ||
| } as extensionsApi.Resource); | ||
| expect(provisioningHelper.getUsedProducts(testSpec)).to.be.deep.eq([ | ||
| provisioningHelper.DeferredProduct.AUTH, | ||
| ]); | ||
| }); | ||
| }); | ||
|
|
||
| describe("checkProductsProvisioned", () => { | ||
| it("passes provisioning check status when nothing is used", async () => { | ||
| await expect( | ||
| provisioningHelper.checkProductsProvisioned(PROJECT_ID, { | ||
| resources: [] as extensionsApi.Resource[], | ||
| } as extensionsApi.ExtensionSpec) | ||
| ).to.be.fulfilled; | ||
| }); | ||
|
|
||
| it("passes provisioning check when all is provisioned", async () => { | ||
| nock(api.firedataOrigin) | ||
| .get(`/v1/projects/${PROJECT_ID}/products`) | ||
| .reply(200, FIREDATA_AUTH_ACTIVATED_RESPONSE); | ||
| nock(api.firebaseStorageOrigin) | ||
| .get(`/v1beta/projects/${PROJECT_ID}/buckets`) | ||
| .reply(200, FIREBASE_STORAGE_DEFAULT_BUCKET_LINKED_RESPONSE); | ||
|
|
||
| await expect( | ||
| provisioningHelper.checkProductsProvisioned(PROJECT_ID, SPEC_WITH_STORAGE_AND_AUTH) | ||
| ).to.be.fulfilled; | ||
|
|
||
| expect(nock.isDone()).to.be.true; | ||
| }); | ||
|
|
||
| it("fails provisioning check storage when default bucket is not linked", async () => { | ||
| nock(api.firedataOrigin) | ||
| .get(`/v1/projects/${PROJECT_ID}/products`) | ||
| .reply(200, FIREDATA_AUTH_ACTIVATED_RESPONSE); | ||
| nock(api.firebaseStorageOrigin) | ||
| .get(`/v1beta/projects/${PROJECT_ID}/buckets`) | ||
| .reply(200, { | ||
| buckets: [ | ||
| { | ||
| name: `projects/12345/bucket/some-other-bucket`, | ||
| }, | ||
| ], | ||
| }); | ||
|
|
||
| await expect( | ||
| provisioningHelper.checkProductsProvisioned(PROJECT_ID, SPEC_WITH_STORAGE_AND_AUTH) | ||
| ).to.be.rejectedWith(FirebaseError, "Firebase Storage: store and retrieve user-generated"); | ||
|
|
||
| expect(nock.isDone()).to.be.true; | ||
| }); | ||
|
|
||
| it("fails provisioning check storage when no firebase storage buckets", async () => { | ||
| nock(api.firedataOrigin) | ||
| .get(`/v1/projects/${PROJECT_ID}/products`) | ||
| .reply(200, FIREDATA_AUTH_ACTIVATED_RESPONSE); | ||
| nock(api.firebaseStorageOrigin).get(`/v1beta/projects/${PROJECT_ID}/buckets`).reply(200, {}); | ||
|
|
||
| await expect( | ||
| provisioningHelper.checkProductsProvisioned(PROJECT_ID, SPEC_WITH_STORAGE_AND_AUTH) | ||
| ).to.be.rejectedWith(FirebaseError, "Firebase Storage: store and retrieve user-generated"); | ||
|
|
||
| expect(nock.isDone()).to.be.true; | ||
| }); | ||
|
|
||
| it("fails provisioning check storage when no auth is not provisioned", async () => { | ||
| nock(api.firedataOrigin).get(`/v1/projects/${PROJECT_ID}/products`).reply(200, {}); | ||
| nock(api.firebaseStorageOrigin) | ||
| .get(`/v1beta/projects/${PROJECT_ID}/buckets`) | ||
| .reply(200, FIREBASE_STORAGE_DEFAULT_BUCKET_LINKED_RESPONSE); | ||
|
|
||
| await expect( | ||
| provisioningHelper.checkProductsProvisioned(PROJECT_ID, SPEC_WITH_STORAGE_AND_AUTH) | ||
| ).to.be.rejectedWith( | ||
| FirebaseError, | ||
| "Firebase Authentication: authenticate and manage users from" | ||
| ); | ||
|
|
||
| expect(nock.isDone()).to.be.true; | ||
| }); | ||
| }); | ||
| }); | ||
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.
Do we need this on ext:update as well? Its a corner case, but I could imagine it being relevant if a new version added a feature that required a new service to be provisioned
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.
Good point. Added.