File

src/Permissions.ts

Description

The options passed at the Permissions constructor

Index

Properties

Properties

limitOwnReduce
limitOwnReduce: TlimitOwnReduce<TUserId | any>
Type : TlimitOwnReduce<TUserId | any>
Optional
permissionDefinitionDefaults
permissionDefinitionDefaults: PermissionDefinitionDefaults
Type : PermissionDefinitionDefaults
Optional
permissionDefinitions
permissionDefinitions: PermissionDefinition<TUserId | TResourceId> | PermissionDefinition<TUserId, TResourceId>[]
Type : PermissionDefinition<TUserId | TResourceId> | PermissionDefinition<TUserId, TResourceId>[]
Optional
import * as _ from 'lodash';
import * as _f from 'lodash/fp';
import { diff } from 'json-diff';
import { AccessControl, IQueryInfo, Permission } from 'accesscontrol';
// own
import { AccessControlRe } from 'accesscontrol-re';
import {
  EPossession,
  GrantPermitQuery,
  isValidIUser,
  Tid,
  TisOwner,
  TlimitOwned,
  TlimitOwnReduce,
  TlistOwned,
} from './types';
import {
  buildAccessControl,
  deleteEmptyArrayKeys,
  hasSomeOwnGrant,
  stringify,
  projectPDWithDefaultsToInternal,
} from './utils';
import { consolidatePermissionDefinitions } from './consolidations';
import { Permit } from './Permit.class';
import {
  PermissionDefinition,
  PermissionDefinitionDefaults,
  PermissionDefinitionInternal,
} from './PermissionDefinitions';
import { getLogger } from './logger';

/**
 The options passed at the `Permissions` constructor
 */
export interface IPermissionsOptions<
  TUserId extends Tid = number,
  TResourceId extends Tid = number
> {
  permissionDefinitions?:
    | PermissionDefinition<TUserId, TResourceId>
    | PermissionDefinition<TUserId, TResourceId>[];

  permissionDefinitionDefaults?: PermissionDefinitionDefaults;

  limitOwnReduce?: TlimitOwnReduce<TUserId, any>;
}

/**
 The main class - see [Basic Usage](/additional-documentation/basic-usage.html)
*/
export class Permissions<TUserId extends Tid = number, TResourceId extends Tid = number> {
  private _permissionDefinitionsInternal: PermissionDefinitionInternal[] = [];

  private _accessControl: AccessControl;

  private _acre: AccessControlRe;

  private _rolesNotFound = {};

  private roles: string[];

  private _limitOwnReduce: TlimitOwnReduce<TUserId, any>;

  private _isBuilt = false;

  constructor({
    permissionDefinitions,
    permissionDefinitionDefaults,
    limitOwnReduce,
  }: IPermissionsOptions<TUserId, TResourceId> = {}) {
    this._limitOwnReduce = limitOwnReduce;
    this.addDefinitions(permissionDefinitions || [], permissionDefinitionDefaults);
  }

  public addDefinitions(
    permissionDefinitions:
      | PermissionDefinition<TUserId, TResourceId>
      | PermissionDefinition<TUserId, TResourceId>[],
    permissionDefinitionDefaults: PermissionDefinitionDefaults = {}
  ) {
    this.ensureHasNotBuild();

    if (!permissionDefinitions)
      throw new Error(
        `SA-Permissions: in addDefinitions(), invalid permissionDefinitions: ${stringify(
          permissionDefinitions
        )}`
      );
    if (!_.isArray(permissionDefinitions)) permissionDefinitions = [permissionDefinitions];

    const ipdsToAdd = permissionDefinitions.map(
      projectPDWithDefaultsToInternal(permissionDefinitionDefaults)
    );

    // sanity checks before adding ipds
    _.each(ipdsToAdd, (ipdToAdd, ipdToAddIdx): any => {
      // if we are trying to redefine a role+resource+action:possession
      // with DIFFERENT attributes (i.e non-strict) throw as its very dangerous!
      const nonStrictDuplicatePds = this.filterPDsWithDuplicateGrantActions(ipdToAdd);

      if (!_.isEmpty(nonStrictDuplicatePds)) {
        const firstConflictingAction = _.findKey(
          ipdToAdd.grant,
          (attributes, action) => !!nonStrictDuplicatePds[0].grant[action]
        );
        throw new Error(
          `SA-Permissions: InvalidPermissionDefinitionError: addDefinitions() redefining action error.
            Action: "${firstConflictingAction}"
            Action Attributes: ${stringify(ipdToAdd.grant[firstConflictingAction])}
            While adding PD: ${stringify(ipdToAdd)}
            Conflicted with PD: ${stringify(nonStrictDuplicatePds[0])}`
        );
      }

      // if we are trying to redefine a role+resource+action:possession
      // even with SAME different attributes (i.e very strict) warn as obsolete!
      const strictDuplicatePds = this.filterPDsWithDuplicateGrantActions(ipdToAdd, true);

      if (!_.isEmpty(strictDuplicatePds)) {
        const firstConflictingAction = _.findKey(
          ipdToAdd.grant,
          (attributes, action) => !!strictDuplicatePds[0].grant[action]
        );
        getLogger().warn(
          `addDefinitions() redefining action in a PD with same attributes is obsolete:`,
          {
            action: firstConflictingAction,
            attributes: ipdToAdd.grant[firstConflictingAction],
            permissionDefinition: ipdToAdd,
          }
        );
      }

      if (hasSomeOwnGrant(ipdToAdd)) {
        const isOwnerFound = !!ipdToAdd.isOwner;
        let listOwnedFound = !!ipdToAdd.listOwned;
        let limitOwnedFound = !!ipdToAdd.limitOwned;

        // on this PD
        if (listOwnedFound && limitOwnedFound)
          throw new Error(
            `SA-Permissions: in addDefinitions() found BOTH "listOwned" & "limitOwned" callbacks in the added PermissionDefinition. Use one or the other, but not both. PermissionDefinition = ${JSON.stringify(
              permissionDefinitions[ipdToAddIdx],
              null,
              2
            )}`
          );

        // It has some OWN Grant, but no owner hooks found, throw
        if (!isOwnerFound || (!listOwnedFound && !limitOwnedFound)) {
          throw new Error(
            `SA-Permissions: in addDefinitions() PermissionDefinition has 'own' action but no ${
              !isOwnerFound ? '"isOwner"' : '"listOwned" nor "limitOwned"'
            } callbacks are there. PermissionDefinition = ${stringify(ipdToAdd)} `
          );
        }

        // check all for same resource as ipdToAdd
        let conflictedPD;
        for (const opd of this._permissionDefinitionsInternal) {
          if (ipdToAdd.resource === opd.resource) {
            listOwnedFound = listOwnedFound || !!opd.listOwned;
            limitOwnedFound = limitOwnedFound || !!opd.limitOwned;
          }
          if (listOwnedFound && limitOwnedFound) {
            conflictedPD = opd;
            break;
          }
        }
        if (listOwnedFound && limitOwnedFound)
          throw new Error(
            `SA-Permissions: in addDefinitions() found BOTH "listOwned" & "limitOwned" callbacks in some PermissionDefinition for resource "${
              ipdToAdd.resource
            }". Use one or the other, but not both.
            Adding PD: ${stringify(permissionDefinitions[ipdToAddIdx])}
            Conflicted with PD: ${stringify(conflictedPD)}`
          );
      }

      this._permissionDefinitionsInternal.push(ipdToAdd); // all ok, add it!
    });
  }

  /**
   * Check is this Permissions instance has been built (so no more .addDefinitions() allowed)
   */
  public get isBuilt(): boolean {
    return this._isBuilt;
  }

  public build() {
    this._isBuilt = true;
    if (this._acre) return this;
    [this._accessControl, this._acre] = buildAccessControl(this._permissionDefinitionsInternal);
    this.roles = this.getRoles();
    return this;
  }

  /**
   The `grantPermit()` is the way to *query* the Permissions instance for granting permissions to a User.

   The method responds with an instance of [Permit](/classes/Permit.html) that holds all known information about the queried **user**, **resource** and **action**.

   In short, the question is "can some of `user.roles` perform `action` either a) on **any** `resource` or b) on an **own** `resource` (AND the specific `resourceId` if passed)?

   We are checking all roles for both **any** & **own**, while collecting all `isOwner` & `listOwned` and feed all known information into a **Permit** object.

   @return Promise<Permit> a Promise of a [Permit](/classes/Permit.html) instance.
   */
  public async grantPermit({
    // <TUserId extends Tid = number, TResourceId extends Tid = number>
    user,
    action,
    resource,
    resourceId,
  }: GrantPermitQuery<TUserId, TResourceId>): Promise<Permit<TUserId, TResourceId>> {
    this.ensureHasBuild();
    if (!isValidIUser(user))
      throw new Error(
        'SA-Permissions: at grantPermit(), user is not a valid `interface IUser {id: TId; roles: string[];}`'
      );

    if (!this.getResources().includes(resource))
      throw new Error(`SA-Permissions: at grantPermit(), Invalid resource: "${resource}"`);

    if (action.split(':').length > 1)
      throw new Error(
        `SA-Permissions: at grantPermit(), Invalid action structure: "${action}". The colon ":" in the action is not allowed on grantPermit() and you must NOT specify ":own" or ":any" after the action at it. SA-Permissions always returns a Permit that checks for both any & own.`
      );

    let acPermission: Permission; // = { granted: false } as any;
    let anyAcPermission: Permission;
    let ownAcPermission: Permission;
    // The `Permit` values
    const isOwners: TisOwner<TUserId, TResourceId>[] = [];
    const listOwneds: TlistOwned<TUserId, TResourceId>[] = [];
    const limitOwneds: TlimitOwned<any, TUserId>[] = [];

    // 2 passes: check all EPossession against all roles.
    // if any permissions.granted is true, granted is true
    //  but continue to gather all permissions.attributes, isOwner & listOwned
    for (const queryPossession of [EPossession.any, EPossession.own]) {
      getLogger().debug('grantPermit: possession', { possession: queryPossession });

      const unknownRoles = _.difference(user.roles, this.roles);
      const roles = _.without(user.roles, ...unknownRoles);

      _.each(unknownRoles, (rl) => {
        if (!this._rolesNotFound[rl]) {
          this._rolesNotFound[rl] = true;
          getLogger().warn(
            `SA-Permissions(): at grantPermit(), role not found: ${rl} (will not warn again about this role)`
          );
        }
      });

      const queryInfo: IQueryInfo = {
        role: roles,
        action: `${action}:${queryPossession}`,
        resource,
      };

      try {
        acPermission = this._acre.permission(queryInfo);
      } catch (error) {
        // @todo: handle
        throw error;
      }

      getLogger().debug('grantPermit: this._accessControl.permission(queryInfo)', {
        queryInfo,
        'permission.granted': acPermission.granted,
        'permission.attributes': acPermission.attributes,
      });

      switch (queryPossession) {
        case EPossession.any: {
          anyAcPermission = acPermission;
          break;
        }

        case EPossession.own: {
          ownAcPermission = acPermission;

          if (ownAcPermission.granted) {
            const matchingCpds = _.filter(this._permissionDefinitionsInternal, (pd) => {
              return (
                _.some(pd.roles, (pdRole) => user.roles.includes(pdRole)) &&
                (resource === pd.resource || pd.resource === '*') &&
                (!!(pd?.grant || {})[`${action}:${EPossession.own}`] ||
                  !!(pd?.grant || {})[`*:${EPossession.own}`] ||
                  !!(pd?.grant || {})[`${action}:${EPossession.any}`] ||
                  !!(pd?.grant || {})[`*:${EPossession.any}`])
              );
            });

            // prettier-ignore
            if (!anyAcPermission.granted && _.isEmpty(matchingCpds))
              throw new Error(
                `SA-Permissions: own access granted but no matching PermissionDefinitions found: ` +
                `${stringify({ user, action, resource })}`,
              );

            _.each(matchingCpds, (cpd) => {
              const { isOwner, listOwned, limitOwned } = cpd;
              if (isOwner) isOwners.push(isOwner as any);
              if (listOwned) listOwneds.push(listOwned as any);
              if (limitOwned) limitOwneds.push(limitOwned as any);
            });
          }
          break;
        }

        default:
          throw new Error(
            `SA-Permissions::grantPermit: invalid EPossession in queryPossession "${queryPossession}"`
          );
      }
    }

    const permit = new (Permit as any)( // constructor is best kept private, only we should use it!
      user,
      action,
      resource,
      resourceId,
      anyAcPermission,
      ownAcPermission,
      _.uniq(isOwners),
      _.uniq(listOwneds),
      _.uniq(limitOwneds),
      this._limitOwnReduce
    );

    // prettier-ignore
    if (!anyAcPermission.granted && ownAcPermission.granted) {
      // The following checks SHOULD NOT be needed, they should be caught at the addDefinitions() call. Please report to authors if you encounter them.
      const createError = (butDetail: string) =>
        new Error(`SA-Permissions: grantPermit() "OWN" access granted but ${butDetail
        }. The error should have been caught at addDefinitions() call, please report to authors. GrantPermitQuery = ${
          stringify({ user, action, resource, resourceId })}`);

      if (_.isEmpty(isOwners)) throw createError('no "isOwner" ownership hook found');
      if (_.isEmpty(listOwneds) && _.isEmpty(limitOwneds)) throw createError('no "listOwned" nor "limitOwned" ownership hooks found');
      if (!_.isEmpty(listOwneds) && !_.isEmpty(limitOwneds)) throw createError('found BOTH "listOwned" & "limitOwned" ownership hooks. Use one or the other, but not both');

      if (resourceId) (permit as any).resourceIdOwnPermissionGranted = await permit.isOwn(resourceId);
    }

    return permit as Permit<TUserId, TResourceId>;
  }

  // some helpers
  public getRoles(): string[] {
    this.ensureHasBuild();
    return this._acre.getRoles();
  }

  public getResources(): string[] {
    this.ensureHasBuild();
    return this._acre.getResources();
  }

  public getActions(): string[] {
    this.ensureHasBuild();
    return this._acre.getActions();
  }

  /**
   * Returns a deep clone of [`AccessControl#getGrants()`](https://onury.io/accesscontrol/?api=ac#AccessControl#getGrants) (which according to its docs `Gets the internal grants object that stores all current grants.`), but omitting empty arrays eg `'rollover:any': []`.
   *
   * @see https://onury.io/accesscontrol/?api=ac#AccessControl#getGrants
   */
  public getGrants(): object {
    // @todo: typings
    this.ensureHasBuild();
    // delete empty arrays, eg `'rollover:any': []` cause they are useless & break our `compare()`
    return deleteEmptyArrayKeys(_.cloneDeep(this._accessControl.getGrants()));
  }

  public compare(permissions1: Permissions<any, any>, permissions2: Permissions<any, any> = this) {
    return diff(permissions1.getGrants(), permissions2.getGrants());
  }

  // Grab accessControl.getGrants(), BUT delete all empty / denied grants that

  /**
   Returns a list of the `PermissionDefinition` objects stored in this instance, with optional filtering & consolidations removing duplicates and redundant grants (**WARNING**: this is experimental)

   @param filter allows you to filter PDs:

     * Use an object eg `{ resource: 'document' }` as the `_.matches` iteratee shorthand.
       If this `_.matches` object is used, the props used for filtering are considered "default" and are omitted from each PD.

     * OR use a function returning boolean for each PD, eg (pd) => pd.resource === 'document'

     See https://lodash.com/docs/4.17.11#filter

   @param consolidateFlag is **experimental**, it tries to consolidate PermissionDefinitions, remove duplicates and merge compatible ones
  */
  public getDefinitions(
    filter?: {
      [key: string]: any;
    },
    consolidateFlag: boolean | 'force' = false
  ): Partial<PermissionDefinitionInternal>[] {
    const filteredPDs = _f.flow(
      _f.filter(filter),
      _f.reject((opd) => _.isEmpty(opd.grant))
    )(this._permissionDefinitionsInternal);

    const resultPDs = consolidateFlag
      ? consolidatePermissionDefinitions(
          filter,
          consolidateFlag
        )(_.cloneDeep(this._permissionDefinitionsInternal))
      : filteredPDs;

    const resultedSaPermissions = new Permissions({
      permissionDefinitions: resultPDs,
      permissionDefinitionDefaults: filter,
    }).build();

    const filteredInstancePermissions = new Permissions({
      permissionDefinitions: filteredPDs as any,
      permissionDefinitionDefaults: filter,
    }).build();

    const difference = this.compare(resultedSaPermissions, filteredInstancePermissions);

    if (difference !== undefined) {
      throw new Error(
        `SA-Permissions: getDefinitions diff:
          ${stringify(difference)}

         Existing grants:
          ${stringify(this.getGrants())}

         Generated grants:
          ${stringify(resultedSaPermissions.getGrants())}
        `
      );
    }

    return resultPDs;
  }

  private ensureHasBuild() {
    if (!this._acre)
      throw new Error(
        `SA-Permissions InvalidInvocation: calling permissions methods before having build()`
      );
  }

  private ensureHasNotBuild() {
    if (this._acre)
      throw new Error(
        `SA-Permissions InvalidInvocation: calling addDefinitions() after having build()`
      );
  }

  /**
   *
   * @param pdi a PermissionDefinitionInternal
   * @param strict true means we dont care if redefining action is _.equal. Duplicating is bad enough!
   */
  private filterPDsWithDuplicateGrantActions = (
    pdi: PermissionDefinitionInternal,
    strict = false
  ) =>
    _.filter(
      this._permissionDefinitionsInternal,
      (originalIpd) =>
        _.isEqual(originalIpd.resource, pdi.resource) &&
        _.some(pdi.roles, (ipdToAddRole) => _.includes(originalIpd.roles, ipdToAddRole)) &&
        _.some(
          pdi.grant,
          (attributes, action) =>
            !!originalIpd.grant[action] &&
            (strict || !_.isEqual(originalIpd.grant[action], pdi.grant[action]))
        )
    );
}

result-matching ""

    No results matching ""