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);
+ });
+});