diff --git a/API.md b/API.md index f44d9dc..96c6a15 100644 --- a/API.md +++ b/API.md @@ -897,6 +897,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. | +| schemaFromOpenApiSpec | This method generates a schema based on an swagger file. | --- @@ -1052,6 +1053,39 @@ The PolicyStore's id. --- +##### `schemaFromOpenApiSpec` + +```typescript +import { PolicyStore } from '@cdklabs/cdk-verified-permissions' + +PolicyStore.schemaFromOpenApiSpec(swaggerFilePath: string, groupEntityTypeName?: string) +``` + +This method generates a schema based on an swagger file. + +It makes the same assumptions and decisions +made in the Amazon Verified Permissions console. This feature is built for swagger files generated from an Amazon API Gateway +export. It's possible that some swagger files generated by other tools will not work. In that case, please +file an issue. + +###### `swaggerFilePath`Required + +- *Type:* string + +absolute path to a swagger 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. + +--- + #### Properties | **Name** | **Type** | **Description** | diff --git a/README.md b/README.md index 1d1d28c..45078e9 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,30 @@ const policyStore = new PolicyStore(scope, "PolicyStore", { }); ``` +## Schemas + +If you want to have type safety when defining a schema, you can accomplish this 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. + +```ts +const validationSettingsStrict = { + mode: ValidationSettingsMode.STRICT, +}; +const cedarJsonSchema = PolicyStore.schemaFromOpenApiSpec( + '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", +}); +``` + ## Identity Source Define Identity Source with required properties: diff --git a/src/cedar-helpers.ts b/src/cedar-helpers.ts index 6782ed3..9e22210 100644 --- a/src/cedar-helpers.ts +++ b/src/cedar-helpers.ts @@ -39,4 +39,64 @@ export function validatePolicy(policyStatement: string, schemaStr: string) { ${validationResult.validationErrors.join('; ')}`, ); } +} + +export function cleanUpApiNameForNamespace(apiName: string): string { + const validCedarName = apiName.replace(/[^a-zA-Z0-9_]/g, '').trim(); + if (validCedarName.length === 0) { + return 'ImportedApi'; + } + if (/[0-9_]/.exec(validCedarName[0])) { + return `Api${validCedarName}`; + } + return validCedarName; +} + +export function buildSchema( + namespace: string, + actionNames: string[], + principalGroupType?: string, +): Record> { + const additionalEntities: Record = {}; + if (principalGroupType) { + additionalEntities[principalGroupType] = { + shape: { + type: 'Record', + attributes: {}, + }, + }; + } + const actions = actionNames.reduce((acc, actionName) => { + return { + ...acc, + [actionName]: { + appliesTo: { + context: { type: 'Record', attributes: {} }, + principalTypes: ['User'], + resourceTypes: ['Application'], + }, + }, + }; + }, {}); + return { + [namespace]: { + entityTypes: { + ...additionalEntities, + User: { + shape: { + type: 'Record', + attributes: {}, + }, + memberOfTypes: principalGroupType ? [principalGroupType] : [], + }, + Application: { + shape: { + type: 'Record', + attributes: {}, + }, + }, + }, + actions, + }, + }; } \ No newline at end of file diff --git a/src/policy-store.ts b/src/policy-store.ts index a3e3f0b..d41b9e2 100644 --- a/src/policy-store.ts +++ b/src/policy-store.ts @@ -4,7 +4,7 @@ import * as iam from 'aws-cdk-lib/aws-iam'; import { CfnPolicyStore } from 'aws-cdk-lib/aws-verifiedpermissions'; import { ArnFormat, IResource, Resource, Stack } from 'aws-cdk-lib/core'; import { Construct } from 'constructs'; -import { checkParseSchema, validatePolicy } from './cedar-helpers'; +import { buildSchema, checkParseSchema, cleanUpApiNameForNamespace, validatePolicy } from './cedar-helpers'; import { Policy, PolicyDefinitionProperty } from './policy'; import { AUTH_ACTIONS, @@ -254,6 +254,45 @@ export class PolicyStore extends PolicyStoreBase { return new Import(policyStoreArn, policyStoreId); } + /** + * This method generates a schema based on an swagger file. It makes the same assumptions and decisions + * made in the Amazon Verified Permissions console. This feature is built for swagger files generated from an Amazon API Gateway + * export. It's possible that some swagger files generated by other tools will not work. In that case, please + * file an issue. + * @param swaggerFilePath absolute path to a swagger 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 schemaFromOpenApiSpec(swaggerFilePath: string, groupEntityTypeName?: string) { + const RELEVANT_HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head']; + const openApiSpecString = fs.readFileSync(swaggerFilePath, 'utf-8'); + const openApiSpec = JSON.parse(openApiSpecString) as any; + if (!openApiSpec.paths) { + throw new Error('Invalid OpenAPI spec - missing paths object'); + } + const namespace = cleanUpApiNameForNamespace(openApiSpec.info.title); + + const pathUrls = Object.keys(openApiSpec.paths); + const actionNames = []; + for (const pathUrl of pathUrls) { + const pathDef = openApiSpec.paths[pathUrl]; + if (!pathDef) { + continue; + } + let pathVerbs = Object.keys(pathDef); + if (pathVerbs.includes('x-amazon-apigateway-any-method')) { + pathVerbs = RELEVANT_HTTP_METHODS; + } + for (const httpVerb of pathVerbs) { + if (!RELEVANT_HTTP_METHODS.includes(httpVerb)) { + continue; + } + const actionName = `${httpVerb} ${pathUrl}`; + actionNames.push(actionName); + } + } + return buildSchema(namespace, actionNames, groupEntityTypeName); + } + private readonly policyStore: CfnPolicyStore; /** * ARN of the Policy Store. diff --git a/test/cedar-helpers.test.ts b/test/cedar-helpers.test.ts new file mode 100644 index 0000000..25bbbe9 --- /dev/null +++ b/test/cedar-helpers.test.ts @@ -0,0 +1,10 @@ +import { cleanUpApiNameForNamespace } from '../src/cedar-helpers'; + +describe('testing edge cases in cedar helpers', () => { + test('cleanUpApiNameForNamespace tests', () => { + expect(cleanUpApiNameForNamespace('test')).toBe('test'); + expect(cleanUpApiNameForNamespace('bad-name')).toBe('badname'); + expect(cleanUpApiNameForNamespace('---')).toBe('ImportedApi'); + expect(cleanUpApiNameForNamespace('1234backend')).toBe('Api1234backend'); + }); +}); \ No newline at end of file diff --git a/test/podcastappswagger.json b/test/podcastappswagger.json new file mode 100644 index 0000000..bd7a858 --- /dev/null +++ b/test/podcastappswagger.json @@ -0,0 +1,183 @@ +{ + "swagger" : "2.0", + "info" : { + "version" : "2024-06-18T20:07:31Z", + "title" : "PodcastApp" + }, + "host" : "3se71cmpi4.execute-api.us-west-2.amazonaws.com", + "basePath" : "/prod", + "schemes" : [ "https" ], + "paths" : { + "/fakePatToGetUnitTestCoverage": null, + "/artists" : { + "get" : { + "responses" : { }, + "security" : [ { + "AVPAuthorizer" : [ ] + } ] + }, + "post" : { + "responses" : { } + }, + "delete" : { + "responses" : { } + }, + "options" : { + "consumes" : [ "application/json" ], + "responses" : { + "204" : { + "description" : "204 response", + "headers" : { + "Access-Control-Allow-Origin" : { + "type" : "string" + }, + "Access-Control-Allow-Methods" : { + "type" : "string" + }, + "Access-Control-Allow-Headers" : { + "type" : "string" + } + } + } + } + } + }, + "/artists/{artistId}" : { + "delete" : { + "parameters" : [ { + "name" : "artistId", + "in" : "path", + "required" : true, + "type" : "string" + } ], + "responses" : { } + }, + "options" : { + "consumes" : [ "application/json" ], + "parameters" : [ { + "name" : "artistId", + "in" : "path", + "required" : true, + "type" : "string" + } ], + "responses" : { + "204" : { + "description" : "204 response", + "headers" : { + "Access-Control-Allow-Origin" : { + "type" : "string" + }, + "Access-Control-Allow-Methods" : { + "type" : "string" + }, + "Access-Control-Allow-Headers" : { + "type" : "string" + } + } + } + } + }, + "patch" : { + "parameters" : [ { + "name" : "artistId", + "in" : "path", + "required" : true, + "type" : "string" + } ], + "responses" : { } + } + }, + "/podcasts" : { + "get" : { + "consumes" : [ "application/json" ], + "produces" : [ "application/json" ], + "responses" : { + "200" : { + "description" : "200 response", + "schema" : { + "$ref" : "#/definitions/Empty" + } + } + }, + "security" : [ { + "AVPAuthorizer" : [ ] + } ] + }, + "post" : { + "responses" : { } + }, + "delete" : { + "responses" : { } + }, + "options" : { + "consumes" : [ "application/json" ], + "responses" : { + "204" : { + "description" : "204 response", + "headers" : { + "Access-Control-Allow-Origin" : { + "type" : "string" + }, + "Access-Control-Allow-Methods" : { + "type" : "string" + }, + "Access-Control-Allow-Headers" : { + "type" : "string" + } + } + } + } + } + }, + "/podcasts/{podcastId}" : { + "options" : { + "consumes" : [ "application/json" ], + "parameters" : [ { + "name" : "podcastId", + "in" : "path", + "required" : true, + "type" : "string" + } ], + "responses" : { + "204" : { + "description" : "204 response", + "headers" : { + "Access-Control-Allow-Origin" : { + "type" : "string" + }, + "Access-Control-Allow-Methods" : { + "type" : "string" + }, + "Access-Control-Allow-Headers" : { + "type" : "string" + } + } + } + } + }, + "x-amazon-apigateway-any-method" : { + "parameters" : [ { + "name" : "podcastId", + "in" : "path", + "required" : true, + "type" : "string" + } ], + "responses" : { } + } + } + }, + "securityDefinitions" : { + "AVPAuthorizer" : { + "type" : "apiKey", + "name" : "Unused", + "in" : "header", + "x-amazon-apigateway-authtype" : "custom" + } + }, + "definitions" : { + "Empty" : { + "type" : "object", + "title" : "Empty Schema" + } + } +} \ No newline at end of file diff --git a/test/policy-store.test.ts b/test/policy-store.test.ts index ce92650..0b71ab7 100644 --- a/test/policy-store.test.ts +++ b/test/policy-store.test.ts @@ -621,4 +621,44 @@ describe('Policy store with policies from a path', () => { pStore.addPoliciesFromPath(path.join(__dirname, 'test-policies', 'all-valid')); }).toThrow('could not be validated against the schema'); }); +}); + +describe('generating schemas from OpenApi specs', () => { + test('generate schema from openApi spec fails if swagger file has no paths', () => { + expect(() => { + PolicyStore.schemaFromOpenApiSpec( + path.join(__dirname, 'schema.json'), + 'UserGroup', + ); + }).toThrow(); + }); + + test('generate schema from openApi spec with userGroups', () => { + // GIVEN + const stack = new Stack(undefined, 'Stack'); + + // WHEN + const schema = PolicyStore.schemaFromOpenApiSpec( + path.join(__dirname, 'podcastappswagger.json'), + '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); + }); }); \ No newline at end of file