diff --git a/etc/firebase-admin.data-connect.api.md b/etc/firebase-admin.data-connect.api.md index bf3f3b6b19..3650368734 100644 --- a/etc/firebase-admin.data-connect.api.md +++ b/etc/firebase-admin.data-connect.api.md @@ -6,6 +6,11 @@ import { Agent } from 'http'; +// Warning: (ae-forgotten-export) The symbol "DecodedIdToken" needs to be exported by the entry point index.d.ts +// +// @public +export type AuthClaims = Partial; + // @public export interface ConnectorConfig { location: string; @@ -36,8 +41,21 @@ export function getDataConnect(connectorConfig: ConnectorConfig, app?: App): Dat // @public export interface GraphqlOptions { + impersonate?: ImpersonateAuthenticated | ImpersonateUnauthenticated; operationName?: string; variables?: Variables; } +// @public +export interface ImpersonateAuthenticated { + authClaims: AuthClaims; + unauthenticated?: never; +} + +// @public +export interface ImpersonateUnauthenticated { + authClaims?: never; + unauthenticated: true; +} + ``` diff --git a/src/data-connect/data-connect-api-client-internal.ts b/src/data-connect/data-connect-api-client-internal.ts index 1f7b2da05c..b6082b91f5 100644 --- a/src/data-connect/data-connect-api-client-internal.ts +++ b/src/data-connect/data-connect-api-client-internal.ts @@ -110,6 +110,7 @@ export class DataConnectApiClient { query, ...(options?.variables && { variables: options?.variables }), ...(options?.operationName && { operationName: options?.operationName }), + ...(options?.impersonate && { extensions: { impersonate: options?.impersonate } }), }; return this.getUrl(API_VERSION, this.connectorConfig.location, this.connectorConfig.serviceId, endpoint) .then(async (url) => { diff --git a/src/data-connect/data-connect-api.ts b/src/data-connect/data-connect-api.ts index df997a1dbe..1073437032 100644 --- a/src/data-connect/data-connect-api.ts +++ b/src/data-connect/data-connect-api.ts @@ -15,6 +15,8 @@ * limitations under the License. */ +import { DecodedIdToken } from '../auth/token-verifier'; + /** * Interface representing a Data Connect connector configuration. */ @@ -53,4 +55,52 @@ export interface GraphqlOptions { * The name of the GraphQL operation. Required only if `query` contains multiple operations. */ operationName?: string; + + /** + * If set, impersonate a request with given Firebase Auth context and evaluate the auth + * policies on the operation. If omitted, bypass any defined auth policies. + */ + impersonate?: ImpersonateAuthenticated | ImpersonateUnauthenticated; +} + +/** + * Type representing the partial claims of a Firebase Auth token used to evaluate the + * Data Connect auth policy. + */ +export type AuthClaims = Partial; + +/** + * Interface representing the impersonation of an authenticated user. + */ +export interface ImpersonateAuthenticated { + /** + * Evaluate the auth policy with a customized JWT auth token. Should follow the Firebase Auth token format. + * https://firebase.google.com/docs/data-connect/cel-reference#auth-token-contents + * + * @example A verified user may have the following `authClaims`: + * ```json + * { "sub": "uid", "email_verified": true } + * ``` + */ + authClaims: AuthClaims; + + /** + * Both `authClaims` and `unauthenticated` are mutually exclusive fields and should not be both set. + */ + unauthenticated?: never; +} + +/** + * Interface representing the impersonation of an unauthenticated user. + */ +export interface ImpersonateUnauthenticated { + /** + * Both `authClaims` and `unauthenticated` are mutually exclusive fields and should not be both set. + */ + authClaims?: never; + + /** + * Evaluates the auth policy as an unauthenticated request. Can only be set to true. + */ + unauthenticated: true; } diff --git a/src/data-connect/index.ts b/src/data-connect/index.ts index 77b9ab1e38..ab262682f2 100644 --- a/src/data-connect/index.ts +++ b/src/data-connect/index.ts @@ -30,6 +30,9 @@ export { GraphqlOptions, ExecuteGraphqlResponse, ConnectorConfig, + ImpersonateAuthenticated, + ImpersonateUnauthenticated, + AuthClaims } from './data-connect-api' export { DataConnect, diff --git a/test/integration/data-connect.spec.ts b/test/integration/data-connect.spec.ts index 32ea83cb87..f9ced89ece 100644 --- a/test/integration/data-connect.spec.ts +++ b/test/integration/data-connect.spec.ts @@ -16,7 +16,7 @@ import * as chai from 'chai'; import * as chaiAsPromised from 'chai-as-promised'; -import { getDataConnect, ConnectorConfig } from '../../lib/data-connect/index'; +import { getDataConnect, ConnectorConfig, GraphqlOptions } from '../../lib/data-connect/index'; chai.should(); chai.use(chaiAsPromised); @@ -26,56 +26,59 @@ const expect = chai.expect; /* // Schema type User @table(key: "uid") { - uid: String! - name: String! - address: String! + uid: String! + name: String! + address: String! } +*/ +type User = { + uid?: string; + name?: string; + address?: string; + // Generated + emails_on_from?: Email[]; +}; +/* +// Schema type Email @table { - subject: String! - date: Date! - text: String! - from: User! + subject: String! + date: Date! + text: String! + from: User! } */ +type Email = { + subject?: string; + date?: string; + text?: string; + from?: User; + // Generated + id?: string; +}; interface UserResponse { - user: { - name: string; - uid: string; - } + user: User; } interface UsersResponse { - users: [ - user: { - id: string; - name: string; - address: string; - } - ]; + users: User[]; +} + +interface UserUpsertResponse { + user_upsert: { uid: string; }; } interface UserUpdateResponse { - user_upsert: { uid: string } + user_update: { uid: string; }; } interface EmailsResponse { - emails: [ - email: { - text: string; - subject: string; - id: string; - date: string; - from: { - name: string; - } - } - ]; + emails: Email[]; } interface UserVariables { - id: { uid: string }; + id: { uid: string; }; } const connectorConfig: ConnectorConfig = { @@ -90,17 +93,45 @@ describe('getDataConnect()', () => { const queryListUsers = 'query ListUsers @auth(level: PUBLIC) { users { uid, name, address } }'; const queryListEmails = 'query ListEmails @auth(level: NO_ACCESS) { emails { id subject text date from { name } } }'; const queryGetUserById = 'query GetUser($id: User_Key!) { user(key: $id) { uid name } }'; + + const queryListUsersImpersonation = ` + query ListUsers @auth(level: USER) { + users(where: { uid: { eq_expr: "auth.uid" } }) { uid, name, address } + }`; + const multipleQueries = ` ${queryListUsers} ${queryListEmails} `; + const mutation = `mutation user { user_insert(data: {uid: "${userId}", address: "32 St", name: "Fred Car"}) }`; + + const updateImpersonatedUser = ` + mutation UpdateUser @auth(level: USER) { + user_update(key: { uid_expr: "auth.uid" }, data: { address: "32 Elm St.", name: "Fredrick" }) + }`; + const upsertUser = `mutation UpsertUser($id: String) { user_upsert(data: { uid: $id, address: "32 St.", name: "Fred" }) }`; + const testUser = { + name: 'Fred', + address: '32 St.', + uid: userId + } + + const expectedUsers = [ + testUser, + { + name: 'Jeff', + address: '99 Oak St. N', + uid: 'QVBJcy5ndXJ1' + } + ]; + describe('executeGraphql()', () => { it('executeGraphql() successfully executes a GraphQL mutation', async () => { - const resp = await getDataConnect(connectorConfig).executeGraphql( + const resp = await getDataConnect(connectorConfig).executeGraphql( upsertUser, { variables: { id: userId } } ); //{ data: { user_insert: { uid: 'QVBJcy5ndXJ3' } } } @@ -111,18 +142,21 @@ describe('getDataConnect()', () => { it('executeGraphql() successfully executes a GraphQL', async () => { const resp = await getDataConnect(connectorConfig).executeGraphql(queryListUsers); expect(resp.data.users).to.be.not.empty; - expect(resp.data.users[0].name).to.be.not.undefined; - expect(resp.data.users[0].address).to.be.not.undefined; + expect(resp.data.users.length).to.be.greaterThan(1); + expectedUsers.forEach((expectedUser) => { + expect(resp.data.users).to.deep.include(expectedUser); + }); }); it('executeGraphql() use the operationName when multiple queries are provided', async () => { const resp = await getDataConnect(connectorConfig).executeGraphql( - multipleQueries, + multipleQueries, { operationName: 'ListEmails' } ); expect(resp.data.emails).to.be.not.empty; + expect(resp.data.emails.length).equals(1); expect(resp.data.emails[0].id).to.be.not.undefined; - expect(resp.data.emails[0].from.name).to.be.not.undefined; + expect(resp.data.emails[0].from?.name).to.equal('Jeff'); }); it('executeGraphql() should throw for a query error', async () => { @@ -135,8 +169,9 @@ describe('getDataConnect()', () => { queryGetUserById, { variables: { id: { uid: userId } } } ); - expect(resp.data.user.name).to.be.not.undefined; - expect(resp.data.user.uid).equals(userId); + expect(resp.data.user.uid).to.equal(testUser.uid); + expect(resp.data.user.name).to.equal(testUser.name); + expect(resp.data.user.address).to.be.undefined; }); }); @@ -145,13 +180,165 @@ describe('getDataConnect()', () => { const resp = await getDataConnect(connectorConfig).executeGraphqlRead(queryListUsers); expect(resp.data.users).to.be.not.empty; - expect(resp.data.users[0].name).to.be.not.undefined; - expect(resp.data.users[0].address).to.be.not.undefined; + expect(resp.data.users.length).to.be.greaterThan(1); + expectedUsers.forEach((expectedUser) => { + expect(resp.data.users).to.deep.include(expectedUser); + }); }); it('executeGraphqlRead() should throw for a GraphQL mutation', async () => { return getDataConnect(connectorConfig).executeGraphqlRead(mutation) - .should.eventually.be.rejected; + .should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + }); + }); + + describe('Impersonation', () => { + const optsAuthorizedClaims: GraphqlOptions = { + impersonate: { + authClaims: { + sub: userId, + email_verified: true + } + } + }; + + const optsNonExistingClaims: GraphqlOptions = { + impersonate: { + authClaims: { + sub: 'non-exisiting-id', + email_verified: true + } + } + }; + + const optsUnauthorizedClaims: GraphqlOptions = { + impersonate: { + unauthenticated: true + } + }; + + describe('USER Auth Policy', () => { + it('executeGraphqlRead() successfully executes an impersonated query with authenticated claims', async () => { + const resp = + await getDataConnect(connectorConfig).executeGraphqlRead( + queryListUsersImpersonation, optsAuthorizedClaims); + expect(resp.data.users).to.be.not.empty; + expect(resp.data.users.length).equals(1); + expect(resp.data.users[0]).to.deep.equal(testUser); + }); + + it('executeGraphqlRead() should throw for impersonated query with unauthenticated claims', async () => { + return getDataConnect(connectorConfig).executeGraphqlRead(queryListUsersImpersonation, optsUnauthorizedClaims) + .should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + }); + + it('executeGraphql() successfully executes an impersonated query with authenticated claims', async () => { + const resp = + await getDataConnect(connectorConfig).executeGraphqlRead( + queryListUsersImpersonation, optsAuthorizedClaims); + expect(resp.data.users).to.be.not.empty; + expect(resp.data.users.length).equals(1); + expect(resp.data.users[0]).to.deep.equal(testUser); + }); + + it('executeGraphql() should throw for impersonated query with unauthenticated claims', async () => { + return getDataConnect(connectorConfig).executeGraphql(queryListUsersImpersonation, optsUnauthorizedClaims) + .should.eventually.be.rejected.and.has.property('code', 'data-connect/unauthenticated'); + }); + + it('executeGraphql() should return an empty list for an impersonated query with non-existing authenticated ' + + 'claims', + async () => { + const resp = await getDataConnect(connectorConfig).executeGraphql( + queryListUsersImpersonation, optsNonExistingClaims); + // Should find no data + expect(resp.data.users).to.be.empty; + }); + + it('executeGraphql() successfully executes an impersonated mutation with authenticated claims', + async () => { + const resp = await getDataConnect(connectorConfig).executeGraphql( + updateImpersonatedUser, optsAuthorizedClaims); + // Fred -> Fredrick + expect(resp.data.user_update.uid).equals(userId); + }); + + it('executeGraphql() should throw for impersonated mutation with unauthenticated claims', async () => { + return getDataConnect(connectorConfig).executeGraphql(updateImpersonatedUser, optsUnauthorizedClaims) + .should.eventually.be.rejected.and.has.property('code', 'data-connect/unauthenticated'); + }); + + it('executeGraphql() should return null for an impersonated mutation with non-existing authenticated claims', + async () => { + const resp = await getDataConnect(connectorConfig).executeGraphql( + updateImpersonatedUser, optsNonExistingClaims); + // Should mutate no data + expect(resp.data.user_update).to.be.null; + }); + }); + + describe('PUBLIC Auth Policy', () => { + const expectedUsers = [ + { + name: 'Fredrick', + address: '32 Elm St.', + uid: userId + }, + { + name: 'Jeff', + address: '99 Oak St. N', + uid: 'QVBJcy5ndXJ1' + } + ]; + + it('executeGraphql() successfully executes an impersonated query with authenticated claims', async () => { + const resp = await getDataConnect(connectorConfig).executeGraphql( + queryListUsers, optsAuthorizedClaims); + expect(resp.data.users).to.be.not.empty; + expect(resp.data.users.length).to.be.greaterThan(1); + expectedUsers.forEach((expectedUser) => { + expect(resp.data.users).to.deep.include(expectedUser); + }) + }); + + it('executeGraphql() successfully executes an impersonated query with unauthenticated claims', async () => { + const resp = await getDataConnect(connectorConfig).executeGraphql( + queryListUsers, optsUnauthorizedClaims); + expect(resp.data.users).to.be.not.empty; + expect(resp.data.users.length).to.be.greaterThan(1); + expectedUsers.forEach((expectedUser) => { + expect(resp.data.users).to.deep.include(expectedUser); + }); + }); + + it('executeGraphql() successfully executes an impersonated query with non-existing authenticated claims', + async () => { + const resp = await getDataConnect(connectorConfig).executeGraphql( + queryListUsers, optsNonExistingClaims); + expect(resp.data.users).to.be.not.empty; + expect(resp.data.users.length).to.be.greaterThan(1); + expectedUsers.forEach((expectedUser) => { + expect(resp.data.users).to.deep.include(expectedUser); + }); + }); + }); + + describe('NO_ACCESS Auth Policy', () => { + it('executeGraphql() should throw for an impersonated query with authenticated claims', async () => { + return await getDataConnect(connectorConfig).executeGraphql(queryListEmails, optsAuthorizedClaims) + .should.eventually.be.rejected.and.has.property('code', 'data-connect/permission-denied'); + }); + + it('executeGraphql() should throw for an impersonated query with unauthenticated claims', async () => { + return await getDataConnect(connectorConfig).executeGraphql(queryListEmails, optsUnauthorizedClaims) + .should.eventually.be.rejected.and.has.property('code', 'data-connect/permission-denied'); + }); + + it('executeGraphql() should throw for an impersonated query with non-existing authenticated claims', + async () => { + return await getDataConnect(connectorConfig).executeGraphql(queryListEmails, optsNonExistingClaims) + .should.eventually.be.rejected.and.has.property('code', 'data-connect/permission-denied'); + }); }); }); });