diff --git a/.changeset/easy-lizards-smile.md b/.changeset/easy-lizards-smile.md new file mode 100644 index 000000000..309cfdcac --- /dev/null +++ b/.changeset/easy-lizards-smile.md @@ -0,0 +1,5 @@ +--- +"@aws-amplify/data-schema": minor +--- + +Add `inGroup()` method for owner authorization rules to require group membership alongside ownership diff --git a/packages/data-schema/__tests__/ModelType.test.ts b/packages/data-schema/__tests__/ModelType.test.ts index e1630cc11..2516196fe 100644 --- a/packages/data-schema/__tests__/ModelType.test.ts +++ b/packages/data-schema/__tests__/ModelType.test.ts @@ -444,6 +444,232 @@ describe('model auth rules', () => { expect(graphql).toMatchSnapshot(); }); + describe('owner.inGroup() - owner auth with required group membership', () => { + it('can define owner auth with inGroup requirement', () => { + const schema = a.schema({ + widget: a + .model({ + title: a.string().required(), + }) + .authorization((allow) => allow.owner().inGroup('AdminGroup')), + }); + + const graphql = schema.transform().schema; + expect(graphql).toMatchSnapshot(); + }); + + it('can define owner auth with multiple inGroup requirements', () => { + const schema = a.schema({ + widget: a + .model({ + title: a.string().required(), + }) + .authorization((allow) => + allow.owner().inGroup('AdminGroup', 'SuperUserGroup'), + ), + }); + + const graphql = schema.transform().schema; + expect(graphql).toMatchSnapshot(); + }); + + it('can chain owner.inGroup() with operations', () => { + const schema = a.schema({ + widget: a + .model({ + title: a.string().required(), + }) + .authorization((allow) => + allow.owner().inGroup('AdminGroup').to(['create', 'read', 'update']), + ), + }); + + const graphql = schema.transform().schema; + expect(graphql).toMatchSnapshot(); + }); + + it('can chain owner.inGroup() with identityClaim', () => { + const schema = a.schema({ + widget: a + .model({ + title: a.string().required(), + }) + .authorization((allow) => + allow.owner().inGroup('AdminGroup').identityClaim('user_id'), + ), + }); + + const graphql = schema.transform().schema; + expect(graphql).toMatchSnapshot(); + }); + + it('can define ownerDefinedIn with inGroup requirement', () => { + const schema = a.schema({ + widget: a + .model({ + title: a.string().required(), + }) + .authorization((allow) => + allow.ownerDefinedIn('customOwnerField').inGroup('AdminGroup'), + ), + }); + + const graphql = schema.transform().schema; + expect(graphql).toMatchSnapshot(); + }); + + it('can define ownersDefinedIn with inGroup requirement', () => { + const schema = a.schema({ + widget: a + .model({ + title: a.string().required(), + }) + .authorization((allow) => + allow.ownersDefinedIn('editors').inGroup('EditorGroup'), + ), + }); + + const graphql = schema.transform().schema; + expect(graphql).toMatchSnapshot(); + }); + }); + + describe('owner.inGroup() combined with group() - rules should not merge', () => { + it('owner.inGroup() and group() with same group remain as separate rules', () => { + const schema = a.schema({ + widget: a + .model({ + title: a.string().required(), + }) + .authorization((allow) => [ + allow.owner().inGroup('AdminGroup'), + allow.group('AdminGroup'), + ]), + }); + + const graphql = schema.transform().schema; + // Should have two separate rules: + // 1. {allow: owner, ownerField: "owner", groups: ["AdminGroup"]} + // 2. {allow: groups, groups: ["AdminGroup"]} + expect(graphql).toContain('allow: owner'); + expect(graphql).toContain('allow: groups'); + expect(graphql).toMatchSnapshot(); + }); + + it('owner.inGroup() and groups() with overlapping groups remain as separate rules', () => { + const schema = a.schema({ + widget: a + .model({ + title: a.string().required(), + }) + .authorization((allow) => [ + allow.owner().inGroup('AdminGroup', 'ModeratorGroup'), + allow.groups(['AdminGroup', 'UserGroup']), + ]), + }); + + const graphql = schema.transform().schema; + expect(graphql).toContain('allow: owner'); + expect(graphql).toContain('allow: groups'); + expect(graphql).toMatchSnapshot(); + }); + + it('multiple owner rules with different inGroup requirements remain separate', () => { + const schema = a.schema({ + widget: a + .model({ + title: a.string().required(), + }) + .authorization((allow) => [ + allow.owner().inGroup('AdminGroup').to(['create', 'read', 'update', 'delete']), + allow.ownerDefinedIn('reviewer').inGroup('ReviewerGroup').to(['read']), + ]), + }); + + const graphql = schema.transform().schema; + expect(graphql).toMatchSnapshot(); + }); + + it('owner.inGroup() combined with group() and authenticated() produces correct output', () => { + const schema = a.schema({ + widget: a + .model({ + title: a.string().required(), + }) + .authorization((allow) => [ + allow.owner().inGroup('AdminGroup'), + allow.group('AdminGroup').to(['read']), + allow.authenticated().to(['read']), + ]), + }); + + const graphql = schema.transform().schema; + expect(graphql).toContain('allow: owner'); + expect(graphql).toContain('allow: groups'); + expect(graphql).toContain('allow: private'); + expect(graphql).toMatchSnapshot(); + }); + + it('owner.inGroup() with different groups and different permissions remain separate', () => { + const schema = a.schema({ + widget: a + .model({ + title: a.string().required(), + }) + .authorization((allow) => [ + allow.owner().inGroup('ReadersGroup').to(['read']), + allow.owner().inGroup('WritersGroup').to(['create', 'update', 'delete']), + ]), + }); + + const graphql = schema.transform().schema; + // Should have two separate owner rules with different groups and operations + expect(graphql).toContain('groups: ["ReadersGroup"]'); + expect(graphql).toContain('groups: ["WritersGroup"]'); + expect(graphql).toContain('operations: [read]'); + expect(graphql).toContain('operations: [create, update, delete]'); + expect(graphql).toMatchSnapshot(); + }); + + it('owner.inGroup() with overlapping groups but different permissions remain separate', () => { + const schema = a.schema({ + widget: a + .model({ + title: a.string().required(), + }) + .authorization((allow) => [ + allow.owner().inGroup('AdminGroup', 'ModeratorGroup').to(['read', 'update']), + allow.owner().inGroup('AdminGroup', 'SuperUserGroup').to(['create', 'delete']), + ]), + }); + + const graphql = schema.transform().schema; + expect(graphql).toMatchSnapshot(); + }); + + it('ownerDefinedIn.inGroup() with different groups and permissions on same field', () => { + const schema = a.schema({ + widget: a + .model({ + title: a.string().required(), + createdBy: a.string(), + }) + .authorization((allow) => [ + allow.ownerDefinedIn('createdBy').inGroup('ViewerGroup').to(['read']), + allow.ownerDefinedIn('createdBy').inGroup('EditorGroup').to(['read', 'update']), + allow.ownerDefinedIn('createdBy').inGroup('AdminGroup').to(['create', 'read', 'update', 'delete']), + ]), + }); + + const graphql = schema.transform().schema; + // All three rules should be separate + expect(graphql).toContain('ViewerGroup'); + expect(graphql).toContain('EditorGroup'); + expect(graphql).toContain('AdminGroup'); + expect(graphql).toMatchSnapshot(); + }); + }); + it(`includes auth from fields`, () => { const schema = a.schema({ widget: a diff --git a/packages/data-schema/__tests__/__snapshots__/ModelField.test.ts.snap b/packages/data-schema/__tests__/__snapshots__/ModelField.test.ts.snap index 073aa927f..883e737bf 100644 --- a/packages/data-schema/__tests__/__snapshots__/ModelField.test.ts.snap +++ b/packages/data-schema/__tests__/__snapshots__/ModelField.test.ts.snap @@ -31,6 +31,7 @@ exports[`field level auth implied fields objects can be extracted 1`] = ` }, { "identityClaim": [Function], + "inGroup": [Function], Symbol(data): { "groupOrOwnerField": "admin", "groups": undefined, @@ -46,6 +47,7 @@ exports[`field level auth implied fields objects can be extracted 1`] = ` }, }, { + "inGroup": [Function], Symbol(data): { "groupOrOwnerField": "admin", "groups": undefined, @@ -94,6 +96,7 @@ exports[`field level auth implied fields objects can be extracted from related m }, { "identityClaim": [Function], + "inGroup": [Function], Symbol(data): { "groupOrOwnerField": "admin", "groups": undefined, @@ -109,6 +112,7 @@ exports[`field level auth implied fields objects can be extracted from related m }, }, { + "inGroup": [Function], Symbol(data): { "groupOrOwnerField": "admin", "groups": undefined, diff --git a/packages/data-schema/__tests__/__snapshots__/ModelType.test.ts.snap b/packages/data-schema/__tests__/__snapshots__/ModelType.test.ts.snap index 5efeb5887..c899007d5 100644 --- a/packages/data-schema/__tests__/__snapshots__/ModelType.test.ts.snap +++ b/packages/data-schema/__tests__/__snapshots__/ModelType.test.ts.snap @@ -628,6 +628,107 @@ type widget @model @auth(rules: [{allow: owner, ownerField: "owner"}]) }" `; +exports[`model auth rules owner.inGroup() - owner auth with required group membership can chain owner.inGroup() with identityClaim 1`] = ` +"type widget @model @auth(rules: [{allow: owner, ownerField: "owner", groups: ["AdminGroup"], identityClaim: "user_id"}]) +{ + title: String! +}" +`; + +exports[`model auth rules owner.inGroup() - owner auth with required group membership can chain owner.inGroup() with operations 1`] = ` +"type widget @model @auth(rules: [{allow: owner, operations: [create, read, update], ownerField: "owner", groups: ["AdminGroup"]}]) +{ + title: String! +}" +`; + +exports[`model auth rules owner.inGroup() - owner auth with required group membership can define owner auth with inGroup requirement 1`] = ` +"type widget @model @auth(rules: [{allow: owner, ownerField: "owner", groups: ["AdminGroup"]}]) +{ + title: String! +}" +`; + +exports[`model auth rules owner.inGroup() - owner auth with required group membership can define owner auth with multiple inGroup requirements 1`] = ` +"type widget @model @auth(rules: [{allow: owner, ownerField: "owner", groups: ["AdminGroup", "SuperUserGroup"]}]) +{ + title: String! +}" +`; + +exports[`model auth rules owner.inGroup() - owner auth with required group membership can define ownerDefinedIn with inGroup requirement 1`] = ` +"type widget @model @auth(rules: [{allow: owner, ownerField: "customOwnerField", groups: ["AdminGroup"]}]) +{ + title: String! +}" +`; + +exports[`model auth rules owner.inGroup() - owner auth with required group membership can define ownersDefinedIn with inGroup requirement 1`] = ` +"type widget @model @auth(rules: [{allow: owner, ownerField: "editors", groups: ["EditorGroup"]}]) +{ + title: String! +}" +`; + +exports[`model auth rules owner.inGroup() combined with group() - rules should not merge multiple owner rules with different inGroup requirements remain separate 1`] = ` +"type widget @model @auth(rules: [{allow: owner, operations: [create, read, update, delete], ownerField: "owner", groups: ["AdminGroup"]}, + {allow: owner, operations: [read], ownerField: "reviewer", groups: ["ReviewerGroup"]}]) +{ + title: String! +}" +`; + +exports[`model auth rules owner.inGroup() combined with group() - rules should not merge owner.inGroup() and group() with same group remain as separate rules 1`] = ` +"type widget @model @auth(rules: [{allow: owner, ownerField: "owner", groups: ["AdminGroup"]}, + {allow: groups, groups: ["AdminGroup"]}]) +{ + title: String! +}" +`; + +exports[`model auth rules owner.inGroup() combined with group() - rules should not merge owner.inGroup() and groups() with overlapping groups remain as separate rules 1`] = ` +"type widget @model @auth(rules: [{allow: owner, ownerField: "owner", groups: ["AdminGroup", "ModeratorGroup"]}, + {allow: groups, groups: ["AdminGroup", "UserGroup"]}]) +{ + title: String! +}" +`; + +exports[`model auth rules owner.inGroup() combined with group() - rules should not merge owner.inGroup() combined with group() and authenticated() produces correct output 1`] = ` +"type widget @model @auth(rules: [{allow: owner, ownerField: "owner", groups: ["AdminGroup"]}, + {allow: groups, operations: [read], groups: ["AdminGroup"]}, + {allow: private, operations: [read]}]) +{ + title: String! +}" +`; + +exports[`model auth rules owner.inGroup() combined with group() - rules should not merge owner.inGroup() with different groups and different permissions remain separate 1`] = ` +"type widget @model @auth(rules: [{allow: owner, operations: [read], ownerField: "owner", groups: ["ReadersGroup"]}, + {allow: owner, operations: [create, update, delete], ownerField: "owner", groups: ["WritersGroup"]}]) +{ + title: String! +}" +`; + +exports[`model auth rules owner.inGroup() combined with group() - rules should not merge owner.inGroup() with overlapping groups but different permissions remain separate 1`] = ` +"type widget @model @auth(rules: [{allow: owner, operations: [read, update], ownerField: "owner", groups: ["AdminGroup", "ModeratorGroup"]}, + {allow: owner, operations: [create, delete], ownerField: "owner", groups: ["AdminGroup", "SuperUserGroup"]}]) +{ + title: String! +}" +`; + +exports[`model auth rules owner.inGroup() combined with group() - rules should not merge ownerDefinedIn.inGroup() with different groups and permissions on same field 1`] = ` +"type widget @model @auth(rules: [{allow: owner, operations: [read], ownerField: "createdBy", groups: ["ViewerGroup"]}, + {allow: owner, operations: [read, update], ownerField: "createdBy", groups: ["EditorGroup"]}, + {allow: owner, operations: [create, read, update, delete], ownerField: "createdBy", groups: ["AdminGroup"]}]) +{ + title: String! + createdBy: String +}" +`; + exports[`secondary indexes generates a primary key AND secondary index annotation with attributes 1`] = ` "type widget @model @auth(rules: [{allow: public, provider: apiKey}]) { diff --git a/packages/data-schema/src/Authorization.ts b/packages/data-schema/src/Authorization.ts index ed64ee92d..6a25b7d71 100644 --- a/packages/data-schema/src/Authorization.ts +++ b/packages/data-schema/src/Authorization.ts @@ -172,6 +172,22 @@ function identityClaim>( return omit(this, 'identityClaim'); } +/** + * Requires the owner to also be a member of one of the specified groups. + * This adds an additional group check to owner-based authorization. + * + * @param this Authorization object to operate against. + * @param groups One or more group names that the owner must belong to. + * @returns A copy of the Authorization object with the group requirement attached. + */ +function inGroup>( + this: SELF, + ...groups: string[] +) { + this[__data].groups = groups; + return omit(this, 'inGroup'); +} + function withClaimIn>( this: SELF, property: string, @@ -296,6 +312,7 @@ export const allow = { { to, identityClaim, + inGroup, }, ); }, @@ -325,6 +342,7 @@ export const allow = { { to, identityClaim, + inGroup, }, ); }, @@ -359,6 +377,7 @@ export const allow = { { to, identityClaim, + inGroup, }, ); }, diff --git a/packages/data-schema/src/SchemaProcessor.ts b/packages/data-schema/src/SchemaProcessor.ts index 4f1dd35e5..5d6c2785b 100644 --- a/packages/data-schema/src/SchemaProcessor.ts +++ b/packages/data-schema/src/SchemaProcessor.ts @@ -802,6 +802,8 @@ function calculateAuth(authorization: Authorization[]) { } } + // For group strategy, groups is a list of allowed groups + // For owner strategy with inGroup(), groups is a list of required groups (AND condition) if (rule.groups) { // does `group` need to be escaped? ruleParts.push(