Important note: This documentation is generated from integration tests, so the examples execute and are tested against.
DO NOT EDIT THIS .md FILE - Its generated from ts-node detailed-usage-examples.md.spec.ts
Let's look at some examples, so we can better guide the discussion.
All examples use a simple schema that entails:
A Document is created by a User.
A User belongs to one Company (and a Company has many Users)
A User (as manager) manages zero or more Users
Note: our mock data layer resides in file data.fixtures.ts.
Now consider the following simple Permissions (i.e our business rules, expressed as plain English), based on the above schema:
As an EMPLOYEE, I can create, read & list only my OWN Documents (created by me) , all attributes except confidential. Also, I can list all Documents on the system, but only access the title & date attributes.
As a EMPLOYEE_MANAGER, I can read, list, review & delete all Documents created by any User that I am managing, all document attributes except confidential. Also, I can list all Documents on the system, but only access the title, date & status attributes.
As a COMPANY_ADMIN, I can read, update and review all Documents created by any User in my Company, all attributes.
As a SUPER_ADMIN, I can do all actions on any resource (not just documents), created by ANY User, ANY Company and access all attributes.
We see that most Roles (and hence Users with these Roles) can perform different sets of actions on Documents they somehow "own".
But the definition of ownership in our apps are arbitrary - it can be "Documents created by users of my company", or "Documents created by users I manage" or it could be any particular business rule such as "users that are friends" etc.
We define these ownership definitions as "ownership hooks", by defining isOwner and either listOwned or limitOwned functions for each PermissionDefinition that has "own" possession rules.
With that in mind, lets convert the above "human permissions / business rules" into PermissionDefinitions:
const permissions = new Permissions({
permissionDefinitionDefaults: { resource: 'document' },
permissionDefinitions: [
{
roles: ['EMPLOYEE'],
resource: 'document',
descr:
'> As an **EMPLOYEE**, I can **create**, **read** & **list** only my **OWN Documents (created by me)** , all attributes except **confidential**. Also, I can **list** all **Documents** on the system, but only access the **title** & **date** attributes.',
isOwner: async ({ user, resourceId }) => isUserCreatorOfDocument({ user, resourceId }),
listOwned: async (user) => listUserCreatedDocuments(user),
possession: 'own',
grant: {
create: ['*', '!confidential'],
read: ['*', '!confidential'],
list: ['*', '!confidential'],
'list:any': ['title', 'date'],
},
},
{
roles: ['EMPLOYEE_MANAGER'],
resource: 'document',
descr:
'> As a **EMPLOYEE_MANAGER**, I can **read**, **list**, **review** & **delete** all **Documents** created by **any User that I am managing**, all document attributes except **confidential**. Also, I can **list** all **Documents** on the system, but only access the **title**, **date** & **status** attributes.',
isOwner: async ({ user, resourceId }) => listDocsOfMeAndMyManagedUsers(user).includes(resourceId),
listOwned: async (user) => listDocsOfMeAndMyManagedUsers(user),
possession: 'own',
grant: {
read: ['*', '!confidential', '!personal'],
review: ['*', '!confidential', '!personal'],
delete: ['*', '!confidential', '!personal'],
list: ['*', '!confidential', '!personal'],
'list:any': ['title', 'date', 'status'],
},
},
{
roles: ['COMPANY_ADMIN'],
resource: 'document',
descr:
'> As a **COMPANY_ADMIN**, I can **read**, **update** and **review** all **Documents** created by **any User in my Company**, all attributes.',
isOwner: async ({ user, resourceId }) => listDocsOfMeAndMyCompanyUsers(user).includes(resourceId),
listOwned: async (user) => listDocsOfMeAndMyCompanyUsers(user),
possession: 'own',
grant: ['read', 'update', 'review'],
},
{
roles: ['SUPER_ADMIN'],
resource: '*',
descr:
'> As a **SUPER_ADMIN**, I can do all actions on **any resource** (not just documents), created by ANY User, ANY Company and access all attributes.',
grant: ['*'],
},
],
}).build();
Its a good practice to keep the human description close in the PD & keep them in sync.
We can now start Granting Permissions, i.e grantPermit().
Lets grant permit of a simple EMPLOYEE user to "read" a document.
const permit = await permissions.grantPermit({
user: { id: 1, roles: ['EMPLOYEE'] },
action: 'read',
resource: 'document',
});
which gives us a Permit object we can use in our app:
permit.granted === true;
permit.anyGranted === false;
permit.ownGranted === true;
We see that this user has ONLY own access granted for this action "read", so they can't access any random resource item.
Important: In your app you MUST offer only the resource items allowed for each permit, so when ONLY own access is granted you MUST check the actual possession and start filtering.
We need to handle a) check one item's onwership and b) retrieve a filtered list of many own items.
Lets check if a particular documentId is owned by this user:
(await permit.isOwn(100)) === true;
(await permit.isOwn(200)) === false;
Lets now handle the set of documents owned by the user: there are 2 ways of achieving this, and it depends on your service.
The simplest (but not so scalable) is the one we used in our PDs above, the eager listOwned & listOwn() way.
But also check the lazy limitOwned & limitOwn() way, if you plan to scale. The 2 are not compatible and cant be mixed (in the same resource), so choose wisely!
Using listOwn() we get a FULL list of documentIds that are "owned" by this user:
await permit.listOwn();
// equals
[1, 10, 100];
Important: In your service you MUST always be picking your resource items, before you return them.
PermissionDefinitions & Permit decide what objects the calling app will receive, irrespective of permit.anyGranted being true/false (see reason in Example 2).
Its a good practice to pick just before sending the Output DTO object to the calling app.
First lets see what attributes we can access from an "own" document.
await permit.attributes(100);
// equals
['*', '!confidential'];
We get the allowed attributes for an own document for this user, i.e all attributes except 'confidential'.
Now lets see what we get from any random Document object.
await permit.attributes();
// equals
[];
await permit.attributes(200);
// equals
[];
No attributes allowed! Why did that happen?
Because we DONT own these random documents, we shouldn't be accessing them at all. Even if we try to return a document not owned (which we should not anyway), the permit.pick() operation below will give you an empty object.
The permit.attributes() is not very useful, you basically want to "pick" only the allowed attributes & values.
This is what permit.pick() does, similarly to lodash _.pick, but with the allowed attributes baked in.
Passing an own document, we get only all the allowed attributes (including 'someRandomField' since we have the "*" in our definition, but without 'confidential') :
await permit.pick({
id: 100,
title: 'Document 100 title',
date: '2020-02-19',
confidential: '100 secrets lie here',
someRandomField: 'Some random 100 value',
});
// equals
({ id: 100, title: 'Document 100 title', date: '2020-02-19', someRandomField: 'Some random 100 value' });
But passing an non-owned document, we will get an empty object:
await permit.pick({
id: 999,
title: 'Document 999 title',
date: '1920-02-19',
confidential: '999 secrets lie here',
someRandomField: 'Some random 999 value',
});
// equals
({});
Permit has some useful helpers, which handle internally the async nature of ownership hooks and thus can save you some frustration.
For example what if we are handling an array of Documents and we want to a) filter out non-owned ones and b) pick attributes of the owned ones?
await permit.filterPick([
{
id: 999,
title: 'Document 999 title',
date: '1920-02-19',
confidential: '999 secrets lie here',
someRandomField: 'Some random 999 value',
},
{
id: 100,
title: 'Document 100 title',
date: '2020-02-19',
confidential: '100 secrets lie here',
someRandomField: 'Some random 100 value',
},
]);
// equals
[{ id: 100, title: 'Document 100 title', date: '2020-02-19', someRandomField: 'Some random 100 value' }];
Note: ideally you should be filtering your data layer before you reach here, and this is where listOwn() & limitOwn() come in.
Another helper is permit.mapPick(), which is not filtering but only does a mapping and attributes picking:
await permit.mapPick(
[
{
id: 999,
title: 'Document 999 title',
date: '1920-02-19',
confidential: '999 secrets lie here',
someRandomField: 'Some random 999 value',
},
{
id: 100,
title: 'Document 100 title',
date: '2020-02-19',
confidential: '100 secrets lie here',
someRandomField: 'Some random 100 value',
},
],
(doc) => ({
...doc,
title: doc.title.toUpperCase(),
someNewField: 'Some new value',
})
);
// equals
[
{},
{
id: 100,
title: 'DOCUMENT 100 TITLE',
date: '2020-02-19',
someRandomField: 'Some random 100 value',
someNewField: 'Some new value',
},
];
It returns an empty object for documents that aren't owned
With the same EMPLOYEE user, lets grant permit for "list" action this time.
We see that the PD has both "list:own" & "list:any", with different set of attributes (i.e for non-own documents, I can only read title & date).
const permit = await permissions.grantPermit({
user: { id: 1, roles: ['EMPLOYEE'] },
action: 'list',
resource: 'document',
});
We indeed have "any":
permit.anyGranted && permit.ownGranted === true;
Just having "list:any" access doesnt mean all Documents are created equally:
await permit.mapPick([
{
id: 999,
title: 'Document 999 title',
date: '1920-02-19',
confidential: '999 secrets lie here',
someRandomField: 'Some random 999 value',
},
{
id: 100,
title: 'Document 100 title',
date: '2020-02-19',
confidential: '100 secrets lie here',
someRandomField: 'Some random 100 value',
},
]);
// equals
[
{ title: 'Document 999 title', date: '1920-02-19' },
{ id: 100, title: 'Document 100 title', date: '2020-02-19', someRandomField: 'Some random 100 value' },
];
How should permit.filterPick behave? Think for a minute.
Well, it should give the same result as permit.mapPick() (without a projectTo), cause filterPick should filter out non-own items, only when we DONT HAVE "any" access.
But this time we do, so it should respect that:
await permit.filterPick([
{
id: 999,
title: 'Document 999 title',
date: '1920-02-19',
confidential: '999 secrets lie here',
someRandomField: 'Some random 999 value',
},
{
id: 100,
title: 'Document 100 title',
date: '2020-02-19',
confidential: '100 secrets lie here',
someRandomField: 'Some random 100 value',
},
]);
// equals
[
{ title: 'Document 999 title', date: '1920-02-19' },
{ id: 100, title: 'Document 100 title', date: '2020-02-19', someRandomField: 'Some random 100 value' },
];
It follows that permit.pick behaves similarly, picking different attributes for "own" and "non-own" items, when "any" is granted:
await permit.pick({
id: 100,
title: 'Document 100 title',
date: '2020-02-19',
confidential: '100 secrets lie here',
someRandomField: 'Some random 100 value',
});
// equals
({ id: 100, title: 'Document 100 title', date: '2020-02-19', someRandomField: 'Some random 100 value' });
await permit.pick({
id: 999,
title: 'Document 999 title',
date: '1920-02-19',
confidential: '999 secrets lie here',
someRandomField: 'Some random 999 value',
});
// equals
({ title: 'Document 999 title', date: '1920-02-19' });
Users can have many roles. The mantra with multiple roles is:
A User with multiple roles, can do whatever each role could do individually, but NO MORE or NO LESS than that.
This principle should be followed by your roles & PermissionDefinitions as well. SuperAwesome Permissions follows this mantra, but there are some caveats in the current version (to be fixed soon).
If one Role grants an action, action is granted with the greatest possible possession in any of the grants (where any > own).
const permit = await permissions.grantPermit({
user: { id: 2, roles: ['EMPLOYEE', 'EMPLOYEE_MANAGER'] },
action: 'create',
resource: 'document',
});
permit.ownGranted === true; // from EMPLOYEE role
permit.anyGranted === false; // would be true only if some role had it
When handling one or many items with permit.isOwn, permit.listOwn or permit.limitOwn, the Permit will consider as "owned" the union of all resourceIds owned by each role that has the specific action.
Consider these different grantPermit() cases for the action: 'read , always for same User with id: 2, but with different roles in each attempted case, where all roles have the "read" action granted:
permit = await permissions.grantPermit({
user: { id: 2, roles: ['EMPLOYEE'] },
action: 'read',
resource: 'document',
});
await permit.listOwn();
// equals
[2, 20, 200];
permit = await permissions.grantPermit({
user: { id: 2, roles: ['EMPLOYEE_MANAGER'] },
action: 'read',
resource: 'document',
});
await permit.listOwn();
// equals
[2, 20, 200, 1, 10, 100, 4, 40, 400];
permit = await permissions.grantPermit({
user: { id: 2, roles: ['COMPANY_ADMIN'] },
action: 'read',
resource: 'document',
});
await permit.listOwn();
// equals
[1, 10, 100, 2, 20, 200, 3, 30, 300, 7, 70, 700];
permit = await permissions.grantPermit({
user: { id: 2, roles: ['EMPLOYEE_MANAGER', 'COMPANY_ADMIN'] },
action: 'read',
resource: 'document',
});
await permit.listOwn();
// equals
[2, 20, 200, 1, 10, 100, 4, 40, 400, 3, 30, 300, 7, 70, 700]; // merged - union of all owned hooks on all roles
If the action is not shared among the different roles (in different PDs), then only the ownerships in PDs that have this action come into play.
Consider the following cases for the action:'delete' this time, again for same User with id: 2, where only the EMPLOYEE_MANAGER role has the "delete" action granted:
permit = await permissions.grantPermit({
user: { id: 2, roles: ['EMPLOYEE_MANAGER'] },
action: 'delete',
resource: 'document',
});
expect(permit.granted).toBe(true);
expect(permit.anyGranted).toBe(false);
expect(permit.ownGranted).toBe(true);
await permit.listOwn();
// equals
[2, 20, 200, 1, 10, 100, 4, 40, 400];
permit = await permissions.grantPermit({
user: { id: 2, roles: ['COMPANY_ADMIN'] },
action: 'delete',
resource: 'document',
});
expect(permit.granted).toBe(false);
expect(permit.anyGranted).toBe(false);
expect(permit.ownGranted).toBe(false);
await permit.listOwn();
// Throws exception since even `permit.granted` is false
permit = await permissions.grantPermit({
user: { id: 2, roles: ['EMPLOYEE_MANAGER', 'COMPANY_ADMIN'] },
action: 'delete',
resource: 'document',
});
expect(permit.granted).toBe(true);
expect(permit.anyGranted).toBe(false);
expect(permit.ownGranted).toBe(true);
await permit.listOwn();
// equals
[2, 20, 200, 1, 10, 100, 4, 40, 400]; // only the EMPLOYEE_MANAGER ownership is active for delete action
In the real world this translates to
An EMPLOYEE_MANAGER managing a team of People can delete their documents. But a COMPANY_MANAGER can NOT delete company documents.
Therefore company documents are secured from being deleted, unlike the team's:
// an EMPLOYEE_MANAGER document
(await permit.isOwn(100)) === true;
// a COMPANY_ADMIN document, not considered as owned for **delete** action
(await permit.isOwn(700)) === false;
Attributes from all roles are merged as a union optimistically. This means that if any one Role can access an attribute, then the user can access it. This sounds right, until we think of ownership: the rule applies irrespective of the role that contributed to owning a resource which is problematic :-(
Consider this example:
const permit = await permissions.grantPermit({
user: { id: 2, roles: ['EMPLOYEE', 'EMPLOYEE_MANAGER'] },
action: 'list',
resource: 'document',
});
await permit.attributes();
// for non-own, its the merged of "any" attributes 'EMPLOYEE' of 'EMPLOYEE_MANAGER', which is expected:
['date', 'status', 'title'];
We see that for own resources, again we get the merged of "own" attributes of both roles, but really it should depend on the specific ownership:
await permit.attributes(200);
// equals CORRECTLY to
['*', '!confidential'];
We see that since ownership for resourceId = 200 is established by both EMPLOYEE & EMPLOYEE_MANAGER roles, it correctly equals to the most optimistic merged attributes.
Now this is the issue: in EMPLOYEE_MANAGER we have an extra restricted attribute !personal. The real world analogy is that an EMPLOYEE_MANAGER can "read" their employee documents, BUT NOT their "personal" attribute, as we want only the employee as the creator to access it. Think of it as some personal information the employee is adding to the doc, but their manager should not be able access it.
Notice now that documentId 400 is only owned by the EMPLOYEE_MANAGER role (and NOT by EMPLOYEE as the creator), i.e it is only the EMPLOYEE_MANAGER role that allows this user to access someones else's created document, hence the "personal" attribute on this particular item should not be accessed!
But lets see:
await permit.attributes(400);
// 400 is owned only by EMPLOYEE_MANAGER, but attributes incorrectly equal to:
['*', '!confidential']; // Attributes should really equal to ['*','!confidential','!personal'];
It seems that our user inherited an optimistically merged version of attributes for all own resources, irrespective of which role allowed the actual ownership of the resource.
It means a user with EMPLOYEE_MANAGER + EMPLOYEE together can do more things than EMPLOYEE alone and EMPLOYEE_MANAGER alone. This is contrary to our mantra "no more and no less".
So be aware of this glitch & as in all security tools, test well! The issue will be fixed in a future version of SuperAwesome Permissions.
permit.limitOwn()Make sure you've read how limitOwned / permit.limitOwn works
A simple Array collection using limitOwnReduce & lodash:
// example 5 in action
await(async () => {
const { Permissions } = require('@superawesome/permissions');
const _ = require('lodash');
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
const isEven = (n) => n % 2 === 0;
const isLarge = (n) => n > 7;
const isUserIdMatchesNumber = async ({ user, resourceId }) => user.id === resourceId;
// Setting up PermissionDefinitions
const permissions = new Permissions({
permissionDefinitions: [
{
roles: 'EvenNumbersRole',
isOwner: async ({ resourceId }) => isEven(resourceId),
limitOwned: ({ user, context: predicates = [] }) => [isEven, ...predicates],
grant: ['list'],
},
{
roles: 'LargeNumbersRole',
isOwner: async ({ resourceId }) => isLarge(resourceId),
limitOwned: ({ user, context: predicates = [] }) => [isLarge, ...predicates],
grant: ['list'],
},
{
roles: 'UserIdMatchesNumberRole',
isOwner: isUserIdMatchesNumber,
limitOwned: ({ user, context: predicates = [] }) => [(number) => user.id === number, ...predicates],
grant: ['list'],
},
],
permissionDefinitionDefaults: {
resource: 'numbers',
possession: 'own',
},
limitOwnReduce: ({ user, limitOwneds, context: predicates = [] }) => {
for (const limitOwned of limitOwneds) {
predicates = limitOwned({ user, context: predicates });
}
return _.overSome(predicates);
},
}).build();
// Granting permit for a given User at runtime, based on the above permissions.
const permit = await permissions.grantPermit({
user: {
id: 1,
roles: ['EvenNumbersRole', 'LargeNumbersRole', 'UserIdMatchesNumberRole'],
},
resource: 'numbers',
action: 'list',
});
return numbers.filter(permit.limitOwn());
});
// equals
[1, 2, 4, 6, 8, 9, 10, 11, 12];
We could simplify Example 5 more, cause if we dont need the context value, we can just omit it.
So by slightly adjusting our limitOwnReduce from example 5:
limitOwnReduce: ({ user, limitOwneds }) => _.overSome(limitOwneds.map(limitOwned => limitOwned({user}))),our limitOwned callbacks would also become much simpler:
{
roles: 'EvenNumbersRole',
limitOwned: () => isEven,
...
},
{
roles: 'LargeNumbersRole',
limitOwned: () => isLarge,
...
},
{
roles: 'UserIdMatchesNumberRole',
limitOwned: ({ user}) => (number) => user.id === number,
...
}The final code is neater:
// example 6 in action
await(async () => {
const { Permissions } = require('@superawesome/permissions');
const _ = require('lodash');
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
const isEven = (n) => n % 2 === 0;
const isLarge = (n) => n > 7;
const isUserIdMatchesNumber = async ({ user, resourceId }) => user.id === resourceId;
// Setting up PermissionDefinitions
const permissions = new Permissions({
permissionDefinitions: [
{
roles: 'EvenNumbersRole',
isOwner: async ({ resourceId }) => isEven(resourceId),
limitOwned: () => isEven,
grant: ['list'],
},
{
roles: 'LargeNumbersRole',
isOwner: async ({ resourceId }) => isLarge(resourceId),
limitOwned: () => isLarge,
grant: ['list'],
},
{
roles: 'UserIdMatchesNumberRole',
isOwner: isUserIdMatchesNumber,
limitOwned: ({ user }) => (number) => user.id === number,
grant: ['list'],
},
],
permissionDefinitionDefaults: {
resource: 'numbers',
possession: 'own',
},
limitOwnReduce: ({ user, limitOwneds }) =>
_.overSome(limitOwneds.map((limitOwned) => limitOwned({ user }))),
}).build();
// Granting permit for a given User at runtime, based on the above permissions.
const permit = await permissions.grantPermit({
user: {
id: 1,
roles: ['EvenNumbersRole', 'LargeNumbersRole', 'UserIdMatchesNumberRole'],
},
resource: 'numbers',
action: 'list',
});
return numbers.filter(permit.limitOwn());
});
// equals
[1, 2, 4, 6, 8, 9, 10, 11, 12];