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

Detailed Usage & Examples

Let's look at some examples, so we can better guide the discussion.

Hypothetical Schema

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.

Roles and their CRUD Rules:

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.

1. Adding PermissionDefinitions & build()

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.

2. Granting Permissions

We can now start Granting Permissions, i.e grantPermit().

Example 1

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;

Basic Permissions - Ownership only

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.

permit.isOwn()

Lets check if a particular documentId is owned by this user:

(await permit.isOwn(100)) === true;
(await permit.isOwn(200)) === false;

permit.listOwn()

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];

3. Filtering & Picking the right objects

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.

permit.attributes()

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.

permit.pick()

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.

Owned

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

Not owned

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
({});

Helpers to filter, pick & map

Permit has some useful helpers, which handle internally the async nature of ownership hooks and thus can save you some frustration.

permit.filterPick()

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.

permit.mapPick()

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

4. Let SuperAwesome Permissions & PermissionDefinitions shape your App's data

Example 2

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;

Why you should be picky

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' },
];

Now the Question: to filterPick or not to filterPick?

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' },
];

Pick has your back

It follows that permit.pick behaves similarly, picking different attributes for "own" and "non-own" items, when "any" is granted:

Picking Own

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

Picking non-own, using any

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

5. A User with many Roles

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).

Example 3 - Action merging

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

Ownership evaluation merging

Shared action

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:

With role(s) EMPLOYEE we get:

permit = await permissions.grantPermit({
  user: { id: 2, roles: ['EMPLOYEE'] },
  action: 'read',
  resource: 'document',
});

await permit.listOwn();
// equals
[2, 20, 200];

With role(s) EMPLOYEE_MANAGER we get:

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];

With role(s) COMPANY_ADMIN we get:

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];

With role(s) EMPLOYEE_MANAGER, COMPANY_ADMIN we get:

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

Not shared action

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:

With role(s) EMPLOYEE_MANAGER we get:

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];

With role(s) COMPANY_ADMIN we get:

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

With role(s) EMPLOYEE_MANAGER, COMPANY_ADMIN we get:

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;

Example 4 - Over-optimistic attributes merging for own resources on multiple roles (see Caveat #2)

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.

6. Scaling using permit.limitOwn()

Make sure you've read how limitOwned / permit.limitOwn works

Example 5 - limitOwn

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];

Example 6

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];

result-matching ""

    No results matching ""