src/app/core/common-components/entity-form/dynamic-form-validators/permission-condition-validators.ts

Description

A single per-field decision derived from one permission rule: if its condition fragment matches the field's value, the rule decides the outcome (deny for inverted rules, grant otherwise).

import { inject, Injectable } from "@angular/core";
import { ValidatorFn } from "@angular/forms";
import { Ability, subject } from "@casl/ability";
import { Entity } from "../../../entity/model/entity";
import { EntitySchemaField } from "../../../entity/schema/entity-schema-field";
import { EntitySchemaService } from "../../../entity/schema/entity-schema.service";
import { ConfigurableEnumService } from "../../../basic-datatypes/configurable-enum/configurable-enum.service";
import { ConfigurableEnumDatatype } from "../../../basic-datatypes/configurable-enum/configurable-enum-datatype/configurable-enum.datatype";
import { EntityAbility } from "../../../permissions/ability/entity-ability";

/**
 * Minimal structural type of the CASL rules (see `Ability.rulesFor`)
 * that condition validators are derived from.
 */
interface RuleWithConditions {
  conditions?: Record<string, any>;
  inverted?: boolean;
}

const MATCH_ACTION = "match";
const MATCH_SUBJECT = "PermissionConditionCheck";

/**
 * Derives form validators and human-readable messages from the `conditions`
 * of the current user's permission rules, so unmet conditions surface as
 * normal field errors instead of only a technical rejection when saving.
 */
@Injectable({ providedIn: "root" })
export class PermissionConditionValidatorsService {
  private readonly ability = inject(EntityAbility);
  private readonly entitySchemaService = inject(EntitySchemaService);
  private readonly enumService = inject(ConfigurableEnumService);

  /**
   * Validator reflecting the current user's permission rule conditions for the
   * given field, or `null` if the field's value cannot be the reason for a
   * denied save.
   */
  forField(entity: Entity, fieldId: string): ValidatorFn | null {
    const action = entity.isNew ? "create" : "update";
    const rules = this.ability.rulesFor(action, entity.getType());
    const schemaField = entity.getSchema().get(fieldId);

    // rule conditions are evaluated against entities in database format
    return buildPermissionConditionValidator(
      rules,
      fieldId,
      (value) =>
        this.entitySchemaService.valueToDatabaseFormat(
          value,
          schemaField,
          entity,
        ),
      this.getConditionValueFormatter(schemaField),
    );
  }

  /**
   * Human-readable summary of the values the current user's permission rule
   * conditions require on the entity (empty string if no rule has conditions),
   * e.g. to explain a denied save.
   */
  describeRequiredValues(action: "create" | "update", entity: Entity): string {
    return this.ability
      .rulesFor(action, entity.getType())
      .filter(
        (r) =>
          !r.inverted && r.conditions && Object.keys(r.conditions).length > 0,
      )
      .map((r) =>
        Object.entries(r.conditions)
          .map(([field, fragment]) => {
            const schemaField = entity.getSchema().get(field);
            const description = describeConditionFragment(
              fragment,
              this.getConditionValueFormatter(schemaField),
            );
            return `${schemaField?.label ?? field}: ${description}`;
          })
          .join(", "),
      )
      .join($localize`:joining alternative permission conditions: or `);
  }

  /**
   * Human-readable display of a single (database-format) condition value,
   * resolving configurable-enum ids to their labels.
   */
  private getConditionValueFormatter(
    schemaField: EntitySchemaField | undefined,
  ): ((value: any) => string) | undefined {
    if (schemaField?.dataType !== ConfigurableEnumDatatype.dataType) {
      return undefined;
    }
    return (value) =>
      this.enumService
        .getEnumValues(schemaField.additional)
        .find((option) => option.id === value)?.label ?? String(value);
  }
}

/**
 * A single per-field decision derived from one permission rule: if its
 * condition fragment matches the field's value, the rule decides the outcome
 * (deny for inverted rules, grant otherwise).
 */
interface FieldDecision {
  fragment: any;
  inverted: boolean;
  matcher: Ability;
}

/**
 * Create a form validator that checks a single field's value against the
 * permission rule `conditions` restricting it, so that users see a normal
 * field error instead of only a technical rejection when saving.
 *
 * The rules are evaluated in the given priority order (as returned by
 * `ability.rulesFor`): the first rule whose condition matches the value
 * decides, mirroring CASL's own precedence.
 *
 * Returns `null` if the field's value cannot be the reason for a denied save.
 * Inverted ("cannot") rules with conditions on several fields and conditions
 * on properties without a form control (e.g. "created.by") are not covered
 * here and remain handled by the save-time permission check.
 *
 * @param rules The relevant CASL rules for the action and entity type (`ability.rulesFor(...)`)
 * @param fieldId The form field / entity property to validate
 * @param toDbFormat Transformation of the control value into database format,
 *   as rule conditions are evaluated against database-format entities
 * @param formatValue Lookup of a human-readable display for a single
 *   (database-format) condition value, e.g. resolving enum ids to labels
 */
export function buildPermissionConditionValidator(
  rules: RuleWithConditions[],
  fieldId: string,
  toDbFormat: (value: any) => any = (value) => value,
  formatValue?: (value: any) => string,
): ValidatorFn | null {
  if (!rules.some((r) => !r.inverted)) {
    // without any granting rule the action is denied as a whole,
    // which is handled by disabling the form, not per-field errors
    return null;
  }

  const decisions: FieldDecision[] = [];
  let unconditionalGrant = false;
  for (const rule of rules) {
    if (!rule.inverted) {
      if (rule.conditions && fieldId in rule.conditions) {
        decisions.push(createFieldDecision(rule, fieldId));
      } else {
        // grants regardless of this field's value; lower-priority rules
        // can no longer change the outcome for this field
        unconditionalGrant = true;
        break;
      }
    } else if (
      rule.conditions &&
      fieldId in rule.conditions &&
      Object.keys(rule.conditions).length === 1
    ) {
      decisions.push(createFieldDecision(rule, fieldId));
    }
    // inverted rules conditioned on other or several fields cannot be decided
    // by this field's value alone and are left to the save-time check
  }

  const disallowedFragments = decisions
    .filter((d) => d.inverted)
    .map((d) => d.fragment);
  if (disallowedFragments.length === 0 && unconditionalGrant) {
    // no rule can reject based on this field's value
    return null;
  }

  const allowedValues = unconditionalGrant
    ? undefined
    : decisions
        .filter((d) => !d.inverted)
        .map((d) => describeConditionFragment(d.fragment, formatValue))
        .join(" / ");
  const disallowedValues = disallowedFragments
    .map((fragment) => describeConditionFragment(fragment, formatValue))
    .join(" / ");

  const errorMessage = [
    allowedValues
      ? $localize`:form field error for value outside the user's permissions:Your permissions only allow the following value(s) here\: ${allowedValues}`
      : undefined,
    disallowedValues
      ? $localize`:form field error for a value denied by the user's permissions:Your permissions do not allow the following value(s) here\: ${disallowedValues}`
      : undefined,
  ]
    .filter((part) => !!part)
    .join(" ");
  const error = {
    permissionCondition: { allowedValues, disallowedValues, errorMessage },
  };

  return (control) => {
    let value = control.value;
    if (value !== undefined && value !== null) {
      try {
        value = toDbFormat(value);
      } catch {
        // value cannot be evaluated reliably against the (database-format)
        // conditions; leave the denial to the save-time permission check
        return null;
      }
    }

    const checkedObject = subject(
      MATCH_SUBJECT,
      value === undefined ? {} : { [fieldId]: value },
    );
    for (const decision of decisions) {
      if (decision.matcher.can(MATCH_ACTION, checkedObject)) {
        return decision.inverted ? error : null;
      }
    }
    return unconditionalGrant ? null : error;
  };
}

function createFieldDecision(
  rule: RuleWithConditions,
  fieldId: string,
): FieldDecision {
  const fragment = rule.conditions[fieldId];
  return {
    fragment,
    inverted: !!rule.inverted,
    matcher: new Ability([
      {
        action: MATCH_ACTION,
        subject: MATCH_SUBJECT,
        conditions: { [fieldId]: fragment },
      },
    ]),
  };
}

/**
 * Human-readable summary of a single permission condition fragment
 * (the value of one key in a CASL rule's `conditions` object).
 *
 * @param fragment The condition fragment to describe
 * @param formatValue Lookup of a human-readable display for a single value,
 *   e.g. resolving enum ids to labels
 */
export function describeConditionFragment(
  fragment: any,
  formatValue: (value: any) => string = (value) => String(value),
): string {
  if (fragment === null) {
    return String(fragment);
  }
  if (typeof fragment !== "object") {
    return formatValue(fragment);
  }
  if (Array.isArray(fragment)) {
    return fragment.map(formatValue).join(", ");
  }

  const operators = Object.entries(fragment);
  if (operators.length === 1) {
    const [operator, value] = operators[0];
    if (operator === "$eq") {
      return describeConditionFragment(value, formatValue);
    }
    if (operator === "$in" && Array.isArray(value)) {
      return value.map(formatValue).join(", ");
    }
  }
  return JSON.stringify(fragment);
}

results matching ""

    No results matching ""