Skip to content

Commit

Permalink
feat: generate schema from RestApi construct (#235)
Browse files Browse the repository at this point in the history
Fixes #220 

Adds `PolicyStore.schemaFromRestApi()`, a method for generating AVP
permissions schemas directly from the `RestApi` construct. This
alternative allows for easier schema synchronization/generation when API
changes occur, complementing the existing
PolicyStore.schemaFromOpenApiSpec() that requires a Swagger JSON file.

---------

Co-authored-by: Matteo Restelli <[email protected]>
  • Loading branch information
joshkraft and reste85 authored Sep 9, 2024
1 parent 04c51d5 commit 2883173
Show file tree
Hide file tree
Showing 5 changed files with 234 additions and 56 deletions.
32 changes: 32 additions & 0 deletions API.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ const policyStore = new PolicyStore(scope, "PolicyStore", {

If you want to have type safety when defining a schema, you can accomplish this **<ins>only</ins>** 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 `schemaFromRestApi` 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 = {
Expand All @@ -83,6 +85,26 @@ const policyStore = new PolicyStore(scope, "PolicyStore", {
});
```

Generate a schema from a RestApi construct:

```ts
const validationSettingsStrict = {
mode: ValidationSettingsMode.STRICT,
};
const cedarJsonSchema = PolicyStore.schemaFromRestApi(
new RestApi(scope, "RestApi"),
"UserGroup",
);
const cedarSchema = {
cedarJson: JSON.stringify(cedarJsonSchema),
};
const policyStore = new PolicyStore(scope, "PolicyStore", {
schema: cedarSchema,
validationSettings: validationSettingsStrict,
description: "Policy store with schema generated from RestApi construct",
});
```

## Identity Source

Define Identity Source with Cognito Configuration and required properties:
Expand Down
1 change: 1 addition & 0 deletions rosetta/default.ts-fixture
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Fixture with packages imported, but nothing else
import { IdentitySource, Policy, PolicyType, PolicyTemplate, AddPolicyOptions, PolicyStore, ValidationSettingsMode } from '@cdklabs/cdk-verified-permissions';
import { UserPool } from 'aws-cdk-lib/aws-cognito';
import { RestApi } from 'aws-cdk-lib/aws-apigateway';
import { Stack } from 'aws-cdk-lib';
import { Construct } from 'constructs';

Expand Down
29 changes: 28 additions & 1 deletion src/policy-store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as fs from 'fs';
import * as path from 'path';
import { RestApi } from 'aws-cdk-lib/aws-apigateway';
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';
Expand All @@ -12,6 +13,8 @@ import {
WRITE_ACTIONS,
} from './private/permissions';

const RELEVANT_HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head'];

export interface Schema {
readonly cedarJson: string;
}
Expand Down Expand Up @@ -263,7 +266,6 @@ export class PolicyStore extends PolicyStoreBase {
* @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) {
Expand Down Expand Up @@ -293,6 +295,31 @@ export class PolicyStore extends PolicyStoreBase {
return buildSchema(namespace, actionNames, groupEntityTypeName);
}

/**
* This method generates a schema based on an AWS CDK RestApi construct. It makes the same assumptions
* and decisions made in the Amazon Verified Permissions console.
*
* @param restApi The RestApi construct instance from which to generate the schema.
* @param groupEntityTypeName Specifies a group entity type name. If passed, the schema's User type will have a parent of this type.
*/
public static schemaFromRestApi(restApi: RestApi, groupEntityTypeName?: string) {
const namespace = cleanUpApiNameForNamespace(restApi.restApiName);
const actionNames: string[] = [];
for (const method of restApi.methods) {
const pathVerb = method.httpMethod.toLowerCase();
const pathUrl = method.resource.path;
if (pathVerb === 'any') {
for (const verb of RELEVANT_HTTP_METHODS) {
actionNames.push(`${verb} ${pathUrl}`);
}
}
if (RELEVANT_HTTP_METHODS.includes(pathVerb)) {
actionNames.push(`${pathVerb} ${pathUrl}`);
}
}
return buildSchema(namespace, actionNames, groupEntityTypeName);
}

private readonly policyStore: CfnPolicyStore;
/**
* ARN of the Policy Store.
Expand Down
204 changes: 150 additions & 54 deletions test/policy-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as path from 'path';
import * as cedar from '@cedar-policy/cedar-wasm/nodejs';
import { ArnFormat, Aws, Stack } from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { RestApi } from 'aws-cdk-lib/aws-apigateway';
import * as iam from 'aws-cdk-lib/aws-iam';
import { CfnPolicy, CfnPolicyStore } from 'aws-cdk-lib/aws-verifiedpermissions';
import { getResourceLogicalId } from './utils';
Expand Down Expand Up @@ -624,69 +625,164 @@ describe('Policy store with policies from a path', () => {
});
});

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'),
describe('Policy Store schema generation', () => {
const expectedActions = [
'get /artists',
'post /artists',
'delete /artists',
'patch /artists/{artistId}',
'delete /artists/{artistId}',
'get /podcasts',
'post /podcasts',
'delete /podcasts',
'get /podcasts/{podcastId}',
'post /podcasts/{podcastId}',
'put /podcasts/{podcastId}',
'patch /podcasts/{podcastId}',
'delete /podcasts/{podcastId}',
'head /podcasts/{podcastId}',
];

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('Invalid OpenAPI spec - missing paths object');
});

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',
);
}).toThrow();
});

test('generate schema from openApi spec with userGroups', () => {
// GIVEN
const stack = new Stack(undefined, 'Stack');
const pStore = new PolicyStore(stack, 'PolicyStore', {
validationSettings: {
mode: ValidationSettingsMode.STRICT,
},
schema: {
cedarJson: JSON.stringify(schema),
},
});

// 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);
expect(Object.keys(schema.PodcastApp.actions).sort()).toEqual(expectedActions.sort());
});

// 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 openApi spec without userGroups', () => {
// GIVEN
const stack = new Stack(undefined, 'Stack');

// WHEN
const schema = PolicyStore.schemaFromOpenApiSpec(
path.join(__dirname, 'podcastappswagger.json'),

);
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);
expect(Object.keys(schema.PodcastApp.actions).sort()).toEqual(expectedActions.sort());
});
});
test('generate schema from openApi spec without userGroups', () => {
// GIVEN
const stack = new Stack(undefined, 'Stack');

// WHEN
const schema = PolicyStore.schemaFromOpenApiSpec(
path.join(__dirname, 'podcastappswagger.json'),
describe('generating schemas from RestApi constructs', () => {
function buildRestApi() {
const stack = new Stack(undefined, 'Stack');
const api = new RestApi(stack, 'PodcastApp');

const artists = api.root.addResource('artists');
artists.addMethod('GET');
artists.addMethod('POST');
artists.addMethod('DELETE');
const artist = artists.addResource('{artistId}');
artist.addMethod('PATCH');
artist.addMethod('DELETE');
const podcasts = api.root.addResource('podcasts');
podcasts.addMethod('GET');
podcasts.addMethod('POST');
podcasts.addMethod('DELETE');
const podcast = podcasts.addResource('{podcastId}');
podcast.addMethod('ANY');

return { stack, api };
}

test('generate schema from RestApi with userGroups', () => {
// GIVEN
const { stack, api } = buildRestApi();

// WHEN
const schema = PolicyStore.schemaFromRestApi(api, 'UserGroup');
const pStore = new PolicyStore(stack, 'PolicyStore', {
validationSettings: {
mode: ValidationSettingsMode.STRICT,
},
schema: {
cedarJson: JSON.stringify(schema),
},
});

);
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);
expect(Object.keys(schema.PodcastApp.actions).sort()).toEqual(expectedActions.sort());
});

// 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);
test('generate schema from RestApi without userGroups', () => {
// GIVEN
const { stack, api } = buildRestApi();

// WHEN
const schema = PolicyStore.schemaFromRestApi(api);
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);
expect(Object.keys(schema.PodcastApp.actions).sort()).toEqual(expectedActions.sort());
});
});
});

0 comments on commit 2883173

Please sign in to comment.