File

libs/acl/src/lib/acl-engine/acl-engine.ts

Implements

AclEvaluationInterface

Index

Properties
Methods
Accessors

Constructor

constructor(store?: ExpressionStoreAdapter)
Parameters :
Name Type Optional
store ExpressionStoreAdapter Yes

Properties

unscoped
Type : AclEvaluationInterface
Default value : this

Methods

_setGlobalRules
_setGlobalRules(rules: AclRule[])

This replaces the rules with a new set of rules. Only use this if you know what you are doing! Internal usage only! This might be removed at any time without deprecation.

Parameters :
Name Type Optional
rules AclRule[] No
Returns : void
canEdit
canEdit(aclPath: string)
Parameters :
Name Type Optional
aclPath string No
Returns : boolean
canEdit$
canEdit$(aclPath: string)
Parameters :
Name Type Optional
aclPath string No
Returns : Observable<boolean>
canShow
Use `!aclEngine.isHidden(aclPath)` instead
canShow(aclPath: string)
Parameters :
Name Type Optional
aclPath string No
Returns : boolean
canShow$
Use `aclEngine.isHidden$(aclPath).pipe(map((value) => !value))` instead
canShow$(aclPath: string)
Parameters :
Name Type Optional
aclPath string No
Returns : Observable<boolean>
isDisabled
isDisabled(aclPath: string)
Parameters :
Name Type Optional
aclPath string No
Returns : boolean
isDisabled$
isDisabled$(aclPath: string)
Parameters :
Name Type Optional
aclPath string No
Returns : Observable<boolean>
isHidden
isHidden(aclPath: string)
Parameters :
Name Type Optional
aclPath string No
Returns : boolean
isHidden$
isHidden$(aclPath: string)
Parameters :
Name Type Optional
aclPath string No
Returns : Observable<boolean>
isReadonly
isReadonly(aclPath: string)
Parameters :
Name Type Optional
aclPath string No
Returns : boolean
isReadonly$
isReadonly$(aclPath: string)
Parameters :
Name Type Optional
aclPath string No
Returns : Observable<boolean>
listen
listen(callback: (decision: AclDecision) => void)
Parameters :
Name Type Optional
callback function No
Returns : void
removeDynamicFormRules
removeDynamicFormRules(formId: string)
Parameters :
Name Type Optional
formId string No
Returns : void
setDynamicFormRules
setDynamicFormRules(formId: string, aclRules: AclRule[])
Parameters :
Name Type Optional
formId string No
aclRules AclRule[] No
Returns : void
setEnvironmentValue
setEnvironmentValue(key: string, value: string)
Parameters :
Name Type Optional
key string No
value string No
Returns : void
setGlobalRulesFromPolicyTxtFile
setGlobalRulesFromPolicyTxtFile(fileContent: string)
Parameters :
Name Type Optional
fileContent string No
Returns : void

Accessors

rules
getrules()
import { combineLatest, EMPTY, merge, Observable, Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, map, share, startWith } from 'rxjs/operators';
import { isConditionMet } from './condition';
import { parsePolicyTxtContent } from './policy';
import { isRuleRelevantForPath } from './rule';
import {
  AclDecision,
  AclEvaluationInterface,
  AclResourceState,
  AclRule,
  ExpressionStoreAdapter
} from './types';

export class AclEngine implements AclEvaluationInterface {
  unscoped: AclEvaluationInterface = this;

  private _rules: AclRule[] = [];
  private _globalRules: AclRule[] = [];
  private _dynamicFormRulesMap = new Map<string, AclRule[]>();
  private environment: Record<string, string> = {};
  private reportListener?: (rule: AclDecision) => void;

  private cacheInvalidation$ = new Subject<void>();
  private storeDataChanged$;

  private relevantRulesCache = new Map<string, AclRule[]>();

  constructor(private store?: ExpressionStoreAdapter) {
    this.storeDataChanged$ = this.store?.dataChanged$.pipe(debounceTime(10), share());
  }

  setGlobalRulesFromPolicyTxtFile(fileContent: string): void {
    this._globalRules = parsePolicyTxtContent(fileContent);
    this.updateRules();

    if (!this.store && this._globalRules.some((rule) => rule.condition.includes('s('))) {
      throw new Error(
        'You have used the store query function in your policy, but no store was provided!'
      );
    }
  }

  get rules(): AclRule[] {
    return structuredClone(this._rules);
  }

  listen(callback: (decision: AclDecision) => void) {
    this.reportListener = callback;
  }

  setEnvironmentValue(key: string, value: string): void {
    this.environment[`env.${key}`] = value;
    this.cacheInvalidation$.next();
  }

  /**
   * This replaces the rules with a new set of rules.
   * Only use this if you know what you are doing! Internal usage only!
   * This might be removed at any time without deprecation.
   */
  _setGlobalRules(rules: AclRule[]): void {
    const globalRules = rules.filter((rule) => !rule.dynamicFormRule);
    this._globalRules = globalRules;
    this.updateRules();
    this.relevantRulesCache.clear();
    this.cacheInvalidation$.next();
  }

  setDynamicFormRules(formId: string, aclRules: AclRule[]) {
    this._dynamicFormRulesMap.set(formId, aclRules);
    this.updateRules();
    this.relevantRulesCache.clear();
    this.cacheInvalidation$.next();

    if (!this.store && aclRules.some((rule) => rule.condition.includes('s('))) {
      throw new Error(
        'You have used the store query function in your policy, but no store was provided!'
      );
    }
  }

  removeDynamicFormRules(formId: string) {
    this._dynamicFormRulesMap.delete(formId);
    this.updateRules();
  }

  private updateRules() {
    const dynamicFormRules = Array.from(this._dynamicFormRulesMap.values()).flatMap(
      (aclRules) => aclRules
    );
    this._rules = [...this._globalRules, ...dynamicFormRules];
  }

  /**
   * @deprecated Use `aclEngine.isHidden$(aclPath).pipe(map((value) => !value))` instead
   */
  canShow$(aclPath: string): Observable<boolean> {
    return this.isHidden$(aclPath).pipe(map((value) => !value));
  }

  canEdit$(aclPath: string): Observable<boolean> {
    return combineLatest([this.isDisabled$(aclPath), this.isReadonly$(aclPath)]).pipe(
      map(([isDisabled, isReadonly]) => !isDisabled && !isReadonly)
    );
  }

  /**
   * @deprecated Use `!aclEngine.isHidden(aclPath)` instead
   */
  canShow(aclPath: string): boolean {
    return !this.isHidden(aclPath);
  }

  canEdit(aclPath: string): boolean {
    return !this.isDisabled(aclPath) && !this.isReadonly(aclPath);
  }

  isReadonly(aclPath: string): boolean {
    return this.is(aclPath, 'readonly');
  }

  isReadonly$(aclPath: string): Observable<boolean> {
    return this.mapStoreChanges$(() => this.is(aclPath, 'readonly'));
  }

  isDisabled(aclPath: string): boolean {
    return this.is(aclPath, 'disabled');
  }

  isDisabled$(aclPath: string): Observable<boolean> {
    return this.mapStoreChanges$(() => this.is(aclPath, 'disabled'));
  }

  isHidden(aclPath: string): boolean {
    return this.is(aclPath, 'hidden');
  }

  isHidden$(aclPath: string): Observable<boolean> {
    return this.mapStoreChanges$(() => this.is(aclPath, 'hidden'));
  }

  private is(aclPath: string, state: AclResourceState): boolean {
    const cacheKey = `${aclPath}~~${state}`;
    let relevantRules = this.relevantRulesCache.get(cacheKey);

    if (!relevantRules) {
      relevantRules = this._rules.filter((rule) => isRuleRelevantForPath(rule, aclPath, state));
      this.relevantRulesCache.set(cacheKey, relevantRules);
    }

    const applicableRule = relevantRules.find((rule) =>
      isConditionMet(
        rule.condition,
        this.environment,
        this.store?.queryStoreValue ??
          (() => {
            throw new Error('No store available!');
          })
      )
    );

    // At this point, we know that either
    // - there is no applicable rule or
    // - the applicable rule that we found is the one that determines the state of the resource.
    // The found applicable rule will always be "relevant" for the requested state.
    // If, for example, "hidden" was requested, the applicable rule will always have either "hidden"
    // or "visible" as its state.
    // If, for example, "readonly" was requested, the applicable rule will always have either
    // "readonly" or "editable" as its state.
    // If there is no applicable rule, the below equation evaluates to false and we end up with
    // our default decision, which is "false" (not hidden, not readonly, not disabled).
    // This means that we can simply compare the two states and use that as our decision.
    const decision = state === applicableRule?.state;

    if (this.reportListener) {
      this.reportListener({ path: aclPath, state, decision, timestamp: Date.now() });
    }

    return decision;
  }

  private mapStoreChanges$(mapFn: () => boolean): Observable<boolean> {
    return merge(this.storeDataChanged$ ?? EMPTY, this.cacheInvalidation$).pipe(
      startWith(null),
      map(mapFn),
      distinctUntilChanged()
    );
  }
}

results matching ""

    No results matching ""