diff --git a/packages/amplify-graphql-api-construct/src/__tests__/__functional__/iam-custom-operations.test.ts b/packages/amplify-graphql-api-construct/src/__tests__/__functional__/iam-custom-operations.test.ts new file mode 100644 index 0000000000..603564a36a --- /dev/null +++ b/packages/amplify-graphql-api-construct/src/__tests__/__functional__/iam-custom-operations.test.ts @@ -0,0 +1,240 @@ +import * as cdk from 'aws-cdk-lib'; +import { Template } from 'aws-cdk-lib/assertions'; +import * as cognito from 'aws-cdk-lib/aws-cognito'; +import { AccountPrincipal, Effect, PolicyDocument, PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; +import { AmplifyGraphqlApi } from '../../amplify-graphql-api'; +import { AmplifyGraphqlDefinition } from '../../amplify-graphql-definition'; + +/** + * This tests the CDK construct interface for enabling IAM authorization mode by default for Gen 2 and ensures that it properly applies to + * custom operations. Further, it asserts that the policies created by the transformers are properly scoped, and never include references to + * the custom operations. This is especially important for configurations that include a Cognito Identity Pool--the Identity Pool auth and + * unauth roles should not be granted access to the custom operations unless explicitly allowed. + */ +describe('Custom operations have @aws_iam directives when enableIamAuthorizationMode is true', () => { + it('Correctly scopes policies when a user pool is present', () => { + const stack = new cdk.Stack(); + + const userPool = cognito.UserPool.fromUserPoolId(stack, 'ImportedUserPool', 'ImportedUserPoolId'); + + new AmplifyGraphqlApi(stack, 'TestApi', { + definition: AmplifyGraphqlDefinition.fromString(/* GraphQL */ ` + type Todo @model @auth(rules: [{ provider: userPools, allow: owner }]) { + description: String! + } + type Query { + getFooCustom: String + } + type Mutation { + updateFooCustom: String + } + type Subscription { + onUpdateFooCustom: String @aws_subscribe(mutations: ["updateFooCustom"]) + } + `), + authorizationModes: { + defaultAuthorizationMode: 'AMAZON_COGNITO_USER_POOLS', + userPoolConfig: { userPool }, + iamConfig: { enableIamAuthorizationMode: true }, + }, + }); + + const template = Template.fromStack(stack); + + // We do not expect any policy statements relating to the GraphQL API, since no policy was created by the customer. We'll do a string + // match across all roles & policies as a brute-force way of ensuring that nothing is granting access to the custom operations. We + // search policies directly, and also roles to make sure we get inline policies attached to the roles. + const allRoles = template.findResources('AWS::IAM::Role'); + const allRolesString = JSON.stringify(allRoles); + expect(allRolesString).not.toContain('appsync:GraphQL'); + + const allPolicies = template.findResources('AWS::IAM::Policy'); + const allPoliciesString = JSON.stringify(allPolicies); + expect(allPoliciesString).not.toContain('appsync:GraphQL'); + + // There should be no managed policies at all since we didn't include an Identity Pool + const allManagedPolicies = template.findResources('AWS::IAM::ManagedPolicy'); + expect(allManagedPolicies).toStrictEqual({}); + }); + + it('Correctly handles no-model cases', () => { + const stack = new cdk.Stack(); + + new AmplifyGraphqlApi(stack, 'TestApi', { + definition: AmplifyGraphqlDefinition.fromString(/* GraphQL */ ` + type Query { + getFooCustom: String + } + type Mutation { + updateFooCustom: String + } + type Subscription { + onUpdateFooCustom: String @aws_subscribe(mutations: ["updateFooCustom"]) + } + `), + authorizationModes: { + iamConfig: { enableIamAuthorizationMode: true }, + }, + }); + + const template = Template.fromStack(stack); + + // We do not expect any policy statements relating to the GraphQL API, since no policy was created by the customer. We'll do a string + // match across all roles & policies as a brute-force way of ensuring that nothing is granting access to the custom operations. We + // search policies directly, and also roles to make sure we get inline policies attached to the roles. + const allRoles = template.findResources('AWS::IAM::Role'); + const allRolesString = JSON.stringify(allRoles); + expect(allRolesString).not.toContain('appsync:GraphQL'); + + const allPolicies = template.findResources('AWS::IAM::Policy'); + const allPoliciesString = JSON.stringify(allPolicies); + expect(allPoliciesString).not.toContain('appsync:GraphQL'); + + // There should be no managed policies at all since we didn't include an Identity Pool + const allManagedPolicies = template.findResources('AWS::IAM::ManagedPolicy'); + expect(allManagedPolicies).toStrictEqual({}); + }); + + it('Respects customer-provided access policies', () => { + const stack = new cdk.Stack(); + + const api = new AmplifyGraphqlApi(stack, 'TestApi', { + definition: AmplifyGraphqlDefinition.fromString(/* GraphQL */ ` + type Query { + getFooCustom: String + } + type Mutation { + updateFooCustom: String + } + type Subscription { + onUpdateFooCustom: String @aws_subscribe(mutations: ["updateFooCustom"]) + } + `), + authorizationModes: { + iamConfig: { enableIamAuthorizationMode: true }, + }, + }); + + new Role(stack, 'TestRole', { + assumedBy: new AccountPrincipal('123456789012'), + roleName: 'TestCustomOperationAccessRole', + inlinePolicies: { + TestCustomOperationAccessPolicy: new PolicyDocument({ + statements: [ + new PolicyStatement({ + actions: ['appsync:GraphQL'], + resources: [`${api.resources.graphqlApi.arn}/types/Mutation/fields/updateFooCustom`], + effect: Effect.ALLOW, + }), + ], + }), + }, + }); + + const template = Template.fromStack(stack); + + // Rather than a string match across all roles, we'll ensure that access to the custom mutation comes only in the customer-provided role + const allRoles = template.findResources('AWS::IAM::Role'); + + const customerRoleKeys = Object.keys(allRoles).filter((key) => key.startsWith('TestRole')); + expect(customerRoleKeys.length).toEqual(1); + + const customerRole = allRoles[customerRoleKeys[0]]; + const customerRoleString = JSON.stringify(customerRole); + + const allRolesExceptCustomer = JSON.parse(JSON.stringify(allRoles)); + delete allRolesExceptCustomer[customerRoleKeys[0]]; + + const allRolesExceptCustomerString = JSON.stringify(allRoles); + + // No role or policy should contain a permission for getFooCustom, since it wasn't explicitly granted + expect(allRolesExceptCustomerString).not.toContain('getFooCustom'); + expect(customerRoleString).not.toContain('getFooCustom'); + + // The customer role should contain a permission for updateFooCustom as specified in the inline policy above + expect(customerRoleString).toContain('appsync:GraphQL'); + expect(customerRoleString).toContain('/types/Mutation/fields/updateFooCustom'); + + // No standalone policy resource should contain a permission to the API + const allPolicies = template.findResources('AWS::IAM::Policy'); + const allPoliciesString = JSON.stringify(allPolicies); + expect(allPoliciesString).not.toContain('appsync:GraphQL'); + + // There should be no managed policies at all since we didn't include an Identity Pool + const allManagedPolicies = template.findResources('AWS::IAM::ManagedPolicy'); + expect(allManagedPolicies).toStrictEqual({}); + }); + + it('Correctly scopes when an identity pool is present', () => { + const stack = new cdk.Stack(); + + const userPool = cognito.UserPool.fromUserPoolId(stack, 'ImportedUserPool', 'ImportedUserPoolId'); + const userPoolClient = userPool.addClient('TestClient'); + + const identityPool = new cognito.CfnIdentityPool(stack, 'TestIdentityPool', { + allowUnauthenticatedIdentities: true, + cognitoIdentityProviders: [ + { + clientId: userPoolClient.userPoolClientId, + providerName: 'Amazon Cognito', + }, + ], + }); + const appsync = new ServicePrincipal('appsync.amazonaws.com'); + const authenticatedUserRole = new Role(stack, 'AuthRole', { assumedBy: appsync }); + const unauthenticatedUserRole = new Role(stack, 'UnauthRole', { assumedBy: appsync }); + + new AmplifyGraphqlApi(stack, 'TestApi', { + definition: AmplifyGraphqlDefinition.fromString(/* GraphQL */ ` + type Todo @model @auth(rules: [{ provider: identityPool, allow: private }]) { + description: String! + } + type Query { + getFooCustom: String + } + type Mutation { + updateFooCustom: String + } + type Subscription { + onUpdateFooCustom: String @aws_subscribe(mutations: ["updateFooCustom"]) + } + `), + authorizationModes: { + defaultAuthorizationMode: 'AMAZON_COGNITO_USER_POOLS', + userPoolConfig: { userPool }, + iamConfig: { enableIamAuthorizationMode: true }, + identityPoolConfig: { + identityPoolId: identityPool.attrId, + authenticatedUserRole, + unauthenticatedUserRole, + }, + }, + }); + + const template = Template.fromStack(stack); + + const allRoles = template.findResources('AWS::IAM::Role'); + const allRolesString = JSON.stringify(allRoles); + expect(allRolesString).not.toContain('appsync:GraphQL'); + + const allPolicies = template.findResources('AWS::IAM::Policy'); + const allPoliciesString = JSON.stringify(allPolicies); + expect(allPoliciesString).not.toContain('appsync:GraphQL'); + + // The managed policy for the identity pool should allow access to only the models, but not to the custom operations + const allManagedPolicies = template.findResources('AWS::IAM::ManagedPolicy'); + const allManagedPoliciesString = JSON.stringify(allManagedPolicies); + expect(allManagedPoliciesString).toContain('appsync:GraphQL'); + expect(allManagedPoliciesString).toContain('getTodo'); + expect(allManagedPoliciesString).toContain('listTodos'); + expect(allManagedPoliciesString).toContain('createTodo'); + expect(allManagedPoliciesString).toContain('updateTodo'); + expect(allManagedPoliciesString).toContain('deleteTodo'); + expect(allManagedPoliciesString).toContain('onCreateTodo'); + expect(allManagedPoliciesString).toContain('onUpdateTodo'); + expect(allManagedPoliciesString).toContain('onDeleteTodo'); + expect(allManagedPoliciesString).not.toContain('getFooCustom'); + expect(allManagedPoliciesString).not.toContain('updateFooCustom'); + expect(allManagedPoliciesString).not.toContain('onUpdateFooCustom'); + }); +}); diff --git a/packages/amplify-graphql-api-construct/src/__tests__/index-import.test.ts b/packages/amplify-graphql-api-construct/src/__tests__/index-import.test.ts new file mode 100644 index 0000000000..550b94cf82 --- /dev/null +++ b/packages/amplify-graphql-api-construct/src/__tests__/index-import.test.ts @@ -0,0 +1,9 @@ +// This exists only to provide coverage metrics for various export files + +import * as src from '../index'; +import * as types from '../types'; + +test('Work around coverage metrics for export and type-only files', () => { + expect(src).toBeDefined(); + expect(types).toBeDefined(); +}); diff --git a/packages/amplify-graphql-api-construct/src/__tests__/sql-model-datasource-strategy.test.ts b/packages/amplify-graphql-api-construct/src/__tests__/sql-model-datasource-strategy.test.ts new file mode 100644 index 0000000000..4465311d0c --- /dev/null +++ b/packages/amplify-graphql-api-construct/src/__tests__/sql-model-datasource-strategy.test.ts @@ -0,0 +1,193 @@ +/* eslint-disable max-classes-per-file */ +import { isSQLLambdaModelDataSourceStrategy, isSqlModelDataSourceDbConnectionConfig } from '../sql-model-datasource-strategy'; + +/** Mock to test that isSqlLambdaModelDataSourceStrategy recognizes function types */ +class MockFunctionStrategy { + dbType: string; + name: string; + dbConnectionConfig: any; + + constructor() { + this.dbType = 'MYSQL'; + this.name = 'test'; + this.dbConnectionConfig = new MockConnectionConfig('path'); + } +} + +class MockConnectionConfig { + connectionUriSsmPath: string | string[] | undefined; + constructor(path: string | string[] | undefined) { + this.connectionUriSsmPath = path; + } +} + +describe('sql-model-datasource-strategy utilities', () => { + describe('isSQLLambdaModelDataSourceStrategy', () => { + test.each([ + { + label: 'accepts an object type', + expected: true, + candidateObject: { + dbType: 'MYSQL', + name: 'test', + dbConnectionConfig: { + connectionUriSsmPath: '/path', + }, + }, + }, + + { + label: 'accepts a postgres database type', + expected: true, + candidateObject: { + dbType: 'POSTGRES', + name: 'test', + dbConnectionConfig: { + connectionUriSsmPath: '/path', + }, + }, + }, + + { + label: 'accepts a function type', + expected: true, + candidateObject: new MockFunctionStrategy(), + }, + + { + label: 'rejects an unknown db type', + expected: false, + candidateObject: { + dbType: 'ZZZZZ', + name: 'test', + dbConnectionConfig: { + connectionUriSsmPath: '/path', + }, + }, + }, + + { + label: 'rejects an unknown connection config', + expected: false, + candidateObject: { + dbType: 'POSTGRES', + name: 'test', + dbConnectionConfig: { + foo: false, + }, + }, + }, + + { + label: 'rejects a missing name', + expected: false, + candidateObject: { + dbType: 'POSTGRES', + dbConnectionConfig: { + connectionUriSsmPath: '/path', + }, + }, + }, + + { + label: 'rejects a non-object, non-function value', + expected: false, + candidateObject: 123, + }, + + { + label: 'rejects an undefined value', + expected: false, + candidateObject: 123, + }, + ])('$label', ({ candidateObject, expected }) => { + expect(isSQLLambdaModelDataSourceStrategy(candidateObject)).toEqual(expected); + }); + }); + + describe('isSqlModelDataSourceDbConnectionConfig', () => { + test.each([ + { + label: 'accepts an SSM connection URI config with a single value', + expected: true, + candidateObject: { + connectionUriSsmPath: '/path', + }, + }, + + { + label: 'accepts an SSM connection URI config with an array value', + expected: true, + candidateObject: { + connectionUriSsmPath: ['/path1', '/path2'], + }, + }, + + { + label: 'accepts a function with a single value', + expected: true, + candidateObject: new MockConnectionConfig('/path1'), + }, + + { + label: 'accepts a function with an array value', + expected: true, + candidateObject: new MockConnectionConfig(['/path1', '/path2']), + }, + + { + label: 'rejects a function with a missing value', + expected: false, + candidateObject: new MockConnectionConfig(undefined), + }, + + { + label: 'rejects a non-object, non-function value', + expected: false, + candidateObject: 123, + }, + + { + label: 'rejects an undefined value', + expected: false, + candidateObject: 123, + }, + + { + label: 'accepts an SSM individual parameter config', + expected: true, + candidateObject: { + hostnameSsmPath: '/hostnameSsmPath', + portSsmPath: '/portSsmPath', + usernameSsmPath: '/usernameSsmPath', + passwordSsmPath: '/passwordSsmPath', + databaseNameSsmPath: '/databaseNameSsmPath', + }, + }, + + { + label: 'rejects an SSM individual parameter config with a missing value', + expected: false, + candidateObject: { + portSsmPath: '/portSsmPath', + usernameSsmPath: '/usernameSsmPath', + passwordSsmPath: '/passwordSsmPath', + databaseNameSsmPath: '/databaseNameSsmPath', + }, + }, + + { + label: 'accepts an secrets manager config', + expected: true, + candidateObject: { + secretArn: 'arn:aws:secretsmanager:::secret', + port: 1234, + databaseName: 'databaseName', + hostname: 'hostname', + }, + }, + ])('$label', ({ candidateObject, expected }) => { + expect(isSqlModelDataSourceDbConnectionConfig(candidateObject)).toEqual(expected); + }); + }); +}); diff --git a/packages/amplify-graphql-auth-transformer/API.md b/packages/amplify-graphql-auth-transformer/API.md index 3958e675f5..755b401dd4 100644 --- a/packages/amplify-graphql-auth-transformer/API.md +++ b/packages/amplify-graphql-auth-transformer/API.md @@ -110,6 +110,8 @@ export class AuthTransformer extends TransformerAuthBase implements TransformerA // (undocumented) addAutoGeneratedRelationalFields: (ctx: TransformerContextProvider, def: ObjectTypeDefinitionNode, allowedFields: Set, fields: readonly string[]) => void; // (undocumented) + addCustomOperationFieldsToAuthNonModelConfig: (ctx: TransformerTransformSchemaStepContextProvider) => void; + // (undocumented) addFieldResolverForDynamicAuth: (ctx: TransformerContextProvider, def: ObjectTypeDefinitionNode, typeName: string, fieldName: string) => void; // (undocumented) addFieldsToObject: (ctx: TransformerTransformSchemaStepContextProvider, modelName: string, ownerFields: Array) => void; diff --git a/packages/amplify-graphql-auth-transformer/src/__tests__/iam-custom-operations.test.ts b/packages/amplify-graphql-auth-transformer/src/__tests__/iam-custom-operations.test.ts new file mode 100644 index 0000000000..2d3d3864ec --- /dev/null +++ b/packages/amplify-graphql-auth-transformer/src/__tests__/iam-custom-operations.test.ts @@ -0,0 +1,517 @@ +import { ModelTransformer } from '@aws-amplify/graphql-model-transformer'; +import { mockSqlDataSourceStrategy, testTransform } from '@aws-amplify/graphql-transformer-test-utils'; +import { PrimaryKeyTransformer } from '@aws-amplify/graphql-index-transformer'; +import { SqlTransformer } from '@aws-amplify/graphql-sql-transformer'; +import { + constructDataSourceStrategies, + constructSqlDirectiveDataSourceStrategies, + DDB_AMPLIFY_MANAGED_DATASOURCE_STRATEGY, + isSqlStrategy, +} from '@aws-amplify/graphql-transformer-core'; +import { + AppSyncAuthConfiguration, + ModelDataSourceStrategy, + SqlDirectiveDataSourceStrategy, + SynthParameters, + TransformerPluginProvider, +} from '@aws-amplify/graphql-transformer-interfaces'; +import { AuthTransformer } from '../graphql-auth-transformer'; + +const makeAuthConfig = (): AppSyncAuthConfiguration => ({ + defaultAuthentication: { + authenticationType: 'API_KEY', + }, + additionalAuthenticationProviders: [ + { + authenticationType: 'AWS_IAM', + }, + { + authenticationType: 'AMAZON_COGNITO_USER_POOLS', + }, + ], +}); + +const makeSynthParameters = (): Partial => ({ + enableIamAccess: true, +}); + +const makeTransformers = (): TransformerPluginProvider[] => [ + new ModelTransformer(), + new AuthTransformer(), + new PrimaryKeyTransformer(), + new SqlTransformer(), +]; + +const makeSqlDirectiveDataSourceStrategies = (schema: string, strategy: ModelDataSourceStrategy): SqlDirectiveDataSourceStrategy[] => + isSqlStrategy(strategy) ? constructSqlDirectiveDataSourceStrategies(schema, strategy) : []; + +const strategyTypes = ['DDB', 'SQL'] as const; + +const makeStrategy = (strategyType: 'DDB' | 'SQL'): ModelDataSourceStrategy => + strategyType === 'SQL' ? mockSqlDataSourceStrategy() : DDB_AMPLIFY_MANAGED_DATASOURCE_STRATEGY; + +/** + * Tests that custom operations always get an `@aws_iam` directive if `enableIamAuthorizationMode` (which maps to the `enableIamAccess` + * flag) is true. In Gen 2, IAM access is globally enabled, to allow console Admin use cases, but customers do not have a way to specify it, + * so it is expected that IAM auth is enabled by Amplify. Even if a custom operation has other auth modes, applied, `@aws_iam` should be + * added by the transformer. + * + * Note: + * - Every schema includes an explicit ID and `@primaryKey` directive, so the schema is suitable for both DDB & SQL strategies + * - We aren't exhaustively testing every combination of auth rule and `enableIamAuthorizationMode` since we have other tests that do that. + * This suite is intended to ensure that aws_iam gets applied. + */ +describe('Custom operations have @aws_iam directives when enableIamAuthorizationMode is true', () => { + describe.each(strategyTypes)('Using %s', (strategyType) => { + test('Model is not present', () => { + const strategy = makeStrategy(strategyType); + const schema = /* GraphQL */ ` + type Query { + getFooCustom: String ${isSqlStrategy(strategy) ? '@sql(statement: "SELECT 1")' : ''} + } + type Mutation { + updateFooCustom: String ${isSqlStrategy(strategy) ? '@sql(statement: "UPDATE FOO set content=1")' : ''} + } + type Subscription { + onUpdateFooCustom: String @aws_subscribe(mutations: ["updateFooCustom"]) + } + `; + + const out = testTransform({ + schema, + dataSourceStrategies: constructDataSourceStrategies(schema, strategy), + authConfig: makeAuthConfig(), + synthParameters: makeSynthParameters(), + transformers: makeTransformers(), + sqlDirectiveDataSourceStrategies: makeSqlDirectiveDataSourceStrategies(schema, strategy), + }); + + expect(out.schema).toMatch(/getFooCustom: String.*@aws_iam/); + expect(out.schema).toMatch(/updateFooCustom: String.*@aws_iam/); + expect(out.schema).toMatch(/onUpdateFooCustom: String.*@aws_iam/); + }); + + test('Model is not present and custom operations include an explicit auth rule from Gen 2 schema builder', () => { + const strategy = makeStrategy(strategyType); + const schema = /* GraphQL */ ` + type Query { + getFooCustom: String ${isSqlStrategy(strategy) ? '@sql(statement: "SELECT 1")' : ''} @aws_cognito_user_pools + } + type Mutation { + updateFooCustom: String ${isSqlStrategy(strategy) ? '@sql(statement: "UPDATE FOO set content=1")' : ''} @aws_cognito_user_pools + } + type Subscription { + onUpdateFooCustom: String @aws_subscribe(mutations: ["updateFooCustom"]) @aws_cognito_user_pools + } + `; + + const out = testTransform({ + schema, + dataSourceStrategies: constructDataSourceStrategies(schema, strategy), + authConfig: makeAuthConfig(), + synthParameters: makeSynthParameters(), + transformers: makeTransformers(), + sqlDirectiveDataSourceStrategies: makeSqlDirectiveDataSourceStrategies(schema, strategy), + }); + + expect(out.schema).toMatch(/getFooCustom: String.*@aws_cognito_user_pools.*@aws_iam/); + expect(out.schema).toMatch(/updateFooCustom: String.*@aws_cognito_user_pools.*@aws_iam/); + expect(out.schema).toMatch(/onUpdateFooCustom: String.*@aws_cognito_user_pools.*@aws_iam/); + }); + + test('Model is present', () => { + const strategy = makeStrategy(strategyType); + const schema = /* GraphQL */ ` + type Foo @model @auth(rules: [{ allow: public }]) { + id: ID! @primaryKey + content: String + } + type Query { + getFooCustom: String ${isSqlStrategy(strategy) ? '@sql(statement: "SELECT 1")' : ''} + } + type Mutation { + updateFooCustom: String ${isSqlStrategy(strategy) ? '@sql(statement: "UPDATE FOO set content=1")' : ''} + } + type Subscription { + onUpdateFooCustom: String @aws_subscribe(mutations: ["updateFooCustom"]) + } + `; + + const out = testTransform({ + schema, + dataSourceStrategies: constructDataSourceStrategies(schema, strategy), + authConfig: makeAuthConfig(), + synthParameters: makeSynthParameters(), + transformers: makeTransformers(), + sqlDirectiveDataSourceStrategies: makeSqlDirectiveDataSourceStrategies(schema, strategy), + }); + + expect(out.schema).toMatch(/getFooCustom: String.*@aws_iam/); + expect(out.schema).toMatch(/updateFooCustom: String.*@aws_iam/); + expect(out.schema).toMatch(/onUpdateFooCustom: String.*@aws_iam/); + }); + + test('Model is present and custom operations include an explicit auth rule from Gen 2 schema builder', () => { + const strategy = makeStrategy(strategyType); + const schema = /* GraphQL */ ` + type Foo @model @auth(rules: [{ allow: public }]) { + id: ID! @primaryKey + content: String + } + type Query { + getFooCustom: String ${isSqlStrategy(strategy) ? '@sql(statement: "SELECT 1")' : ''} @aws_cognito_user_pools + } + type Mutation { + updateFooCustom: String ${isSqlStrategy(strategy) ? '@sql(statement: "UPDATE FOO set content=1")' : ''} @aws_cognito_user_pools + } + type Subscription { + onUpdateFooCustom: String @aws_subscribe(mutations: ["updateFooCustom"]) @aws_cognito_user_pools + } + `; + + const out = testTransform({ + schema, + dataSourceStrategies: constructDataSourceStrategies(schema, strategy), + authConfig: makeAuthConfig(), + synthParameters: makeSynthParameters(), + transformers: makeTransformers(), + sqlDirectiveDataSourceStrategies: makeSqlDirectiveDataSourceStrategies(schema, strategy), + }); + + expect(out.schema).toMatch(/getFooCustom: String.*@aws_cognito_user_pools.*@aws_iam/); + expect(out.schema).toMatch(/updateFooCustom: String.*@aws_cognito_user_pools.*@aws_iam/); + expect(out.schema).toMatch(/onUpdateFooCustom: String.*@aws_cognito_user_pools.*@aws_iam/); + }); + + test('Model is present but queries are disabled', () => { + const strategy = makeStrategy(strategyType); + const schema = /* GraphQL */ ` + type Foo @model(queries: null) @auth(rules: [{ allow: public }]) { + id: ID! @primaryKey + content: String + } + type Query { + getFooCustom: String ${isSqlStrategy(strategy) ? '@sql(statement: "SELECT 1")' : ''} + } + type Mutation { + updateFooCustom: String ${isSqlStrategy(strategy) ? '@sql(statement: "UPDATE FOO set content=1")' : ''} + } + type Subscription { + onUpdateFooCustom: String @aws_subscribe(mutations: ["updateFooCustom"]) + } + `; + + const out = testTransform({ + schema, + dataSourceStrategies: constructDataSourceStrategies(schema, strategy), + authConfig: makeAuthConfig(), + synthParameters: makeSynthParameters(), + transformers: makeTransformers(), + sqlDirectiveDataSourceStrategies: makeSqlDirectiveDataSourceStrategies(schema, strategy), + }); + + expect(out.schema).toMatch(/getFooCustom: String.*@aws_iam/); + expect(out.schema).toMatch(/updateFooCustom: String.*@aws_iam/); + expect(out.schema).toMatch(/onUpdateFooCustom: String.*@aws_iam/); + }); + + test('Model is present but mutations are disabled', () => { + const strategy = makeStrategy(strategyType); + const schema = /* GraphQL */ ` + type Foo @model(mutations: null) @auth(rules: [{ allow: public }]) { + id: ID! @primaryKey + content: String + } + type Query { + getFooCustom: String ${isSqlStrategy(strategy) ? '@sql(statement: "SELECT 1")' : ''} + } + type Mutation { + updateFooCustom: String ${isSqlStrategy(strategy) ? '@sql(statement: "UPDATE FOO set content=1")' : ''} + } + type Subscription { + onUpdateFooCustom: String @aws_subscribe(mutations: ["updateFooCustom"]) + } + `; + + const out = testTransform({ + schema, + dataSourceStrategies: constructDataSourceStrategies(schema, strategy), + authConfig: makeAuthConfig(), + synthParameters: makeSynthParameters(), + transformers: makeTransformers(), + sqlDirectiveDataSourceStrategies: makeSqlDirectiveDataSourceStrategies(schema, strategy), + }); + + expect(out.schema).toMatch(/getFooCustom: String.*@aws_iam/); + expect(out.schema).toMatch(/updateFooCustom: String.*@aws_iam/); + expect(out.schema).toMatch(/onUpdateFooCustom: String.*@aws_iam/); + }); + + test('Model is present but subscriptions are disabled', () => { + const strategy = makeStrategy(strategyType); + const schema = /* GraphQL */ ` + type Foo @model(subscriptions: null) @auth(rules: [{ allow: public }]) { + id: ID! @primaryKey + content: String + } + type Query { + getFooCustom: String ${isSqlStrategy(strategy) ? '@sql(statement: "SELECT 1")' : ''} + } + type Mutation { + updateFooCustom: String ${isSqlStrategy(strategy) ? '@sql(statement: "UPDATE FOO set content=1")' : ''} + } + type Subscription { + onUpdateFooCustom: String @aws_subscribe(mutations: ["updateFooCustom"]) + } + `; + + const out = testTransform({ + schema, + dataSourceStrategies: constructDataSourceStrategies(schema, strategy), + authConfig: makeAuthConfig(), + synthParameters: makeSynthParameters(), + transformers: makeTransformers(), + sqlDirectiveDataSourceStrategies: makeSqlDirectiveDataSourceStrategies(schema, strategy), + }); + + expect(out.schema).toMatch(/getFooCustom: String.*@aws_iam/); + expect(out.schema).toMatch(/updateFooCustom: String.*@aws_iam/); + expect(out.schema).toMatch(/onUpdateFooCustom: String.*@aws_iam/); + }); + + test('Does not add duplicate @aws_iam directive if already present', () => { + const strategy = makeStrategy(strategyType); + const schema = /* GraphQL */ ` + type Query { + getFooCustom: String ${isSqlStrategy(strategy) ? '@sql(statement: "SELECT 1")' : ''} @aws_iam + } + type Mutation { + updateFooCustom: String ${isSqlStrategy(strategy) ? '@sql(statement: "UPDATE FOO set content=1")' : ''} @aws_iam + } + type Subscription { + onUpdateFooCustom: String @aws_subscribe(mutations: ["updateFooCustom"]) @aws_iam + } + `; + + const out = testTransform({ + schema, + dataSourceStrategies: constructDataSourceStrategies(schema, strategy), + authConfig: makeAuthConfig(), + synthParameters: makeSynthParameters(), + transformers: makeTransformers(), + sqlDirectiveDataSourceStrategies: makeSqlDirectiveDataSourceStrategies(schema, strategy), + }); + + expect(out.schema).toMatch(/getFooCustom: String.*@aws_iam/); + expect(out.schema).toMatch(/updateFooCustom: String.*@aws_iam/); + expect(out.schema).toMatch(/onUpdateFooCustom: String.*@aws_iam/); + expect(out.schema).not.toMatch(/getFooCustom: String.*@aws_iam.*@aws_iam/); + expect(out.schema).not.toMatch(/updateFooCustom: String.*@aws_iam.*@aws_iam/); + expect(out.schema).not.toMatch(/onUpdateFooCustom: String.*@aws_iam.*@aws_iam/); + }); + + test('Adds @aws_iam directive if sandbox is enabled', () => { + const strategy = makeStrategy(strategyType); + const schema = /* GraphQL */ ` + type Query { + getFooCustom: String ${isSqlStrategy(strategy) ? '@sql(statement: "SELECT 1")' : ''} + } + type Mutation { + updateFooCustom: String ${isSqlStrategy(strategy) ? '@sql(statement: "UPDATE FOO set content=1")' : ''} + } + type Subscription { + onUpdateFooCustom: String @aws_subscribe(mutations: ["updateFooCustom"]) + } + `; + + const out = testTransform({ + schema, + dataSourceStrategies: constructDataSourceStrategies(schema, strategy), + authConfig: makeAuthConfig(), + transformers: makeTransformers(), + transformParameters: { + sandboxModeEnabled: true, + }, + sqlDirectiveDataSourceStrategies: makeSqlDirectiveDataSourceStrategies(schema, strategy), + }); + + expect(out.schema).toMatch(/getFooCustom: String.*@aws_iam/); + expect(out.schema).toMatch(/updateFooCustom: String.*@aws_iam/); + expect(out.schema).toMatch(/onUpdateFooCustom: String.*@aws_iam/); + }); + + test('Does not add if neither sandbox nor enableIamAuthorizationMode is enabled', () => { + const strategy = makeStrategy(strategyType); + const schema = /* GraphQL */ ` + type Query { + getFooCustom: String ${isSqlStrategy(strategy) ? '@sql(statement: "SELECT 1")' : ''} + } + type Mutation { + updateFooCustom: String ${isSqlStrategy(strategy) ? '@sql(statement: "UPDATE FOO set content=1")' : ''} + } + type Subscription { + onUpdateFooCustom: String @aws_subscribe(mutations: ["updateFooCustom"]) + } + `; + + const out = testTransform({ + schema, + dataSourceStrategies: constructDataSourceStrategies(schema, strategy), + authConfig: makeAuthConfig(), + transformers: makeTransformers(), + sqlDirectiveDataSourceStrategies: makeSqlDirectiveDataSourceStrategies(schema, strategy), + }); + + expect(out.schema).not.toMatch(/getFooCustom: String.*@aws_iam/); + expect(out.schema).not.toMatch(/updateFooCustom: String.*@aws_iam/); + expect(out.schema).not.toMatch(/onUpdateFooCustom: String.*@aws_iam/); + }); + + // TODO: Enable this test once we fix https://github.com/aws-amplify/amplify-category-api/issues/2929 + test.skip('Adds @aws_iam to non-model custom types when there is no model', () => { + const strategy = makeStrategy(strategyType); + const schema = /* GraphQL */ ` + type Foo { + description: String + } + type Query { + getFooCustom: Foo + } + type Mutation { + updateFooCustom: Foo + } + type Subscription { + onUpdateFooCustom: Foo @aws_subscribe(mutations: ["updateFooCustom"]) + } + `; + + const out = testTransform({ + schema, + dataSourceStrategies: constructDataSourceStrategies(schema, strategy), + authConfig: makeAuthConfig(), + synthParameters: makeSynthParameters(), + transformers: makeTransformers(), + sqlDirectiveDataSourceStrategies: makeSqlDirectiveDataSourceStrategies(schema, strategy), + }); + + // Expect the custom operations to be authorized + expect(out.schema).toMatch(/getFooCustom: Foo.*@aws_iam/); + expect(out.schema).toMatch(/updateFooCustom: Foo.*@aws_iam/); + expect(out.schema).toMatch(/onUpdateFooCustom: Foo.*@aws_iam/); + + // Also expect the custom type referenced by the custom operation to be authorized + expect(out.schema).toMatch(/type Foo.*@aws_iam/); + }); + + // TODO: Enable this test once we fix https://github.com/aws-amplify/amplify-category-api/issues/2929 + test.skip('Adds @aws_iam to non-model custom types when there is a model', () => { + const strategy = makeStrategy(strategyType); + const schema = /* GraphQL */ ` + type Todo @model { + id: ID! @primaryKey + done: Boolean + } + type Foo { + description: String + } + type Query { + getFooCustom: Foo + } + type Mutation { + updateFooCustom: Foo + } + type Subscription { + onUpdateFooCustom: Foo @aws_subscribe(mutations: ["updateFooCustom"]) + } + `; + + const out = testTransform({ + schema, + dataSourceStrategies: constructDataSourceStrategies(schema, strategy), + authConfig: makeAuthConfig(), + synthParameters: makeSynthParameters(), + transformers: makeTransformers(), + sqlDirectiveDataSourceStrategies: makeSqlDirectiveDataSourceStrategies(schema, strategy), + }); + + // Expect the custom operations to be authorized + expect(out.schema).toMatch(/getFooCustom: Foo.*@aws_iam/); + expect(out.schema).toMatch(/updateFooCustom: Foo.*@aws_iam/); + expect(out.schema).toMatch(/onUpdateFooCustom: Foo.*@aws_iam/); + + // Also expect the custom type referenced by the custom operation to be authorized + expect(out.schema).toMatch(/type Foo.*@aws_iam/); + }); + + test('Adds @aws_iam to non-model custom types when there is some other auth directive on the field', () => { + const strategy = makeStrategy(strategyType); + const schema = /* GraphQL */ ` + type Foo { + description: String @auth(rules: [{ allow: groups, groups: ["ZZZ_DOES_NOT_EXIST"] }]) + } + type Query { + getFooCustom: Foo + } + type Mutation { + updateFooCustom: Foo + } + type Subscription { + onUpdateFooCustom: Foo @aws_subscribe(mutations: ["updateFooCustom"]) + } + `; + + const out = testTransform({ + schema, + dataSourceStrategies: constructDataSourceStrategies(schema, strategy), + authConfig: makeAuthConfig(), + synthParameters: makeSynthParameters(), + transformers: makeTransformers(), + sqlDirectiveDataSourceStrategies: makeSqlDirectiveDataSourceStrategies(schema, strategy), + }); + + // Expect the custom operations to be authorized + expect(out.schema).toMatch(/getFooCustom: Foo.*@aws_iam/); + expect(out.schema).toMatch(/updateFooCustom: Foo.*@aws_iam/); + expect(out.schema).toMatch(/onUpdateFooCustom: Foo.*@aws_iam/); + + // Also expect the custom type referenced by the custom operation to be authorized + expect(out.schema).toMatch(/description: String.*@aws_iam/); + }); + + test('Does not add duplicate @aws_iam directive to custom type if already present', () => { + const strategy = makeStrategy(strategyType); + const schema = /* GraphQL */ ` + type Foo @aws_iam { + description: String + } + type Query { + getFooCustom: Foo ${isSqlStrategy(strategy) ? '@sql(statement: "SELECT 1")' : ''} + } + type Mutation { + updateFooCustom: Foo ${isSqlStrategy(strategy) ? '@sql(statement: "UPDATE FOO set content=1")' : ''} + } + type Subscription { + onUpdateFooCustom: Foo @aws_subscribe(mutations: ["updateFooCustom"]) + } + `; + + const out = testTransform({ + schema, + dataSourceStrategies: constructDataSourceStrategies(schema, strategy), + authConfig: makeAuthConfig(), + synthParameters: makeSynthParameters(), + transformers: makeTransformers(), + sqlDirectiveDataSourceStrategies: makeSqlDirectiveDataSourceStrategies(schema, strategy), + }); + + // Expect the custom operations to be authorized + expect(out.schema).toMatch(/getFooCustom: Foo.*@aws_iam/); + expect(out.schema).toMatch(/updateFooCustom: Foo.*@aws_iam/); + expect(out.schema).toMatch(/onUpdateFooCustom: Foo.*@aws_iam/); + + // Also expect the custom type referenced by the custom operation to be authorized + expect(out.schema).toMatch(/type Foo.*@aws_iam/); + expect(out.schema).not.toMatch(/type Foo.*@aws_iam.*@aws_iam/); + }); + }); +}); diff --git a/packages/amplify-graphql-auth-transformer/src/graphql-auth-transformer.ts b/packages/amplify-graphql-auth-transformer/src/graphql-auth-transformer.ts index adf021a188..4ac4990c89 100644 --- a/packages/amplify-graphql-auth-transformer/src/graphql-auth-transformer.ts +++ b/packages/amplify-graphql-auth-transformer/src/graphql-auth-transformer.ts @@ -1,21 +1,20 @@ import { DirectiveWrapper, - TransformerContractError, - TransformerAuthBase, - InvalidDirectiveError, - MappingTemplate, - TransformerResolver, - getSortKeyFieldNames, generateGetArgumentsInput, - isSqlModel, - getModelDataSourceNameForTypeName, - isModelType, - getFilterInputName, getConditionInputName, + getFilterInputName, + getModelDataSourceNameForTypeName, + getSortKeyFieldNames, getSubscriptionFilterInputName, - getConnectionName, - InputFieldWrapper, + InvalidDirectiveError, + isBuiltInGraphqlNode, isDynamoDbModel, + isModelType, + isSqlModel, + MappingTemplate, + TransformerAuthBase, + TransformerContractError, + TransformerResolver, } from '@aws-amplify/graphql-transformer-core'; import { DataSourceProvider, @@ -345,8 +344,34 @@ export class AuthTransformer extends TransformerAuthBase implements TransformerA } }; + /** + * Adds custom Queries, Mutations, and Subscriptions to the authNonModelConfig map to ensure they are included when adding implicit + * aws_iam auth directives. + */ + addCustomOperationFieldsToAuthNonModelConfig = (ctx: TransformerTransformSchemaStepContextProvider): void => { + if (!ctx.transformParameters.sandboxModeEnabled && !ctx.synthParameters.enableIamAccess) { + return; + } + + const hasAwsIamDirective = (field: FieldDefinitionNode): boolean => { + return field.directives?.some((dir) => dir.name.value === 'aws_iam'); + }; + + const allObjects = ctx.inputDocument.definitions.filter(isBuiltInGraphqlNode); + allObjects.forEach((object) => { + const typeName = object.name.value; + const fieldsWithoutIamDirective = object.fields.filter((field) => !hasAwsIamDirective(field)); + fieldsWithoutIamDirective.forEach((field) => { + addDirectivesToField(ctx, typeName, field.name.value, [makeDirective('aws_iam', [])]); + }); + }); + }; + transformSchema = (context: TransformerTransformSchemaStepContextProvider): void => { + this.addCustomOperationFieldsToAuthNonModelConfig(context); + const searchableAggregateServiceDirectives = new Set(); + const getOwnerFields = (acm: AccessControlMatrix): string[] => acm.getRoles().reduce((prev: string[], role: string) => { if (this.roleMap.get(role)!.strategy === 'owner') prev.push(this.roleMap.get(role)!.entity!); diff --git a/packages/amplify-graphql-transformer-core/API.md b/packages/amplify-graphql-transformer-core/API.md index f498d42d04..7a0dcb8d65 100644 --- a/packages/amplify-graphql-transformer-core/API.md +++ b/packages/amplify-graphql-transformer-core/API.md @@ -453,11 +453,11 @@ export class InvalidTransformerError extends Error { export const isAmplifyDynamoDbModelDataSourceStrategy: (strategy: ModelDataSourceStrategy) => strategy is AmplifyDynamoDbModelDataSourceStrategy; // @public (undocumented) -export const isBuiltInGraphqlNode: (obj: DefinitionNode) => obj is ObjectTypeDefinitionNode | (InterfaceTypeDefinitionNode & { +export const isBuiltInGraphqlNode: (obj: DefinitionNode) => obj is (ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode) & { name: { - value: 'Mutation' | 'Query'; + value: 'Mutation' | 'Query' | 'Subscription'; }; -}); +}; // @public (undocumented) export const isDefaultDynamoDbModelDataSourceStrategy: (strategy: ModelDataSourceStrategy) => strategy is DefaultDynamoDbModelDataSourceStrategy; @@ -478,11 +478,11 @@ function isLambdaSyncConfig(syncConfig: SyncConfig): syncConfig is SyncConfigLam export const isModelType: (ctx: DataSourceStrategiesProvider, typename: string) => boolean; // @public (undocumented) -export const isMutationNode: (obj: DefinitionNode) => obj is ObjectTypeDefinitionNode | (InterfaceTypeDefinitionNode & { +export const isMutationNode: (obj: DefinitionNode) => obj is (ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode) & { name: { value: 'Mutation'; }; -}); +}; // @public (undocumented) export const isObjectTypeDefinitionNode: (obj: DefinitionNode) => obj is ObjectTypeDefinitionNode; @@ -494,11 +494,11 @@ export const isPostgresDbType: (dbType: ModelDataSourceStrategyDbType) => dbType export const isPostgresModel: (ctx: DataSourceStrategiesProvider, typename: string) => boolean; // @public (undocumented) -export const isQueryNode: (obj: DefinitionNode) => obj is ObjectTypeDefinitionNode | (InterfaceTypeDefinitionNode & { +export const isQueryNode: (obj: DefinitionNode) => obj is (ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode) & { name: { value: 'Query'; }; -}); +}; // @public (undocumented) export const isQueryType: (typeName: string) => typeName is "Query"; diff --git a/packages/amplify-graphql-transformer-core/src/utils/graphql-utils.ts b/packages/amplify-graphql-transformer-core/src/utils/graphql-utils.ts index 9908147827..5de1ac9bbd 100644 --- a/packages/amplify-graphql-transformer-core/src/utils/graphql-utils.ts +++ b/packages/amplify-graphql-transformer-core/src/utils/graphql-utils.ts @@ -17,25 +17,25 @@ export const isBuiltInGraphqlType = (typeName: string): typeName is 'Mutation' | export const isMutationNode = ( obj: DefinitionNode, -): obj is ObjectTypeDefinitionNode | (InterfaceTypeDefinitionNode & { name: { value: 'Mutation' } }) => { +): obj is (ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode) & { name: { value: 'Mutation' } } => { return isObjectTypeDefinitionNode(obj) && isMutationType(obj.name.value); }; export const isQueryNode = ( obj: DefinitionNode, -): obj is ObjectTypeDefinitionNode | (InterfaceTypeDefinitionNode & { name: { value: 'Query' } }) => { +): obj is (ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode) & { name: { value: 'Query' } } => { return isObjectTypeDefinitionNode(obj) && isQueryType(obj.name.value); }; export const isSubscriptionNode = ( obj: DefinitionNode, -): obj is ObjectTypeDefinitionNode | (InterfaceTypeDefinitionNode & { name: { value: 'Subscription' } }) => { +): obj is (ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode) & { name: { value: 'Subscription' } } => { return isObjectTypeDefinitionNode(obj) && isSubscriptionType(obj.name.value); }; export const isBuiltInGraphqlNode = ( obj: DefinitionNode, -): obj is ObjectTypeDefinitionNode | (InterfaceTypeDefinitionNode & { name: { value: 'Mutation' | 'Query' } }) => { +): obj is (ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode) & { name: { value: 'Mutation' | 'Query' | 'Subscription' } } => { return isMutationNode(obj) || isQueryNode(obj) || isSubscriptionNode(obj); };