diff --git a/packages/amplify-graphql-auth-transformer/src/__tests__/searchable-auth.test.ts b/packages/amplify-graphql-auth-transformer/src/__tests__/searchable-auth.test.ts index e1bd1ee6dde..4cfd3491014 100644 --- a/packages/amplify-graphql-auth-transformer/src/__tests__/searchable-auth.test.ts +++ b/packages/amplify-graphql-auth-transformer/src/__tests__/searchable-auth.test.ts @@ -1,8 +1,29 @@ -import { AuthTransformer } from '../graphql-auth-transformer'; +import { AuthTransformer, SEARCHABLE_AGGREGATE_TYPES } from '../'; import { ModelTransformer } from '@aws-amplify/graphql-model-transformer'; import { SearchableModelTransformer } from '@aws-amplify/graphql-searchable-transformer'; import { GraphQLTransform } from '@aws-amplify/graphql-transformer-core'; import { AppSyncAuthConfiguration } from '@aws-amplify/graphql-transformer-interfaces'; +import { DocumentNode, ObjectTypeDefinitionNode, Kind, FieldDefinitionNode, parse, InputValueDefinitionNode } from 'graphql'; + +const getObjectType = (doc: DocumentNode, type: string): ObjectTypeDefinitionNode | undefined => { + return doc.definitions.find(def => def.kind === Kind.OBJECT_TYPE_DEFINITION && def.name.value === type) as + | ObjectTypeDefinitionNode + | undefined; +}; +const expectMultiple = (fieldOrType: ObjectTypeDefinitionNode | FieldDefinitionNode, directiveNames: string[]) => { + expect(directiveNames).toBeDefined(); + expect(directiveNames).toHaveLength(directiveNames.length); + expect(fieldOrType.directives.length).toEqual(directiveNames.length); + directiveNames.forEach(directiveName => { + expect(fieldOrType.directives).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: expect.objectContaining({ value: directiveName }), + }), + ]), + ); + }); +}; test('auth logic is enabled on owner/static rules in es request', () => { const validSchema = ` @@ -47,6 +68,7 @@ test('auth logic is enabled on owner/static rules in es request', () => { }); test('auth logic is enabled for iam/apiKey auth rules', () => { + const expectedDirectives = ['aws_api_key', 'aws_iam']; const validSchema = ` type Post @model @searchable @@ -89,5 +111,14 @@ test('auth logic is enabled for iam/apiKey auth rules', () => { }); const out = transformer.transform(validSchema); expect(out).toBeDefined(); - expect(out.schema).toContain('SearchablePostConnection @aws_api_key @aws_iam'); + expect(out.schema).toBeDefined(); + const schemaDoc = parse(out.schema); + for (const aggregateType of SEARCHABLE_AGGREGATE_TYPES) { + expectMultiple(getObjectType(schemaDoc, aggregateType), expectedDirectives); + } + // expect the searchbable types to have the auth directives for total providers + // expect the allowed fields for agg to exclude secret + expect(out.pipelineFunctions['Query.searchPosts.auth.1.req.vtl']).toContain( + `#set( $allowedAggFields = ["createdAt","updatedAt","id","content"] )`, + ); }); 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 89da319484a..ac275a9ea12 100644 --- a/packages/amplify-graphql-auth-transformer/src/graphql-auth-transformer.ts +++ b/packages/amplify-graphql-auth-transformer/src/graphql-auth-transformer.ts @@ -20,24 +20,25 @@ import { TransformerBeforeStepContextProvider, } from '@aws-amplify/graphql-transformer-interfaces'; import { + AUTH_PROVIDER_DIRECTIVE_MAP, + DEFAULT_GROUP_CLAIM, + DEFAULT_IDENTITY_CLAIM, + DEFAULT_GROUPS_FIELD, + DEFAULT_OWNER_FIELD, + MODEL_OPERATIONS, + SEARCHABLE_AGGREGATE_TYPES, AuthRule, authDirectiveDefinition, ConfiguredAuthProviders, getConfiguredAuthProviders, AuthTransformerConfig, collectFieldNames, - DEFAULT_GROUP_CLAIM, - MODEL_OPERATIONS, ModelOperation, ensureAuthRuleDefaults, - DEFAULT_IDENTITY_CLAIM, - DEFAULT_GROUPS_FIELD, - DEFAULT_OWNER_FIELD, getModelConfig, validateFieldRules, validateRules, AuthProvider, - AUTH_PROVIDER_DIRECTIVE_MAP, extendTypeWithDirectives, RoleDefinition, addDirectivesToOperation, @@ -229,6 +230,7 @@ Static group authorization should perform as expected.`, }; transformSchema = (context: TransformerTransformSchemaStepContextProvider): void => { + const searchableAggregateServiceDirectives = new Set(); const getOwnerFields = (acm: AccessControlMatrix) => { return acm.getRoles().reduce((prev: string[], role: string) => { if (this.roleMap.get(role)!.strategy === 'owner') prev.push(this.roleMap.get(role)!.entity!); @@ -237,11 +239,15 @@ Static group authorization should perform as expected.`, }; for (let [modelName, acm] of this.authModelConfig) { const def = context.output.getObject(modelName)!; + const modelHasSearchable = def.directives.some(dir => dir.name.value === 'searchable'); // collect ownerFields and them in the model this.addFieldsToObject(context, modelName, getOwnerFields(acm)); // Get the directives we need to add to the GraphQL nodes const providers = this.getAuthProviders(acm.getRoles()); const directives = this.getServiceDirectives(providers, providers.length === 0 ? this.shouldAddDefaultServiceDirective() : false); + if (modelHasSearchable) { + providers.forEach(p => searchableAggregateServiceDirectives.add(p)); + } if (directives.length > 0) { extendTypeWithDirectives(context, modelName, directives); } @@ -257,6 +263,13 @@ Static group authorization should perform as expected.`, addDirectivesToField(context, typeName, fieldName, directives); } } + // add the service directives to the searchable aggregate types + if (searchableAggregateServiceDirectives.size > 0) { + const serviceDirectives = this.getServiceDirectives(Array.from(searchableAggregateServiceDirectives), false); + for (let aggType of SEARCHABLE_AGGREGATE_TYPES) { + extendTypeWithDirectives(context, aggType, serviceDirectives); + } + } }; generateResolvers = (context: TransformerContextProvider): void => { @@ -542,6 +555,12 @@ Static group authorization should perform as expected.`, ); } }; + /* + Searchable Auth + Protects + - Search Query + - Agg Query + */ protectSearchResolver = ( ctx: TransformerContextProvider, def: ObjectTypeDefinitionNode, @@ -549,9 +568,35 @@ Static group authorization should perform as expected.`, fieldName: string, acm: AccessControlMatrix, ): void => { + const acmFields = acm.getResources(); + const modelFields = def.fields ?? []; + // only add readonly fields if they exist + const allowedAggFields = modelFields.map(f => f.name.value).filter(f => !acmFields.includes(f)); + let leastAllowedFields = acmFields; const resolver = ctx.resolvers.getResolver(typeName, fieldName) as TransformerResolverProvider; - const roleDefinitions = acm.getRolesPerOperation('read').map(r => this.roleMap.get(r)!); - const authExpression = generateAuthExpressionForSearchQueries(this.configuredAuthProviders, roleDefinitions, def.fields ?? []); + // to protect search and aggregation queries we need to collect all the roles which can query + // and the allowed fields to run field auth on aggregation queries + const readRoleDefinitions = acm.getRolesPerOperation('read').map(role => { + const allowedFields = acmFields.filter(resource => acm.isAllowed(role, resource, 'read')); + const roleDefinition = this.roleMap.get(role)!; + // we add the allowed fields if the role does not have full access + // or if the rule is a dynamic rule (ex. ownerField, groupField) + if (allowedFields.length !== acmFields.length || !roleDefinition.static) { + roleDefinition.allowedFields = allowedFields; + leastAllowedFields = leastAllowedFields.filter(f => allowedFields.includes(f)); + } else { + roleDefinition.allowedFields = null; + } + return roleDefinition; + }); + // add readonly fields with all the fields every role has access to + allowedAggFields.push(...leastAllowedFields); + const authExpression = generateAuthExpressionForSearchQueries( + this.configuredAuthProviders, + readRoleDefinitions, + modelFields, + allowedAggFields, + ); resolver.addToSlot( 'auth', MappingTemplate.s3MappingTemplateFromString(authExpression, `${typeName}.${fieldName}.{slotName}.{slotIndex}.req.vtl`), diff --git a/packages/amplify-graphql-auth-transformer/src/resolvers/field.ts b/packages/amplify-graphql-auth-transformer/src/resolvers/field.ts index cbc7c385dfc..c7e9e4bfa2d 100644 --- a/packages/amplify-graphql-auth-transformer/src/resolvers/field.ts +++ b/packages/amplify-graphql-auth-transformer/src/resolvers/field.ts @@ -20,6 +20,7 @@ import { qref, notEquals, obj, + list, } from 'graphql-mapping-template'; import { RoleDefinition, @@ -31,7 +32,7 @@ import { IS_AUTHORIZED_FLAG, API_KEY_AUTH_TYPE, } from '../utils'; -import { getOwnerClaim, generateStaticRoleExpression, apiKeyExpression, iamExpression, emptyPayload } from './helpers'; +import { getOwnerClaim, generateStaticRoleExpression, apiKeyExpression, iamExpression, emptyPayload, getIdentityClaimExp } from './helpers'; // Field Read VTL Functions const generateDynamicAuthReadExpression = (roles: Array, fields: ReadonlyArray) => { @@ -66,15 +67,15 @@ const generateDynamicAuthReadExpression = (roles: Array, fields: not(ref(IS_AUTHORIZED_FLAG)), compoundExpression([ set(ref(`groupEntity${idx}`), methodCall(ref('util.defaultIfNull'), ref(`ctx.source.${role.entity!}`), nul())), - set(ref(`groupClaim${idx}`), getOwnerClaim(role.claim!)), - forEach(ref('userGroup'), ref('dynamicGroupClaim'), [ - iff( - entityIsList - ? methodCall(ref(`groupEntity${idx}.contains`), ref('userGroup')) - : equals(ref(`groupEntity${idx}`), ref('userGroup')), - compoundExpression([set(ref(IS_AUTHORIZED_FLAG), bool(true)), raw('#break')]), - ), - ]), + set(ref(`groupClaim${idx}`), getIdentityClaimExp(str(role.claim), list([]))), + entityIsList + ? forEach(ref('userGroup'), ref(`groupClaim${idx}`), [ + iff( + methodCall(ref(`groupEntity${idx}.contains`), ref('userGroup')), + compoundExpression([set(ref(IS_AUTHORIZED_FLAG), bool(true)), raw('#break')]), + ), + ]) + : iff(ref(`groupClaim${idx}.contains($groupEntity${idx})`), set(ref(IS_AUTHORIZED_FLAG), bool(true))), ]), ), ); diff --git a/packages/amplify-graphql-auth-transformer/src/resolvers/index.ts b/packages/amplify-graphql-auth-transformer/src/resolvers/index.ts index aa407c74075..048579c9f84 100644 --- a/packages/amplify-graphql-auth-transformer/src/resolvers/index.ts +++ b/packages/amplify-graphql-auth-transformer/src/resolvers/index.ts @@ -1,4 +1,5 @@ -export { generateAuthExpressionForQueries, generateAuthExpressionForSearchQueries, generateAuthExpressionForRelationQuery } from './query'; +export { generateAuthExpressionForQueries, generateAuthExpressionForRelationQuery } from './query'; +export { generateAuthExpressionForSearchQueries } from './search'; export { generateAuthExpressionForCreate } from './mutation.create'; export { generateAuthExpressionForUpdate } from './mutation.update'; export { geneateAuthExpressionForDelete } from './mutation.delete'; diff --git a/packages/amplify-graphql-auth-transformer/src/resolvers/query.ts b/packages/amplify-graphql-auth-transformer/src/resolvers/query.ts index 26f29551848..3b186108bd5 100644 --- a/packages/amplify-graphql-auth-transformer/src/resolvers/query.ts +++ b/packages/amplify-graphql-auth-transformer/src/resolvers/query.ts @@ -29,10 +29,8 @@ import { ConfiguredAuthProviders, IS_AUTHORIZED_FLAG, fieldIsList, - QuerySource, RelationalPrimaryMapConfig, } from '../utils'; -import { InvalidDirectiveError } from '@aws-amplify/graphql-transformer-core'; import { NONE_VALUE } from 'graphql-transformer-common'; const generateStaticRoleExpression = (roles: Array): Array => { @@ -187,11 +185,7 @@ const generateAuthOnModelQueryExpression = ( return []; }; -const generateAuthFilter = ( - roles: Array, - fields: ReadonlyArray, - querySource: QuerySource, -): Array => { +const generateAuthFilter = (roles: Array, fields: ReadonlyArray): Array => { const authFilter = new Array(); const groupMap = new Map>(); const groupContainsExpression = new Array(); @@ -207,87 +201,44 @@ const generateAuthFilter = ( * if groupsField is a list * we create contains experession for each cognito group * */ - if (querySource === 'dynamodb') { - for (let role of roles) { - const entityIsList = fieldIsList(fields, role.entity); - if (role.strategy === 'owner') { - const ownerCondition = entityIsList ? 'contains' : 'eq'; - authFilter.push(obj({ [role.entity]: obj({ [ownerCondition]: getOwnerClaim(role.claim!) }) })); - } - if (role.strategy === 'groups') { - // for fields where the group is a list and the token is a list we must add every group in the claim - if (entityIsList) { - if (groupMap.has(role.claim!)) { - groupMap.get(role.claim).push(role.entity); - } else { - groupMap.set(role.claim!, [role.entity]); - } + for (let role of roles) { + const entityIsList = fieldIsList(fields, role.entity); + if (role.strategy === 'owner') { + const ownerCondition = entityIsList ? 'contains' : 'eq'; + authFilter.push(obj({ [role.entity]: obj({ [ownerCondition]: getOwnerClaim(role.claim!) }) })); + } + if (role.strategy === 'groups') { + // for fields where the group is a list and the token is a list we must add every group in the claim + if (entityIsList) { + if (groupMap.has(role.claim!)) { + groupMap.get(role.claim).push(role.entity); } else { - authFilter.push(obj({ [role.entity]: obj({ in: getIdentityClaimExp(str(role.claim!), list([str(NONE_VALUE)])) }) })); + groupMap.set(role.claim!, [role.entity]); } + } else { + authFilter.push(obj({ [role.entity]: obj({ in: getIdentityClaimExp(str(role.claim!), list([str(NONE_VALUE)])) }) })); } } - for (let [groupClaim, fieldList] of groupMap) { - groupContainsExpression.push( - forEach( - ref('group'), - ref(`util.defaultIfNull($ctx.identity.claims.get("${groupClaim}"), ["${NONE_VALUE}"])`), - fieldList.map(field => qref(methodCall(ref('authFilter.add'), raw(`{"${field}": { "contains": $group }}`)))), - ), - ); - } - return [ - iff( - not(ref(IS_AUTHORIZED_FLAG)), - compoundExpression([ - set(ref('authFilter'), list(authFilter)), - ...(groupContainsExpression.length > 0 ? groupContainsExpression : []), - qref(methodCall(ref('ctx.stash.put'), str('authFilter'), raw('{ "or": $authFilter }'))), - ]), - ), - ]; } - /** - * for opensearch - * we create a terms_set where the field (role.entity) has to match at least element in the terms - * if the field is a list it will look for a subset of elements in the list which should exist in the terms list - * */ - if (querySource === 'opensearch') { - for (let role of roles) { - const entityIsList = fieldIsList(fields, role.entity); - const roleKey = entityIsList ? role.entity : `${role.entity}.keyword`; - if (role.strategy === 'owner') { - authFilter.push( - obj({ - terms_set: obj({ - [roleKey]: obj({ - terms: list([getOwnerClaim(role.claim!)]), - minimum_should_match_script: obj({ source: str('1') }), - }), - }), - }), - ); - } else if (role.strategy === 'groups') { - authFilter.push( - obj({ - terms_set: obj({ - [roleKey]: obj({ - terms: getIdentityClaimExp(str(role.claim!), list([str(NONE_VALUE)])), - minimum_should_match_script: obj({ source: str('1') }), - }), - }), - }), - ); - } - } - return [ - iff( - not(ref(IS_AUTHORIZED_FLAG)), - qref(methodCall(ref('ctx.stash.put'), str('authFilter'), obj({ bool: obj({ should: list(authFilter) }) }))), + for (let [groupClaim, fieldList] of groupMap) { + groupContainsExpression.push( + forEach( + ref('group'), + ref(`util.defaultIfNull($ctx.identity.claims.get("${groupClaim}"), ["${NONE_VALUE}"])`), + fieldList.map(field => qref(methodCall(ref('authFilter.add'), raw(`{"${field}": { "contains": $group }}`)))), ), - ]; + ); } - throw new InvalidDirectiveError(`Could not generate an auth filter for a ${querySource} datasource.`); + return [ + iff( + not(ref(IS_AUTHORIZED_FLAG)), + compoundExpression([ + set(ref('authFilter'), list(authFilter)), + ...(groupContainsExpression.length > 0 ? groupContainsExpression : []), + qref(methodCall(ref('ctx.stash.put'), str('authFilter'), raw('{ "or": $authFilter }'))), + ]), + ), + ]; }; export const generateAuthExpressionForQueries = ( @@ -316,7 +267,7 @@ export const generateAuthExpressionForQueries = ( equals(ref('util.authType()'), str(COGNITO_AUTH_TYPE)), compoundExpression([ ...generateStaticRoleExpression(cogntoStaticRoles), - ...generateAuthFilter(getNonPrimaryFieldRoles(cognitoDynamicRoles), fields, 'dynamodb'), + ...generateAuthFilter(getNonPrimaryFieldRoles(cognitoDynamicRoles), fields), ...generateAuthOnModelQueryExpression(cognitoDynamicRoles, primaryFields, isIndexQuery), ]), ), @@ -328,7 +279,7 @@ export const generateAuthExpressionForQueries = ( equals(ref('util.authType()'), str(OIDC_AUTH_TYPE)), compoundExpression([ ...generateStaticRoleExpression(oidcStaticRoles), - ...generateAuthFilter(getNonPrimaryFieldRoles(oidcDynamicRoles), fields, 'dynamodb'), + ...generateAuthFilter(getNonPrimaryFieldRoles(oidcDynamicRoles), fields), ...generateAuthOnModelQueryExpression(oidcDynamicRoles, primaryFields, isIndexQuery), ]), ), @@ -343,44 +294,6 @@ export const generateAuthExpressionForQueries = ( return printBlock('Authorization Steps')(compoundExpression([...totalAuthExpressions, emptyPayload])); }; -export const generateAuthExpressionForSearchQueries = ( - providers: ConfiguredAuthProviders, - roles: Array, - fields: ReadonlyArray, -): string => { - const { cogntoStaticRoles, cognitoDynamicRoles, oidcStaticRoles, oidcDynamicRoles, apiKeyRoles, iamRoles } = splitRoles(roles); - const totalAuthExpressions: Array = [setHasAuthExpression, set(ref(IS_AUTHORIZED_FLAG), bool(false))]; - if (providers.hasApiKey) { - totalAuthExpressions.push(apiKeyExpression(apiKeyRoles)); - } - if (providers.hasIAM) { - totalAuthExpressions.push(iamExpression(iamRoles, providers.hasAdminUIEnabled, providers.adminUserPoolID)); - } - if (providers.hasUserPools) { - totalAuthExpressions.push( - iff( - equals(ref('util.authType()'), str(COGNITO_AUTH_TYPE)), - compoundExpression([ - ...generateStaticRoleExpression(cogntoStaticRoles), - ...generateAuthFilter(cognitoDynamicRoles, fields, 'opensearch'), - ]), - ), - ); - } - if (providers.hasOIDC) { - totalAuthExpressions.push( - iff( - equals(ref('util.authType()'), str(OIDC_AUTH_TYPE)), - compoundExpression([...generateStaticRoleExpression(oidcStaticRoles), ...generateAuthFilter(oidcDynamicRoles, [], 'opensearch')]), - ), - ); - } - totalAuthExpressions.push( - iff(and([not(ref(IS_AUTHORIZED_FLAG)), methodCall(ref('util.isNull'), ref('ctx.stash.authFilter'))]), ref('util.unauthorized()')), - ); - return printBlock('Authorization Steps')(compoundExpression([...totalAuthExpressions, emptyPayload])); -}; - export const generateAuthExpressionForRelationQuery = ( providers: ConfiguredAuthProviders, roles: Array, @@ -406,7 +319,7 @@ export const generateAuthExpressionForRelationQuery = ( equals(ref('util.authType()'), str(COGNITO_AUTH_TYPE)), compoundExpression([ ...generateStaticRoleExpression(cogntoStaticRoles), - ...generateAuthFilter(getNonPrimaryFieldRoles(cognitoDynamicRoles), fields, 'dynamodb'), + ...generateAuthFilter(getNonPrimaryFieldRoles(cognitoDynamicRoles), fields), ...generateAuthOnRelationalModelQueryExpression(cognitoDynamicRoles, primaryFieldMap), ]), ), @@ -418,7 +331,7 @@ export const generateAuthExpressionForRelationQuery = ( equals(ref('util.authType()'), str(OIDC_AUTH_TYPE)), compoundExpression([ ...generateStaticRoleExpression(oidcStaticRoles), - ...generateAuthFilter(getNonPrimaryFieldRoles(oidcDynamicRoles), fields, 'dynamodb'), + ...generateAuthFilter(getNonPrimaryFieldRoles(oidcDynamicRoles), fields), ...generateAuthOnRelationalModelQueryExpression(oidcDynamicRoles, primaryFieldMap), ]), ), diff --git a/packages/amplify-graphql-auth-transformer/src/resolvers/search.ts b/packages/amplify-graphql-auth-transformer/src/resolvers/search.ts new file mode 100644 index 00000000000..efcc06ef9ff --- /dev/null +++ b/packages/amplify-graphql-auth-transformer/src/resolvers/search.ts @@ -0,0 +1,271 @@ +import { FieldDefinitionNode } from 'graphql'; +import { + compoundExpression, + Expression, + obj, + printBlock, + and, + equals, + notEquals, + iff, + methodCall, + not, + ref, + str, + bool, + forEach, + list, + qref, + raw, + set, + ifElse, + or, +} from 'graphql-mapping-template'; +import { getIdentityClaimExp, getOwnerClaim, emptyPayload, setHasAuthExpression, iamCheck } from './helpers'; +import { + COGNITO_AUTH_TYPE, + OIDC_AUTH_TYPE, + RoleDefinition, + splitRoles, + ConfiguredAuthProviders, + IS_AUTHORIZED_FLAG, + fieldIsList, + ADMIN_ROLE, + API_KEY_AUTH_TYPE, + IAM_AUTH_TYPE, + MANAGE_ROLE, +} from '../utils'; +import { NONE_VALUE } from 'graphql-transformer-common'; + +const allowedAggFieldsList = 'allowedAggFields'; +const aggFieldsFilterMap = 'aggFieldsFilterMap'; +const totalFields = 'totalFields'; + +const apiKeyExpression = (roles: Array): Expression => { + const expression = Array(); + if (roles.length === 0) { + expression.push(ref('util.unauthorized()')); + } else if (roles[0].allowedFields) { + expression.push( + set(ref(IS_AUTHORIZED_FLAG), bool(true)), + qref(methodCall(ref(`${allowedAggFieldsList}.addAll`), raw(JSON.stringify(roles[0].allowedFields)))), + ); + } else { + expression.push(set(ref(IS_AUTHORIZED_FLAG), bool(true)), set(ref(allowedAggFieldsList), ref(totalFields))); + } + return iff(equals(ref('util.authType()'), str(API_KEY_AUTH_TYPE)), compoundExpression(expression)); +}; + +const iamExpression = (roles: Array, adminuiEnabled: boolean = false, adminUserPoolID?: string) => { + const expression = new Array(); + // allow if using admin ui + if (adminuiEnabled) { + expression.push( + iff( + or([ + methodCall(ref('ctx.identity.userArn.contains'), str(`${adminUserPoolID}${ADMIN_ROLE}`)), + methodCall(ref('ctx.identity.userArn.contains'), str(`${adminUserPoolID}${MANAGE_ROLE}`)), + ]), + raw('#return($util.toJson({})'), + ), + ); + } + if (roles.length === 0) { + expression.push(ref('util.unauthorized()')); + } else { + for (let role of roles) { + const exp: Expression[] = [set(ref(IS_AUTHORIZED_FLAG), bool(true))]; + if (role.allowedFields) { + exp.push(qref(methodCall(ref(`${allowedAggFieldsList}.addAll`), raw(JSON.stringify(role.allowedFields))))); + } else { + exp.push(set(ref(allowedAggFieldsList), ref(totalFields))); + } + expression.push(iff(not(ref(IS_AUTHORIZED_FLAG)), iamCheck(role.claim!, compoundExpression(exp)))); + } + } + return iff(equals(ref('util.authType()'), str(IAM_AUTH_TYPE)), compoundExpression(expression)); +}; + +const generateStaticRoleExpression = (roles: Array): Array => { + const staticRoleExpression: Array = []; + let privateRoleIdx = roles.findIndex(r => r.strategy === 'private'); + if (privateRoleIdx > -1) { + if (roles[privateRoleIdx].allowedFields) { + staticRoleExpression.push( + qref(methodCall(ref(`${allowedAggFieldsList}.addAll`), raw(JSON.stringify(roles[privateRoleIdx].allowedFields)))), + ); + } else { + staticRoleExpression.push(set(ref(allowedAggFieldsList), ref(totalFields))); + } + staticRoleExpression.push(set(ref(IS_AUTHORIZED_FLAG), bool(true))); + roles.splice(privateRoleIdx, 1); + } + if (roles.length > 0) { + staticRoleExpression.push( + iff( + not(ref(IS_AUTHORIZED_FLAG)), + compoundExpression([ + set( + ref('staticGroupRoles'), + raw( + JSON.stringify( + roles.map(r => ({ claim: r.claim, entity: r.entity, ...(r.allowedFields ? { allowedFields: r.allowedFields } : {}) })), + ), + ), + ), + forEach(ref('groupRole'), ref('staticGroupRoles'), [ + set(ref('groupsInToken'), getIdentityClaimExp(ref('groupRole.claim'), list([]))), + iff( + methodCall(ref('groupsInToken.contains'), ref('groupRole.entity')), + compoundExpression([ + set(ref(IS_AUTHORIZED_FLAG), bool(true)), + ifElse( + methodCall(ref('util.isNull'), ref('groupRole.allowedFields')), + compoundExpression([set(ref(allowedAggFieldsList), ref(totalFields)), raw(`#break`)]), + qref(methodCall(ref(`${allowedAggFieldsList}.addAll`), ref('groupRole.allowedFields'))), + ), + ]), + ), + ]), + ]), + ), + ); + } + return staticRoleExpression; +}; + +const generateAuthFilter = ( + roles: Array, + fields: ReadonlyArray, + allowedAggFields: Array, +): Array => { + const filterExpression = new Array(); + const authFilter = new Array(); + const aggFieldMap: Record> = {}; + if (!(roles.length > 0)) return []; + /** + * for opensearch + * we create a terms_set where the field (role.entity) has to match at least element in the terms + * if the field is a list it will look for a subset of elements in the list which should exist in the terms list + * */ + roles.forEach((role, idx) => { + // for the terms search it's best to go by keyword for non list dynamic auth fields + const entityIsList = fieldIsList(fields, role.entity); + const roleKey = entityIsList ? role.entity : `${role.entity}.keyword`; + if (role.strategy === 'owner') { + filterExpression.push( + set( + ref(`owner${idx}`), + obj({ + terms_set: obj({ + [roleKey]: obj({ + terms: list([getOwnerClaim(role.claim!)]), + minimum_should_match_script: obj({ source: str('1') }), + }), + }), + }), + ), + ); + authFilter.push(ref(`owner${idx}`)); + if (role.allowedFields) { + role.allowedFields.forEach(field => { + if (!allowedAggFields.includes(field)) { + aggFieldMap[field] = [...(aggFieldMap[field] ?? []), `$owner${idx}`]; + } + }); + } + } else if (role.strategy === 'groups') { + filterExpression.push( + set( + ref(`group${idx}`), + obj({ + terms_set: obj({ + [roleKey]: obj({ + terms: getIdentityClaimExp(str(role.claim!), list([str(NONE_VALUE)])), + minimum_should_match_script: obj({ source: str('1') }), + }), + }), + }), + ), + ); + authFilter.push(ref(`group${idx}`)); + if (role.allowedFields) { + role.allowedFields.forEach(field => { + if (!allowedAggFields.includes(field)) { + aggFieldMap[field] = [...(aggFieldMap[field] ?? []), `$group${idx}`]; + } + }); + } + } + }); + filterExpression.push( + iff( + not(ref(IS_AUTHORIZED_FLAG)), + qref(methodCall(ref('ctx.stash.put'), str('authFilter'), obj({ bool: obj({ should: list(authFilter) }) }))), + ), + ); + if (Object.keys(aggFieldMap).length > 0) { + filterExpression.push( + iff( + notEquals(ref(`${allowedAggFieldsList}.size()`), ref(`${totalFields}.size()`)), + // regex is there so we can remove the quotes from the array values in VTL as they contain objects + // ex. "$owner0" to $owner0 + qref(methodCall(ref('ctx.stash.put'), str(aggFieldsFilterMap), raw(JSON.stringify(aggFieldMap).replace(/"\$(.*?)"/g, '$$$1')))), + ), + ); + } + return filterExpression; +}; + +/* +creates the auth expression for searchable +- handles object level search query +- creates field auth expression for aggregation query +*/ +export const generateAuthExpressionForSearchQueries = ( + providers: ConfiguredAuthProviders, + roles: Array, + fields: ReadonlyArray, + allowedAggFields: Array, +): string => { + const { cogntoStaticRoles, cognitoDynamicRoles, oidcStaticRoles, oidcDynamicRoles, apiKeyRoles, iamRoles } = splitRoles(roles); + const totalAuthExpressions: Array = [ + setHasAuthExpression, + set(ref(IS_AUTHORIZED_FLAG), bool(false)), + set(ref(totalFields), raw(JSON.stringify(fields.map(f => f.name.value)))), + set(ref(allowedAggFieldsList), raw(JSON.stringify(allowedAggFields))), + ]; + if (providers.hasApiKey) { + totalAuthExpressions.push(apiKeyExpression(apiKeyRoles)); + } + if (providers.hasIAM) { + totalAuthExpressions.push(iamExpression(iamRoles, providers.hasAdminUIEnabled, providers.adminUserPoolID)); + } + if (providers.hasUserPools) { + totalAuthExpressions.push( + iff( + equals(ref('util.authType()'), str(COGNITO_AUTH_TYPE)), + compoundExpression([ + ...generateStaticRoleExpression(cogntoStaticRoles), + ...generateAuthFilter(cognitoDynamicRoles, fields, allowedAggFields), + ]), + ), + ); + } + if (providers.hasOIDC) { + totalAuthExpressions.push( + iff( + equals(ref('util.authType()'), str(OIDC_AUTH_TYPE)), + compoundExpression([ + ...generateStaticRoleExpression(oidcStaticRoles), + ...generateAuthFilter(oidcDynamicRoles, fields, allowedAggFields), + ]), + ), + ); + } + totalAuthExpressions.push( + qref(methodCall(ref('ctx.stash.put'), str(allowedAggFieldsList), ref(allowedAggFieldsList))), + iff(and([not(ref(IS_AUTHORIZED_FLAG)), methodCall(ref('util.isNull'), ref('ctx.stash.authFilter'))]), ref('util.unauthorized()')), + ); + return printBlock('Authorization Steps')(compoundExpression([...totalAuthExpressions, emptyPayload])); +}; diff --git a/packages/amplify-graphql-auth-transformer/src/utils/constants.ts b/packages/amplify-graphql-auth-transformer/src/utils/constants.ts index 0abe7368518..85f62f075d4 100644 --- a/packages/amplify-graphql-auth-transformer/src/utils/constants.ts +++ b/packages/amplify-graphql-auth-transformer/src/utils/constants.ts @@ -32,3 +32,10 @@ export const MANAGE_ROLE = '_Manage-only/CognitoIdentityCredentials'; export const NONE_DS = 'NONE_DS'; // relational directives export const RELATIONAL_DIRECTIVES = ['hasOne', 'belongsTo', 'hasMany', 'manyToMany']; +// searchable directive +export const SEARCHABLE_AGGREGATE_TYPES = [ + 'SearchableAggregateResult', + 'SearchableAggregateScalarResult', + 'SearchableAggregateBucketResult', + 'SearchableAggregateBucketResultItem', +]; diff --git a/packages/amplify-graphql-auth-transformer/src/utils/definitions.ts b/packages/amplify-graphql-auth-transformer/src/utils/definitions.ts index e8a75a1339a..abeefde7257 100644 --- a/packages/amplify-graphql-auth-transformer/src/utils/definitions.ts +++ b/packages/amplify-graphql-auth-transformer/src/utils/definitions.ts @@ -4,7 +4,6 @@ export type AuthProvider = 'apiKey' | 'iam' | 'oidc' | 'userPools'; export type ModelQuery = 'get' | 'list'; export type ModelMutation = 'create' | 'update' | 'delete'; export type ModelOperation = 'create' | 'update' | 'delete' | 'read'; -export type QuerySource = 'dynamodb' | 'opensearch'; export type RelationalPrimaryMapConfig = Map; export interface SearchableConfig { diff --git a/packages/amplify-graphql-searchable-transformer/src/__tests__/__snapshots__/amplify-graphql-searchable-transformer.tests.ts.snap b/packages/amplify-graphql-searchable-transformer/src/__tests__/__snapshots__/amplify-graphql-searchable-transformer.tests.ts.snap index e96f3e030b8..7fec1a00e9f 100644 --- a/packages/amplify-graphql-searchable-transformer/src/__tests__/__snapshots__/amplify-graphql-searchable-transformer.tests.ts.snap +++ b/packages/amplify-graphql-searchable-transformer/src/__tests__/__snapshots__/amplify-graphql-searchable-transformer.tests.ts.snap @@ -1032,6 +1032,8 @@ $util.toJson($ListRequest) #end ## [End] ResponseTemplate. **", "Query.searchPosts.req.vtl": "#set( $indexPath = \\"/post/doc/_search\\" ) +#set( $allowedAggFields = $util.defaultIfNull($ctx.stash.allowedAggFields, []) ) +#set( $aggFieldsFilterMap = $util.defaultIfNull($ctx.stash.aggFieldsFilterMap, {}) ) #set( $nonKeywordFields = [] ) #set( $sortValues = [] ) #set( $aggregateValues = {} ) @@ -1068,10 +1070,17 @@ $util.toJson($ListRequest) #end #end #foreach( $aggItem in $context.args.aggregates ) + #if( $allowedAggFields.contains($aggItem.field) ) + #set( $aggFilter = { \\"match_all\\": {} } ) + #elseif( $aggFieldsFilterMap.containsKey($aggItem.field) ) + #set( $aggFilter = { \\"bool\\": { \\"should\\": $aggFieldsFilterMap.get($aggItem.field) } } ) + #else + $util.error(\\"Unauthorized to run aggregation on field: \${aggItem.field}\\", \\"Unauthorized\\") + #end #if( $nonKeywordFields.contains($aggItem.field) ) - $util.qr($aggregateValues.put(\\"$aggItem.name\\", {\\"$aggItem.type\\": {\\"field\\": \\"$aggItem.field\\"}})) + $util.qr($aggregateValues.put(\\"$aggItem.name\\", { \\"filter\\": $aggFilter, \\"aggs\\": { \\"$aggItem.name\\": { \\"$aggItem.type\\": { \\"field\\": \\"$aggItem.field\\" }}} })) #else - $util.qr($aggregateValues.put(\\"$aggItem.name\\", {\\"$aggItem.type\\": {\\"field\\": \\"\${aggItem.field}.keyword\\"}})) + $util.qr($aggregateValues.put(\\"$aggItem.name\\", { \\"filter\\": $aggFilter, \\"aggs\\": { \\"$aggItem.name\\": { \\"$aggItem.type\\": { \\"field\\": \\"\${aggItem.field}.keyword\\" }}} })) #end #end #if( !$util.isNullOrEmpty($ctx.stash.authFilter) ) @@ -1120,15 +1129,16 @@ $util.toJson($ListRequest) #foreach( $aggItem in $context.result.aggregations.keySet() ) #set( $aggResult = {} ) #set( $aggResultValue = {} ) + #set( $currentAggItem = $ctx.result.aggregations.get($aggItem) ) $util.qr($aggResult.put(\\"name\\", $aggItem)) - #if( !$util.isNullOrEmpty($context.result.aggregations) ) - #if( !$util.isNullOrEmpty($context.result.aggregations.get($aggItem).buckets) ) + #if( !$util.isNullOrEmpty($currentAggItem) ) + #if( !$util.isNullOrEmpty($currentAggItem.get($aggItem).buckets) ) $util.qr($aggResultValue.put(\\"__typename\\", \\"SearchableAggregateBucketResult\\")) - $util.qr($aggResultValue.put(\\"buckets\\", $context.result.aggregations.get($aggItem).buckets)) + $util.qr($aggResultValue.put(\\"buckets\\", $currentAggItem.get($aggItem).buckets)) #end - #if( !$util.isNullOrEmpty($context.result.aggregations.get($aggItem).value) ) + #if( !$util.isNullOrEmpty($currentAggItem.get($aggItem).value) ) $util.qr($aggResultValue.put(\\"__typename\\", \\"SearchableAggregateScalarResult\\")) - $util.qr($aggResultValue.put(\\"value\\", $context.result.aggregations.get($aggItem).value)) + $util.qr($aggResultValue.put(\\"value\\", $currentAggItem.get($aggItem).value)) #end #end $util.qr($aggResult.put(\\"result\\", $aggResultValue)) diff --git a/packages/amplify-graphql-searchable-transformer/src/generate-resolver-vtl.ts b/packages/amplify-graphql-searchable-transformer/src/generate-resolver-vtl.ts index e7244aed2d1..f9a84469383 100644 --- a/packages/amplify-graphql-searchable-transformer/src/generate-resolver-vtl.ts +++ b/packages/amplify-graphql-searchable-transformer/src/generate-resolver-vtl.ts @@ -28,6 +28,8 @@ export function requestTemplate(primaryKey: string, nonKeywordFields: Expression return print( compoundExpression([ set(ref('indexPath'), str(`/${type.toLowerCase()}/doc/_search`)), + set(ref('allowedAggFields'), methodCall(ref('util.defaultIfNull'), ref('ctx.stash.allowedAggFields'), list([]))), + set(ref('aggFieldsFilterMap'), methodCall(ref('util.defaultIfNull'), ref('ctx.stash.aggFieldsFilterMap'), obj({}))), set(ref('nonKeywordFields'), list(nonKeywordFields)), set(ref('sortValues'), list([])), set(ref('aggregateValues'), obj({})), @@ -66,10 +68,23 @@ export function requestTemplate(primaryKey: string, nonKeywordFields: Expression ]), ), forEach(ref('aggItem'), ref('context.args.aggregates'), [ + raw( + '#if( $allowedAggFields.contains($aggItem.field) )\n' + + ' #set( $aggFilter = { "match_all": {} } )\n' + + ' #elseif( $aggFieldsFilterMap.containsKey($aggItem.field) )\n' + + ' #set( $aggFilter = { "bool": { "should": $aggFieldsFilterMap.get($aggItem.field) } } )\n' + + ' #else\n' + + ' $util.error("Unauthorized to run aggregation on field: ${aggItem.field}", "Unauthorized")\n' + + ' #end', + ), ifElse( ref('nonKeywordFields.contains($aggItem.field)'), - qref('$aggregateValues.put("$aggItem.name", {"$aggItem.type": {"field": "$aggItem.field"}})'), - qref('$aggregateValues.put("$aggItem.name", {"$aggItem.type": {"field": "${aggItem.field}.keyword"}})'), + qref( + '$aggregateValues.put("$aggItem.name", { "filter": $aggFilter, "aggs": { "$aggItem.name": { "$aggItem.type": { "field": "$aggItem.field" }}} })', + ), + qref( + '$aggregateValues.put("$aggItem.name", { "filter": $aggFilter, "aggs": { "$aggItem.name": { "$aggItem.type": { "field": "${aggItem.field}.keyword" }}} })', + ), ), ]), ifElse( @@ -123,22 +138,23 @@ export function responseTemplate(includeVersion = false) { forEach(ref('aggItem'), ref('context.result.aggregations.keySet()'), [ set(ref('aggResult'), obj({})), set(ref('aggResultValue'), obj({})), + set(ref('currentAggItem'), ref('ctx.result.aggregations.get($aggItem)')), qref('$aggResult.put("name", $aggItem)'), iff( - raw('!$util.isNullOrEmpty($context.result.aggregations)'), + raw('!$util.isNullOrEmpty($currentAggItem)'), compoundExpression([ iff( - raw('!$util.isNullOrEmpty($context.result.aggregations.get($aggItem).buckets)'), + raw('!$util.isNullOrEmpty($currentAggItem.get($aggItem).buckets)'), compoundExpression([ qref('$aggResultValue.put("__typename", "SearchableAggregateBucketResult")'), - qref('$aggResultValue.put("buckets", $context.result.aggregations.get($aggItem).buckets)'), + qref('$aggResultValue.put("buckets", $currentAggItem.get($aggItem).buckets)'), ]), ), iff( - raw('!$util.isNullOrEmpty($context.result.aggregations.get($aggItem).value)'), + raw('!$util.isNullOrEmpty($currentAggItem.get($aggItem).value)'), compoundExpression([ qref('$aggResultValue.put("__typename", "SearchableAggregateScalarResult")'), - qref('$aggResultValue.put("value", $context.result.aggregations.get($aggItem).value)'), + qref('$aggResultValue.put("value", $currentAggItem.get($aggItem).value)'), ]), ), ]), diff --git a/packages/graphql-transformers-e2e-tests/src/__tests__/SearchableWithAuthV2.e2e.test.ts b/packages/graphql-transformers-e2e-tests/src/__tests__/SearchableWithAuthV2.e2e.test.ts index 93a9482bfbf..a4918323d9c 100644 --- a/packages/graphql-transformers-e2e-tests/src/__tests__/SearchableWithAuthV2.e2e.test.ts +++ b/packages/graphql-transformers-e2e-tests/src/__tests__/SearchableWithAuthV2.e2e.test.ts @@ -70,6 +70,11 @@ let GRAPHQL_CLIENT_2: AWSAppSyncClient = undefined; */ let GRAPHQL_CLIENT_3: AWSAppSyncClient = undefined; +/** + * Client 4 is logged in and is a member of the writer group + */ +let GRAPHQL_CLIENT_4: AWSAppSyncClient = undefined; + /** * Auth IAM Client */ @@ -83,6 +88,7 @@ let GRAPHQL_APIKEY_CLIENT: AWSAppSyncClient = undefined; const USERNAME1 = 'user1@test.com'; const USERNAME2 = 'user2@test.com'; const USERNAME3 = 'user3@test.com'; +const USERNAME4 = 'user4@test.com'; const TMP_PASSWORD = 'Password123!'; const REAL_PASSWORD = 'Password1234!'; const WRITER_GROUP_NAME = 'writer'; @@ -103,26 +109,46 @@ beforeAll(async () => { } # only users in the admin group are authorized to view entries in DynamicContent type Todo @model - @searchable - @auth(rules: [ - { allow: groups, groupsField: "groups"} - ]) { - id: ID! - groups: String - content: String - } + @searchable + @auth(rules: [ + { allow: groups, groupsField: "groups"} + ]) { + id: ID! + groups: String + content: String + } # users with apikey perform crud operations on Post except for secret # only users with auth role (iam) can view the secret + # only private iam roles are allowed to run aggregations type Post @model - @searchable - @auth(rules: [ - { allow: public, provider: apiKey } - { allow: private, provider: iam } - ]) { - id: ID! - content: String - secret: String @auth(rules: [{ allow: private, provider: iam }]) - }`; + @searchable + @auth(rules: [ + { allow: public, provider: apiKey } + { allow: private, provider: iam } + ]) { + id: ID! + content: String + secret: String @auth(rules: [{ allow: private, provider: iam }]) + } + # only allow static group and dynamic group to have access on field + type Blog + @model + @searchable + @auth(rules: [{ allow: owner }, { allow: groups, groups: ["admin"] }, { allow: groups, groupsField: "groupsField" }]) { + id: ID! + title: String + ups: Int + downs: Int + percentageUp: Float + isPublished: Boolean + createdAt: AWSDateTime + updatedAt: AWSDateTime + owner: String + groupsField: String + # as a member of admin and member within groupsField I can run aggregations on secret + secret: String @auth(rules: [{ allow: groups, groups: ["admin"] }, { allow: groups, groupsField: "groupsField" }]) + } + `; const transformer = new GraphQLTransform({ authConfig: { defaultAuthentication: { @@ -192,8 +218,10 @@ beforeAll(async () => { await signupUser(USER_POOL_ID, USERNAME1, TMP_PASSWORD); await signupUser(USER_POOL_ID, USERNAME2, TMP_PASSWORD); await signupUser(USER_POOL_ID, USERNAME3, TMP_PASSWORD); + await signupUser(USER_POOL_ID, USERNAME4, TMP_PASSWORD); await createGroup(USER_POOL_ID, WRITER_GROUP_NAME); await createGroup(USER_POOL_ID, ADMIN_GROUP_NAME); + await addUserToGroup(WRITER_GROUP_NAME, USERNAME4, USER_POOL_ID); await addUserToGroup(WRITER_GROUP_NAME, USERNAME2, USER_POOL_ID); await addUserToGroup(ADMIN_GROUP_NAME, USERNAME2, USER_POOL_ID); @@ -233,7 +261,19 @@ beforeAll(async () => { }, }); - // clear previous signed in user + const authRes4: any = await authenticateUser(USERNAME4, TMP_PASSWORD, REAL_PASSWORD); + const idToken4 = authRes4.getIdToken().getJwtToken(); + GRAPHQL_CLIENT_4 = new AWSAppSyncClient({ + url: GRAPHQL_ENDPOINT, + region: AWS_REGION, + disableOffline: true, + auth: { + type: AUTH_TYPE.AMAZON_COGNITO_USER_POOLS, + jwtToken: () => idToken4, + }, + }); + + // sign out previous cognito user await Auth.signOut(); await Auth.signIn(USERNAME1, REAL_PASSWORD); const authCreds = await Auth.currentCredentials(); @@ -523,6 +563,234 @@ test('test post as an cognito user that is not allowed in this schema', async () } }); +test('test that apikey is not allowed to query aggregations on secret for post', async () => { + try { + await GRAPHQL_APIKEY_CLIENT.query({ + query: gql` + query aggSearch { + searchPosts(aggregates: [{ name: "Terms", type: terms, field: secret }]) { + aggregateItems { + name + result { + ... on SearchableAggregateBucketResult { + buckets { + doc_count + key + } + } + } + } + } + } + `, + }); + } catch (err) { + expect(err.graphQLErrors[0].errorType).toEqual('Unauthorized'); + expect(err.graphQLErrors[0].message).toEqual('Unauthorized to run aggregation on field: secret'); + } +}); + +test('test that iam can run aggregations on secret field', async () => { + try { + const response: any = await GRAPHQL_IAM_AUTH_CLIENT.query({ + query: gql` + query aggSearch { + searchPosts(aggregates: [{ name: "Terms", type: terms, field: secret }]) { + aggregateItems { + name + result { + ... on SearchableAggregateBucketResult { + buckets { + doc_count + key + } + } + } + } + } + } + `, + }); + expect(response.data.searchPosts).toBeDefined(); + expect(response.data.searchPosts.aggregateItems).toHaveLength(1); + const aggregateItem = response.data.searchPosts.aggregateItems[0]; + expect(aggregateItem.name).toEqual('Terms'); + expect(aggregateItem.result.buckets).toHaveLength(3); + expect(aggregateItem.result.buckets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + doc_count: 1, + key: 'post1secret', + }), + expect.objectContaining({ + doc_count: 1, + key: 'post2secret', + }), + expect.objectContaining({ + doc_count: 1, + key: 'post3secret', + }), + ]), + ); + } catch (err) { + expect(err).not.toBeDefined(); + } +}); + +test('test that admin can run aggregate query on protected field', async () => { + try { + const response: any = await GRAPHQL_CLIENT_2.query({ + query: gql` + query { + searchBlogs(aggregates: [{ name: "Terms", type: terms, field: secret }]) { + aggregateItems { + name + result { + ... on SearchableAggregateBucketResult { + buckets { + doc_count + key + } + } + } + } + } + } + `, + }); + expect(response.data.searchBlogs).toBeDefined(); + expect(response.data.searchBlogs.aggregateItems); + const aggregateItem = response.data.searchBlogs.aggregateItems[0]; + expect(aggregateItem.name).toEqual('Terms'); + expect(aggregateItem.result.buckets).toHaveLength(2); + expect(aggregateItem.result.buckets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + doc_count: 2, + key: `${USERNAME1}secret`, + }), + expect.objectContaining({ + doc_count: 2, + key: `${USERNAME4}secret`, + }), + ]), + ); + } catch (err) { + expect(err).not.toBeDefined(); + } +}); + +test('test that member in writer group has writer group auth when running aggregate query', async () => { + try { + const response: any = await GRAPHQL_CLIENT_4.query({ + query: gql` + query { + searchBlogs(aggregates: [{ name: "Terms", type: terms, field: secret }]) { + aggregateItems { + name + result { + ... on SearchableAggregateBucketResult { + buckets { + doc_count + key + } + } + } + } + } + } + `, + }); + expect(response.data.searchBlogs).toBeDefined(); + expect(response.data.searchBlogs.aggregateItems); + const aggregateItem = response.data.searchBlogs.aggregateItems[0]; + expect(aggregateItem.name).toEqual('Terms'); + expect(aggregateItem.result.buckets).toHaveLength(2); + expect(aggregateItem.result.buckets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + doc_count: 2, + key: `${USERNAME1}secret`, + }), + expect.objectContaining({ + doc_count: 1, + key: `${USERNAME4}secret`, + }), + ]), + ); + } catch (err) { + expect(err).not.toBeDefined(); + } +}); + +test('test that an owner does not get any results for the agg query on the secret field', async () => { + try { + const response: any = await GRAPHQL_CLIENT_1.query({ + query: gql` + query { + searchBlogs(aggregates: [{ name: "Terms", type: terms, field: secret }]) { + aggregateItems { + name + result { + ... on SearchableAggregateBucketResult { + buckets { + doc_count + key + } + } + } + } + } + } + `, + }); + expect(response.data.searchBlogs).toBeDefined(); + expect(response.data.searchBlogs.aggregateItems); + const aggregateItem = response.data.searchBlogs.aggregateItems[0]; + expect(aggregateItem.name).toEqual('Terms'); + expect(aggregateItem.result.buckets).toHaveLength(0); + } catch (err) { + expect(err).not.toBeDefined(); + } +}); +test('test that an owner can run aggregations on records which belong to them', async () => { + try { + const response: any = await GRAPHQL_CLIENT_1.query({ + query: gql` + query { + searchBlogs(aggregates: [{ name: "Terms", type: terms, field: title }]) { + aggregateItems { + name + result { + ... on SearchableAggregateBucketResult { + buckets { + doc_count + key + } + } + } + } + } + } + `, + }); + expect(response.data.searchBlogs).toBeDefined(); + expect(response.data.searchBlogs.aggregateItems); + const aggregateItem = response.data.searchBlogs.aggregateItems[0]; + expect(aggregateItem.name).toEqual('Terms'); + expect(aggregateItem.result.buckets).toHaveLength(1); + expect(aggregateItem.result.buckets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + doc_count: 2, + key: 'cooking', + }), + ]), + ); + } catch (err) { + expect(err).not.toBeDefined(); + } +}); /** * Input types * */ @@ -542,6 +810,14 @@ type CreatePostInput = { content?: string | null; secret?: string | null; }; +export type CreateBlogInput = { + id?: string; + title?: string; + ups?: number; + owner?: string; + groupsField?: string; + secret?: string; +}; // mutations async function createComment(client: AWSAppSyncClient, input: CreateCommentInput) { @@ -580,6 +856,27 @@ async function createPost(client: AWSAppSyncClient, input: CreatePostInput) return await client.mutate({ mutation: create, variables: { input } }); } +async function createBlog(client: AWSAppSyncClient, input: CreateBlogInput) { + const create = gql` + mutation CreateBlog($input: CreateBlogInput!) { + createBlog(input: $input) { + id + title + ups + downs + percentageUp + isPublished + createdAt + updatedAt + owner + groupsField + secret + } + } + `; + return await client.mutate({ mutation: create, variables: { input } }); +} + const createEntries = async () => { await createComment(GRAPHQL_CLIENT_1, { content: 'ownerContent', @@ -594,6 +891,28 @@ const createEntries = async () => { await createTodo(GRAPHQL_CLIENT_2, { groups: 'admin', content: `adminContent${i}` }); await createPost(GRAPHQL_IAM_AUTH_CLIENT, { content: `post${i}`, secret: `post${i}secret` }); } + await createBlog(GRAPHQL_CLIENT_2, { + groupsField: WRITER_GROUP_NAME, + owner: USERNAME1, + secret: `${USERNAME1}secret`, + ups: 10, + title: 'cooking', + }); + await createBlog(GRAPHQL_CLIENT_2, { + groupsField: WRITER_GROUP_NAME, + owner: USERNAME1, + secret: `${USERNAME1}secret`, + ups: 10, + title: 'cooking', + }); + await createBlog(GRAPHQL_CLIENT_2, { + groupsField: WRITER_GROUP_NAME, + owner: USERNAME4, + secret: `${USERNAME4}secret`, + ups: 25, + title: 'golfing', + }); + await createBlog(GRAPHQL_CLIENT_2, { groupsField: 'editor', owner: USERNAME4, secret: `${USERNAME4}secret`, ups: 10, title: 'cooking' }); // Waiting for the ES Cluster + Streaming Lambda infra to be setup await cf.wait(120, () => Promise.resolve()); };