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