Skip to content

Commit

Permalink
feat: add @auth (#1)
Browse files Browse the repository at this point in the history
* feat: add @auth base package with Access Control

* feat: graphql auth v2 add schemaChanges, iam policy generation, and query/read resolvers

* feat: graphql auth v2 add auth on mutation and subscription resolvers

* feat(amplify-category-api): add global sandbox mode directive on schema generation (aws-amplify#8074)

* feat(amplify-category-api): add global sandbox mode directive on schema generation

* test(amplify-e2e-tests): add e2e tests for sandbox mode

* test(amplify-category-api): add unit test for generating sandbox mode directive; rm unused method

* feat(cli): add sandbox mode warning to amplify status (aws-amplify#8078)

* feat(amplify-category-api): prompt api key creation on amplify push (aws-amplify#8124)

* feat(amplify-category-api): prompt api key create when invalid with sandbox mode

* test(amplify-category-api): add unit tests for provider utils

* test(amplify-category-api): fix test for adding api key prompt

* refactor(cli): refactor api key prompt

* refactor(amplify-category-api): add api key with gql compiled

* feat: @model conflict resolution

* auth directive support for index, searchable, predictions, functions, and relational directives (aws-amplify#8146)

* feat: add support for index and updated unit and e2e tests

* feat: directive suport for functions, predictions, searchable, and relational

* test: updated unit tests for updated auth on directives

* @auth support for datastore and add has auth flag (aws-amplify#8168)

* feat: @auth v2 on datastore and updated unit tests
* feat: add hasAuthFlag

* feat(graphql-model-transformer): set up transformer for sandbox mode directive (aws-amplify#8138)

* feat(graphql-model-transformer): add sandbox mode support to model transformer

* refactor(graphql-transformer-core): do not persist sandbox mode meta data

* fix: add command to show access control and field auth evaluation in access control (aws-amplify#8174)

* fix: admin ui app state check and auth transformer index resolver name (aws-amplify#8175)

* fix: has auth typo and qref on field conditions for private rule (aws-amplify#8180)

* fix(graphql-model-transformer): use hasAuth flag when sandbox mode is disabled (aws-amplify#8179)

* fix: update hasMany to use join table name, sync config warning, updated unit test

* fix: add empty payload for sandbox mode

* fix: snapshot test for @searchable

* fix: udpated snapshot for index and relation directives

* fix: use same none datasource name as resolver manager

* fix: iam resolver check and relational payload (aws-amplify#8234)

* fix: add datastore query in config for auth (aws-amplify#8246)

* fix: auth filter expression (aws-amplify#8248)

* fix: update iam auth to include roles in before template (aws-amplify#8259)

* chore: rebase and update auth dependencies

* fix(graphql-model-transformer): iam role name does not exceed 64 characters

* fix: add base e2e tests with auth fixes

Co-authored-by: Danielle Adams <[email protected]>
Co-authored-by: lazpavel <[email protected]>
  • Loading branch information
3 people committed Sep 27, 2021
1 parent c67f192 commit 4808b91
Show file tree
Hide file tree
Showing 116 changed files with 18,129 additions and 1,621 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Object {
},
Object {
"apiKeyConfig": Object {
"apiKeyExpirationDate": undefined,
"apiKeyExpirationDays": undefined,
"description": undefined,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jest.mock('../../../provider-utils/awscloudformation/utils/amplify-meta-utils',

jest.mock('amplify-cli-core');

const fs_mock = (fs as unknown) as jest.Mocked<typeof fs>;
const fs_mock = fs as unknown as jest.Mocked<typeof fs>;
const writeTransformerConfiguration_mock = writeTransformerConfiguration as jest.MockedFunction<typeof writeTransformerConfiguration>;
const getAppSyncResourceName_mock = getAppSyncResourceName as jest.MockedFunction<typeof getAppSyncResourceName>;
const getAppSyncAuthConfig_mock = getAppSyncAuthConfig as jest.MockedFunction<typeof getAppSyncAuthConfig>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { $TSContext } from 'amplify-cli-core';
import * as prompts from 'amplify-prompts';
import { promptToAddApiKey } from '../../../provider-utils/awscloudformation/prompt-to-add-api-key';
import * as walkthrough from '../../../provider-utils/awscloudformation/service-walkthroughs/appSync-walkthrough';
import * as cfnApiArtifactHandler from '../../../provider-utils/awscloudformation/cfn-api-artifact-handler';

jest.mock('../../../provider-utils/awscloudformation/service-walkthroughs/appSync-walkthrough', () => ({
askApiKeyQuestions: jest.fn(),
}));

jest.mock('../../../provider-utils/awscloudformation/cfn-api-artifact-handler', () => ({
getCfnApiArtifactHandler: jest.fn(() => {
return { updateArtifacts: jest.fn() };
}),
}));

jest.mock('amplify-prompts', () => ({
prompter: {
confirmContinue: jest.fn().mockImplementation(() => true),
},
}));

describe('prompt to add Api Key', () => {
it('runs through expected user flow: print info, update files', async () => {
const envName = 'envone';
const ctx = {
amplify: {
getEnvInfo() {
return { envName };
},
},
} as unknown as $TSContext;

jest.spyOn(prompts.prompter, 'confirmContinue');
jest.spyOn(walkthrough, 'askApiKeyQuestions');
jest.spyOn(cfnApiArtifactHandler, 'getCfnApiArtifactHandler');

await promptToAddApiKey(ctx);

expect(prompts.prompter.confirmContinue).toHaveBeenCalledWith('Would you like to create an API Key?');
expect(walkthrough.askApiKeyQuestions).toHaveBeenCalledTimes(1);
expect(cfnApiArtifactHandler.getCfnApiArtifactHandler).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
askAdditionalAuthQuestions,
} from '../../../../provider-utils/awscloudformation/service-walkthroughs/appSync-walkthrough';
import { authConfigHasApiKey, getAppSyncAuthConfig } from '../../../../provider-utils/awscloudformation/utils/amplify-meta-utils';
import { FeatureFlags, CLIEnvironmentProvider, FeatureFlagRegistration } from 'amplify-cli-core';
import { FeatureFlags } from 'amplify-cli-core';
jest.mock('../../../../provider-utils/awscloudformation/utils/amplify-meta-utils', () => ({
getAppSyncAuthConfig: jest.fn(),
authConfigHasApiKey: jest.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Object {
exports[`AppSyncAuthType to authConfig maps API_KEY correctly 1`] = `
Object {
"apiKeyConfig": Object {
"apiKeyExpirationDate": undefined,
"apiKeyExpirationDays": 120,
"description": undefined,
},
Expand Down Expand Up @@ -47,6 +48,7 @@ Object {

exports[`authConfig to AppSyncAuthType maps API_KEY auth correctly 1`] = `
Object {
"apiKeyExpirationDate": undefined,
"expirationTime": 120,
"keyDescription": "api key description",
"mode": "API_KEY",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { defineGlobalSandboxMode } from '../../../../provider-utils/awscloudformation/utils/global-sandbox-mode';
import { $TSContext } from 'amplify-cli-core';

describe('global sandbox mode GraphQL directive', () => {
it('returns AMPLIFY_DIRECTIVE type with code comment, directive, and env name', () => {
const envName = 'envone';
const ctx = <$TSContext>{
amplify: {
getEnvInfo() {
return { envName };
},
},
};

expect(defineGlobalSandboxMode(ctx))
.toBe(`# This allows public create, read, update, and delete access for a limited time to all models via API Key.
# To configure PRODUCTION-READY authorization rules, review: https://docs.amplify.aws/cli/graphql-transformer/auth
type AMPLIFY_GLOBAL @allow_public_data_access_with_api_key(in: \"${envName}\") # FOR TESTING ONLY!\n
`);
});
});
1 change: 1 addition & 0 deletions packages/amplify-category-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export {
processDockerConfig,
} from './provider-utils/awscloudformation/utils/containers-artifacts';
export { getContainers } from './provider-utils/awscloudformation/docker-compose';
export { promptToAddApiKey } from './provider-utils/awscloudformation/prompt-to-add-api-key';

const category = 'api';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isResourceNameUnique } from 'amplify-cli-core';
import { isResourceNameUnique, FeatureFlags } from 'amplify-cli-core';
import {
AddApiRequest,
AppSyncServiceConfiguration,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { $TSContext } from 'amplify-cli-core';
import { askApiKeyQuestions } from './service-walkthroughs/appSync-walkthrough';
import { authConfigToAppSyncAuthType } from './utils/auth-config-to-app-sync-auth-type-bi-di-mapper';
import { getCfnApiArtifactHandler } from './cfn-api-artifact-handler';
import { prompter } from 'amplify-prompts';

export async function promptToAddApiKey(context: $TSContext): Promise<void> {
if (await prompter.confirmContinue('Would you like to create an API Key?')) {
const apiKeyConfig = await askApiKeyQuestions();
const authConfig = [apiKeyConfig];

await getCfnApiArtifactHandler(context).updateArtifacts({
version: 1,
serviceModification: {
serviceName: 'AppSync',
additionalAuthTypes: authConfig.map(authConfigToAppSyncAuthType),
},
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
$TSContext,
open,
} from 'amplify-cli-core';
import { Duration, Expiration } from '@aws-cdk/core';
import { defineGlobalSandboxMode } from '../utils/global-sandbox-mode';

const serviceName = 'AppSync';
const elasticContainerServiceName = 'ElasticContainer';
Expand Down Expand Up @@ -215,7 +217,7 @@ const serviceApiInputWalkthrough = async (context: $TSContext, defaultValuesFile
authConfig = {
defaultAuthentication: {
apiKeyConfig: {
apiKeyExpirationDays: 7
apiKeyExpirationDays: 7,
},
authenticationType: 'API_KEY',
},
Expand All @@ -226,21 +228,22 @@ const serviceApiInputWalkthrough = async (context: $TSContext, defaultValuesFile
// Repeat prompt until user selects Continue
//
while (!continuePrompt) {

const getAuthModeChoice = async () => {
if (authConfig.defaultAuthentication.authenticationType === 'API_KEY') {
return `${authProviderChoices.find(choice => choice.value === authConfig.defaultAuthentication.authenticationType).name} (default, expiration time: ${authConfig.defaultAuthentication.apiKeyConfig.apiKeyExpirationDays} days from now)`;
return `${
authProviderChoices.find(choice => choice.value === authConfig.defaultAuthentication.authenticationType).name
} (default, expiration time: ${authConfig.defaultAuthentication.apiKeyConfig.apiKeyExpirationDays} days from now)`;
}
return `${authProviderChoices.find(choice => choice.value === authConfig.defaultAuthentication.authenticationType).name} (default)`;
};

const getAdditionalAuthModeChoices = async () => {
let additionalAuthModesText = '';
authConfig.additionalAuthenticationProviders.map(async (authMode) => {
additionalAuthModesText += `, ${authProviderChoices.find(choice => choice.value === authMode.authenticationType).name}`
authConfig.additionalAuthenticationProviders.map(async authMode => {
additionalAuthModesText += `, ${authProviderChoices.find(choice => choice.value === authMode.authenticationType).name}`;
});
return additionalAuthModesText;
}
};

const basicInfoQuestionChoices = [];

Expand All @@ -261,7 +264,9 @@ const serviceApiInputWalkthrough = async (context: $TSContext, defaultValuesFile

if (resolverConfig?.project) {
basicInfoQuestionChoices.push({
name: chalk`{bold Conflict resolution strategy:} ${conflictResolutionHanlderChoices.find(x => x.value === resolverConfig.project.ConflictHandler).name}`,
name: chalk`{bold Conflict resolution strategy:} ${
conflictResolutionHanlderChoices.find(x => x.value === resolverConfig.project.ConflictHandler).name
}`,
value: 'CONFLICT_STRATEGY',
});
}
Expand All @@ -281,7 +286,7 @@ const serviceApiInputWalkthrough = async (context: $TSContext, defaultValuesFile

let { basicApiSettings } = await inquirer.prompt([basicInfoQuestion]);

switch(basicApiSettings) {
switch (basicApiSettings) {
case 'API_NAME':
const resourceQuestions = [
{
Expand Down Expand Up @@ -323,7 +328,6 @@ const serviceApiInputWalkthrough = async (context: $TSContext, defaultValuesFile
},
resolverConfig,
};

};

const updateApiInputWalkthrough = async (context, project, resolverConfig, modelTypes) => {
Expand Down Expand Up @@ -380,6 +384,7 @@ const updateApiInputWalkthrough = async (context, project, resolverConfig, model

export const serviceWalkthrough = async (context: $TSContext, defaultValuesFilename, serviceMetadata) => {
const resourceName = resourceAlreadyExists(context);
const useExperimentalPipelineTransformer = FeatureFlags.getBoolean('graphQLTransformer.useExperimentalPipelinedTransformer');

if (resourceName) {
const errMessage =
Expand All @@ -397,7 +402,7 @@ export const serviceWalkthrough = async (context: $TSContext, defaultValuesFilen
let askToEdit = true;

// Schema template selection
const schemaTemplateOptions = FeatureFlags.getBoolean('graphQLTransformer.useExperimentalPipelinedTransformer') ? schemaTemplatesV2 : schemaTemplatesV1;
const schemaTemplateOptions = useExperimentalPipelineTransformer ? schemaTemplatesV2 : schemaTemplatesV1;
const templateSelectionQuestion = {
type: inputs[4].type,
name: inputs[4].key,
Expand All @@ -408,7 +413,8 @@ export const serviceWalkthrough = async (context: $TSContext, defaultValuesFilen

const { templateSelection } = await inquirer.prompt(templateSelectionQuestion);
const schemaFilePath = path.join(graphqlSchemaDir, templateSelection);
schemaContent = fs.readFileSync(schemaFilePath, 'utf8');
schemaContent += useExperimentalPipelineTransformer ? defineGlobalSandboxMode(context) : '';
schemaContent += fs.readFileSync(schemaFilePath, 'utf8');

return {
...basicInfoAnswers,
Expand Down Expand Up @@ -481,8 +487,10 @@ export const updateWalkthrough = async (context): Promise<UpdateApiRequest> => {

async function displayApiInformation(context, resource, project) {
let authModes: string[] = [];
authModes.push(`- Default: ${await displayAuthMode(context, resource, resource.output.authConfig.defaultAuthentication.authenticationType)}`);
await resource.output.authConfig.additionalAuthenticationProviders.map(async (authMode) => {
authModes.push(
`- Default: ${await displayAuthMode(context, resource, resource.output.authConfig.defaultAuthentication.authenticationType)}`,
);
await resource.output.authConfig.additionalAuthenticationProviders.map(async authMode => {
authModes.push(`- ${await displayAuthMode(context, resource, authMode.authenticationType)}`);
});

Expand All @@ -501,7 +509,11 @@ async function displayApiInformation(context, resource, project) {

context.print.success('Conflict detection (required for DataStore)');
if (project.config && !_.isEmpty(project.config.ResolverConfig)) {
context.print.info(`- Conflict resolution strategy: ${conflictResolutionHanlderChoices.find(choice => choice.value === project.config.ResolverConfig.project.ConflictHandler).name}`);
context.print.info(
`- Conflict resolution strategy: ${
conflictResolutionHanlderChoices.find(choice => choice.value === project.config.ResolverConfig.project.ConflictHandler).name
}`,
);
} else {
context.print.info('- Disabled');
}
Expand All @@ -519,7 +531,9 @@ async function displayAuthMode(context, resource, authMode) {
return authProviderChoices.find(choice => choice.value === authMode).name;
}
let apiKeyExpiresDate = new Date(apiKeyExpires * 1000);
return `${authProviderChoices.find(choice => choice.value === authMode).name} expiring ${apiKeyExpiresDate}: ${resource.output.GraphQLAPIKeyOutput}`;
return `${authProviderChoices.find(choice => choice.value === authMode).name} expiring ${apiKeyExpiresDate}: ${
resource.output.GraphQLAPIKeyOutput
}`;
}
return authProviderChoices.find(choice => choice.value === authMode).name;
}
Expand Down Expand Up @@ -668,9 +682,11 @@ export async function askAdditionalAuthQuestions(context, authConfig, defaultAut
if (await context.prompt.confirm('Configure additional auth types?')) {
// Get additional auth configured
const remainingAuthProviderChoices = authProviderChoices.filter(p => p.value !== defaultAuthType);
const currentAdditionalAuth = ((currentAuthConfig && currentAuthConfig.additionalAuthenticationProviders
? currentAuthConfig.additionalAuthenticationProviders
: []) as any[]).map(authProvider => authProvider.authenticationType);
const currentAdditionalAuth = (
(currentAuthConfig && currentAuthConfig.additionalAuthenticationProviders
? currentAuthConfig.additionalAuthenticationProviders
: []) as any[]
).map(authProvider => authProvider.authenticationType);

const additionalProvidersQuestion: CheckboxQuestion = {
type: 'checkbox',
Expand All @@ -685,7 +701,12 @@ export async function askAdditionalAuthQuestions(context, authConfig, defaultAut
for (let i = 0; i < additionalProvidersAnswer.authType.length; i += 1) {
const authProvider = additionalProvidersAnswer.authType[i];

const config = await askAuthQuestions(authProvider, context, true, currentAuthConfig?.additionalAuthenticationProviders?.find(authSetting => authSetting.authenticationType == authProvider));
const config = await askAuthQuestions(
authProvider,
context,
true,
currentAuthConfig?.additionalAuthenticationProviders?.find(authSetting => authSetting.authenticationType == authProvider),
);

authConfig.additionalAuthenticationProviders.push(config);
}
Expand Down Expand Up @@ -759,7 +780,7 @@ async function askUserPoolQuestions(context) {
};
}

async function askApiKeyQuestions(authSettings) {
export async function askApiKeyQuestions(authSettings = undefined) {
let defaultValues = {
apiKeyExpirationDays: 7,
description: undefined,
Expand All @@ -785,6 +806,8 @@ async function askApiKeyQuestions(authSettings) {
];

const apiKeyConfig = await inquirer.prompt(apiKeyQuestions);
const apiKeyExpirationDaysNum = Number(apiKeyConfig.apiKeyExpirationDays);
apiKeyConfig.apiKeyExpirationDate = Expiration.after(Duration.days(apiKeyExpirationDaysNum)).date;

return {
authenticationType: 'API_KEY',
Expand Down Expand Up @@ -857,9 +880,10 @@ function validateDays(input) {
}

function validateIssuerUrl(input) {
const isValid = /^(((?!http:\/\/(?!localhost))([a-zA-Z0-9.]{1,}):\/\/([a-zA-Z0-9-._~:?#@!$&'()*+,;=/]{1,})\/)|(?!http)(?!https)([a-zA-Z0-9.]{1,}):\/\/)$/.test(
input,
);
const isValid =
/^(((?!http:\/\/(?!localhost))([a-zA-Z0-9.]{1,}):\/\/([a-zA-Z0-9-._~:?#@!$&'()*+,;=/]{1,})\/)|(?!http)(?!https)([a-zA-Z0-9.]{1,}):\/\/)$/.test(
input,
);

if (!isValid) {
return 'The value must be a valid URI with a trailing forward slash. HTTPS must be used instead of HTTP unless you are using localhost.';
Expand Down Expand Up @@ -959,16 +983,17 @@ const buildPolicyResource = (resourceName: string, path: string | null) => {
{
Ref: `${category}${resourceName}GraphQLAPIIdOutput`,
},
...(path ? [path] : [])
]
...(path ? [path] : []),
],
],
};
};

const templateSchemaFilter = authConfig => {
const authIncludesCognito = getAuthTypes(authConfig).includes('AMAZON_COGNITO_USER_POOLS');
return (templateOption: ListChoiceOptions): boolean =>
authIncludesCognito || templateOption.name !== 'Objects with fine-grained access control (e.g., a project management app with owner-based authorization)';
authIncludesCognito ||
templateOption.name !== 'Objects with fine-grained access control (e.g., a project management app with owner-based authorization)';
};

const getAuthTypes = authConfig => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const authConfigToAppSyncAuthTypeMap: Record<string, (authConfig: any) => AppSyn
API_KEY: authConfig => ({
mode: 'API_KEY',
expirationTime: authConfig.apiKeyConfig.apiKeyExpirationDays,
apiKeyExpirationDate: authConfig.apiKeyConfig?.apiKeyExpirationDate,
keyDescription: authConfig.apiKeyConfig.description,
}),
AWS_IAM: () => ({
Expand All @@ -54,6 +55,7 @@ const appSyncAuthTypeToAuthConfigMap: Record<string, (authType: AppSyncAuthType)
authenticationType: 'API_KEY',
apiKeyConfig: {
apiKeyExpirationDays: authType.expirationTime,
apiKeyExpirationDate: authType?.apiKeyExpirationDate,
description: authType.keyDescription,
},
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { $TSContext } from 'amplify-cli-core';

export function defineGlobalSandboxMode(context: $TSContext): string {
const envName = context.amplify.getEnvInfo().envName;

return `# This allows public create, read, update, and delete access for a limited time to all models via API Key.
# To configure PRODUCTION-READY authorization rules, review: https://docs.amplify.aws/cli/graphql-transformer/auth
type AMPLIFY_GLOBAL @allow_public_data_access_with_api_key(in: \"${envName}\") # FOR TESTING ONLY!\n
`;
}
1 change: 1 addition & 0 deletions packages/amplify-cli-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ interface AmplifyToolkit {
) => $TSAny;
sharedQuestions: () => $TSAny;
showAllHelp: () => $TSAny;
showGlobalSandboxModeWarning: (context: $TSContext) => $TSAny;
showHelp: (header: string, commands: { name: string; description: string }[]) => $TSAny;
showHelpfulProviderLinks: (context: $TSContext) => $TSAny;
showResourceTable: () => $TSAny;
Expand Down
Loading

0 comments on commit 4808b91

Please sign in to comment.