diff --git a/API.md b/API.md index 7c9d104..e1008cb 100644 --- a/API.md +++ b/API.md @@ -966,6 +966,7 @@ Permits an IAM principal all write & read operations on the policy store: Create | fromPolicyStoreArn | Create a PolicyStore construct that represents an external PolicyStore via policy store arn. | | fromPolicyStoreAttributes | Creates a PolicyStore construct that represents an external Policy Store. | | fromPolicyStoreId | Create a PolicyStore construct that represents an external policy store via policy store id. | +| schemaFromCfTemplate | This method generates a schema based on a JSON CloudFormation template containing exactly one RestApi. | | schemaFromOpenApiSpec | This method generates a schema based on an swagger file. | --- @@ -1122,6 +1123,36 @@ The PolicyStore's id. --- +##### `schemaFromCfTemplate` + +```typescript +import { PolicyStore } from '@cdklabs/cdk-verified-permissions' + +PolicyStore.schemaFromCfTemplate(cfTemplateFilePath: string, groupEntityTypeName?: string) +``` + +This method generates a schema based on a JSON CloudFormation template containing exactly one RestApi. + +It makes the same assumptions and decisions made in the Amazon Verified Permissions console. + +###### `cfTemplateFilePath`Required + +- *Type:* string + +absolute path to a CloudFormation template file in the local directory structure, in json format. + +--- + +###### `groupEntityTypeName`Optional + +- *Type:* string + +optional parameter to specify the group entity type name. + +If passed, the schema's User type will have a parent of this type. + +--- + ##### `schemaFromOpenApiSpec` ```typescript diff --git a/README.md b/README.md index e46f51f..711005c 100644 --- a/README.md +++ b/README.md @@ -63,15 +63,37 @@ const policyStore = new PolicyStore(scope, "PolicyStore", { If you want to have type safety when defining a schema, you can accomplish this **only** in typescript. Simply use the `Schema` type exported by the `@cedar-policy/cedar-wasm`. -You can also generate a simple schema from a swagger file using the static function `schemaFromOpenApiSpec` in the PolicyStore construct. This functionality replicates what you can find in the AWS Verified Permissions console. +You can also generate simple schemas using the static functions `schemaFromOpenApiSpec` or `schemaFromCfTemplate` in the PolicyStore construct. This functionality replicates what you can find in the AWS Verified Permissions console. + +Generate a schema from an OpenAPI spec: ```ts const validationSettingsStrict = { mode: ValidationSettingsMode.STRICT, }; const cedarJsonSchema = PolicyStore.schemaFromOpenApiSpec( - 'path/to/swaggerfile.json', - 'UserGroup', + "path/to/swaggerfile.json", + "UserGroup" +); +const cedarSchema = { + cedarJson: JSON.stringify(cedarJsonSchema), +}; +const policyStore = new PolicyStore(scope, "PolicyStore", { + schema: cedarSchema, + validationSettings: validationSettingsStrict, + description: "Policy store with schema generated from API Gateway", +}); +``` + +Generate a schema from a CloudFormation template: + +```ts +const validationSettingsStrict = { + mode: ValidationSettingsMode.STRICT, +}; +const cedarJsonSchema = PolicyStore.schemaFromCfTemplate( + "path/to/cftemplate.json", + "UserGroup" ); const cedarSchema = { cedarJson: JSON.stringify(cedarJsonSchema), diff --git a/src/policy-store.ts b/src/policy-store.ts index 513c7e4..3e140d9 100644 --- a/src/policy-store.ts +++ b/src/policy-store.ts @@ -293,6 +293,78 @@ export class PolicyStore extends PolicyStoreBase { return buildSchema(namespace, actionNames, groupEntityTypeName); } + /** + * This method generates a schema based on a JSON CloudFormation template containing exactly one + * RestApi. It makes the same assumptions and decisions made in the Amazon Verified Permissions console. + * + * @param cfTemplateFilePath absolute path to a CloudFormation template file in the local directory structure, in json format + * @param groupEntityTypeName optional parameter to specify the group entity type name. If passed, the schema's User type will have a parent of this type. + */ + public static schemaFromCfTemplate(cfTemplateFilePath: string, groupEntityTypeName?: string) { + /** + * Builds the full path of an AWS::ApiGateway::Method by traversing up the resource hierarchy. + */ + function buildResourcePath(cfTemplate: any, startResourceId: string): string { + let currResourceId: string | undefined = startResourceId; + const parts: string[] = []; + while (currResourceId) { + const resource: any = cfTemplate.Resources[currResourceId]; + if (!resource || resource.Type !== 'AWS::ApiGateway::Resource') { + break; + } + parts.push(resource.Properties.PathPart); + currResourceId = resource.Properties.ParentId.Ref; + } + return '/' + parts.reverse().join('/'); + } + + const RELEVANT_HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head']; + const cfTemplateString = fs.readFileSync(cfTemplateFilePath, 'utf-8'); + const cfTemplate = JSON.parse(cfTemplateString) as any; + if (!cfTemplate.Resources) { + throw new Error('Invalid CF template - missing Resources object'); + } + const resources = cfTemplate.Resources; + + let apiResource; + for (const key in resources) { + const resource = resources[key]; + if (resource.Type === 'AWS::ApiGateway::RestApi') { + if (apiResource) { + throw new Error('Invalid CF template - multiple RestApis found'); + } + apiResource = resource; + } + } + if (!apiResource) { + throw new Error('Invalid CF template - no RestApi found'); + } + if (!apiResource.Properties.Name) { + throw new Error('Invalid CF template - RestApi missing Name property'); + } + const namespace = cleanUpApiNameForNamespace(apiResource.Properties.Name); + + const actionNames = []; + for (const key in resources) { + const resource = resources[key]; + if (resource.Type !== 'AWS::ApiGateway::Method') { + continue; + } + const httpMethod = resource.Properties.HttpMethod.toLowerCase(); + const pathUrl = buildResourcePath(cfTemplate, resource.Properties.ResourceId.Ref); + + if (httpMethod === 'any') { + for (const method of RELEVANT_HTTP_METHODS) { + actionNames.push(`${method} ${pathUrl}`); + } + } else if (RELEVANT_HTTP_METHODS.includes(httpMethod)) { + actionNames.push(`${httpMethod} ${pathUrl}`); + } + } + + return buildSchema(namespace, actionNames, groupEntityTypeName); + } + private readonly policyStore: CfnPolicyStore; /** * ARN of the Policy Store. diff --git a/test/podcastappcloudformation.json b/test/podcastappcloudformation.json new file mode 100644 index 0000000..4efa137 --- /dev/null +++ b/test/podcastappcloudformation.json @@ -0,0 +1,118 @@ +{ + "Resources": { + "PodcastAppApi262252D3": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": "PodcastApp" + } + }, + "PodcastAppApiartistsGET932A4BE6": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "GET", + "ResourceId": { + "Ref": "PodcastAppApiartistsE74E9D28" + } + } + }, + "PodcastAppApiartistsPOSTE5F58123": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "POST", + "ResourceId": { + "Ref": "PodcastAppApiartistsE74E9D28" + } + } + }, + "PodcastAppApiartistsDELETED8ECABA1": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "DELETE", + "ResourceId": { + "Ref": "PodcastAppApiartistsE74E9D28" + } + } + }, + "PodcastAppApiartistsartistId67271C01": { + "Type": "AWS::ApiGateway::Resource", + "Properties": { + "ParentId": { + "Ref": "PodcastAppApiartistsE74E9D28" + }, + "PathPart": "{artistId}" + } + }, + "PodcastAppApiartistsartistIdDELETE33FE0AE3": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "DELETE", + "ResourceId": { + "Ref": "PodcastAppApiartistsartistId67271C01" + } + } + }, + "PodcastAppApiartistsartistIdPATCH59F1CFD1": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "PATCH", + "ResourceId": { + "Ref": "PodcastAppApiartistsartistId67271C01" + } + } + }, + "PodcastAppApipodcasts497B898A": { + "Type": "AWS::ApiGateway::Resource", + "Properties": { + "ParentId": { + "Fn::GetAtt": ["PodcastAppApi262252D3", "RootResourceId"] + }, + "PathPart": "podcasts" + } + }, + "PodcastAppApipodcastsGET85451B06": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "GET", + "ResourceId": { + "Ref": "PodcastAppApipodcasts497B898A" + } + } + }, + "PodcastAppApipodcastsPOSTFC659675": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "POST", + "ResourceId": { + "Ref": "PodcastAppApipodcasts497B898A" + } + } + }, + "PodcastAppApipodcastsDELETE86D19CD6": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "DELETE", + "ResourceId": { + "Ref": "PodcastAppApipodcasts497B898A" + } + } + }, + "PodcastAppApipodcastspodcastId41A31CCC": { + "Type": "AWS::ApiGateway::Resource", + "Properties": { + "ParentId": { + "Ref": "PodcastAppApipodcasts497B898A" + }, + "PathPart": "{podcastId}" + } + }, + "PodcastAppApipodcastspodcastIdANY3158475C": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "ANY", + "ResourceId": { + "Ref": "PodcastAppApipodcastspodcastId41A31CCC" + } + } + } + } +} \ No newline at end of file diff --git a/test/policy-store.test.ts b/test/policy-store.test.ts index 29b87f8..950dc74 100644 --- a/test/policy-store.test.ts +++ b/test/policy-store.test.ts @@ -688,4 +688,156 @@ describe('generating schemas from OpenApi specs', () => { // it should have the eight explicitly defined actions plus the 6 derived from the 'any' definition expect(Object.keys(schema.PodcastApp.actions).length).toEqual(8 + 6); }); -}); \ No newline at end of file +}); + +describe('generating schemas from CloudFormation templates', () => { + const BASE_TEMPLATE_NAME = 'podcastappcloudformation.json'; + + function getBaseTemplate(): any { + return JSON.parse( + fs.readFileSync(path.join(__dirname, BASE_TEMPLATE_NAME), 'utf-8'), + ); + } + + const invalidTemplateTests: { + name: string; + path: string; + wantErr: string; + transform: (template: any) => any; + }[] = [ + { + name: 'no resources', + path: 'podcastappcloudformation-no-resources.json', + wantErr: 'Invalid CF template - missing Resources object', + transform: (template) => { + delete template.Resources; + return template; + }, + }, + { + name: 'multiple rest apis', + path: 'podcastappcloudformation-multiple-rest-apis.json', + wantErr: 'Invalid CF template - multiple RestApis found', + transform: (template) => { + const restApiKey = Object.keys(template.Resources).find( + (key) => template.Resources[key].Type === 'AWS::ApiGateway::RestApi', + ); + template.Resources.RestApi2 = template.Resources[restApiKey!]; + return template; + }, + }, + { + name: 'no rest api', + path: 'podcastappcloudformation-no-rest-api.json', + wantErr: 'Invalid CF template - no RestApi found', + transform: (template) => { + const restApiKey = Object.keys(template.Resources).find( + (key) => template.Resources[key].Type === 'AWS::ApiGateway::RestApi', + ); + delete template.Resources[restApiKey!]; + return template; + }, + }, + { + name: 'rest api missing name', + path: 'podcastappcloudformation-rest-api-missing-name.json', + wantErr: 'Invalid CF template - RestApi missing Name property', + transform: (template) => { + const restApiKey = Object.keys(template.Resources).find( + (key) => template.Resources[key].Type === 'AWS::ApiGateway::RestApi', + ); + delete template.Resources[restApiKey!].Properties.Name; + return template; + }, + }, + ]; + + beforeAll(() => { + for (const test of invalidTemplateTests) { + fs.writeFileSync( + path.join(__dirname, test.path), + JSON.stringify(test.transform(getBaseTemplate()), null, 2), + ); + } + }); + + afterAll(() => { + for (const test of invalidTemplateTests) { + if (fs.existsSync(path.join(__dirname, test.path))) { + fs.unlinkSync(path.join(__dirname, test.path)); + } + } + }); + + test('generate schema from CF template fails if template does not exist', () => { + expect(() => { + PolicyStore.schemaFromCfTemplate( + path.join(__dirname, 'non-existent-template.json'), + ); + }).toThrow(); + }); + + test('generate schema from CF template fails if template is invalid', () => { + for (const test of invalidTemplateTests) { + expect(() => { + PolicyStore.schemaFromCfTemplate(path.join(__dirname, test.path)); + }).toThrow(test.wantErr); + } + }); + + test('generate schema from CF template with userGroups', () => { + // GIVEN + const stack = new Stack(undefined, 'Stack'); + + // WHEN + const schema = PolicyStore.schemaFromCfTemplate( + path.join(__dirname, BASE_TEMPLATE_NAME), + 'UserGroup', + ); + const pStore = new PolicyStore(stack, 'PolicyStore', { + validationSettings: { + mode: ValidationSettingsMode.STRICT, + }, + schema: { + cedarJson: JSON.stringify(schema), + }, + }); + + // THEN + expect(pStore.schema?.cedarJson).toBeDefined(); + expect(Object.keys(schema.PodcastApp.entityTypes)).toStrictEqual([ + 'UserGroup', + 'User', + 'Application', + ]); + // it should have the eight explicitly defined actions plus the 6 derived from the 'any' definition + expect(Object.keys(schema.PodcastApp.actions).length).toEqual(8 + 6); + }); + + test('generate schema from CF template without userGroups', () => { + // GIVEN + const stack = new Stack(undefined, 'Stack'); + + // WHEN + const schema = PolicyStore.schemaFromCfTemplate( + path.join(__dirname, BASE_TEMPLATE_NAME), + ); + const pStore = new PolicyStore(stack, 'PolicyStore', { + validationSettings: { + mode: ValidationSettingsMode.STRICT, + }, + schema: { + cedarJson: JSON.stringify(schema), + }, + }); + + // THEN + expect(pStore.schema?.cedarJson).toBeDefined(); + expect(Object.keys(schema.PodcastApp.entityTypes)).toStrictEqual([ + 'User', + 'Application', + ]); + // it should have the eight explicitly defined actions plus the 6 derived from the 'any' definition + expect(Object.keys(schema.PodcastApp.actions).length).toEqual(8 + 6); + }); +});