src/app/core/permissions/feature-permission/feature-permission.service.ts

Description

The editable permission state of one feature entity type across the given roles.

Index

Properties

Properties

defaultRules
defaultRules: RoleFeaturePermission
Type : RoleFeaturePermission

The shared _default section, shown as the first row so that checkboxes disabled on the role rows have a visible reason - and editable like any other row, matching the role administration (Admin > Roles).

entityType
entityType: string
Type : string
roles
roles: RoleFeaturePermission[]
Type : RoleFeaturePermission[]
import { inject, Injectable } from "@angular/core";
import { Config } from "../../config/config";
import {
  CRUD_ACTIONS,
  CrudAction,
  DatabaseRule,
  DatabaseRules,
  DEFAULT_SECTION_KEY,
  EntityActionPermission,
  isReservedRuleConfigKey,
  ruleCoversAction,
  SYSTEM_DEFAULT_RULE_REASON,
} from "../permission-types";
import { PermissionsConfigService } from "../permissions-config.service";

/**
 * What the stored rules say about one action of one row - the rule state, not
 * the display state of a checkbox. The dialog turns it into its own
 * `PermissionCell` (which extends `PermissionCellState`) in
 * `FeaturePermissionDialogComponent.toCell`; the two are deliberately kept
 * apart, because `allowed` does not mean the same on both sides and because
 * whether a checkbox is editable depends on the `_default` row *as currently
 * edited*, which only the dialog knows.
 */
export interface FeatureActionPermission {
  /**
   * The effective access as stored: what the row's own rules grant together
   * with the shared `_default` section, i.e. what the user can do right now.
   *
   * An editable checkbox does not show this but `ownAllowed`, with the
   * `_default` row layered back on live while the dialog is open.
   */
  allowed: boolean;
  /**
   * Whether the row's *own* rules for this entity type grant the action, ignoring
   * what the shared `_default` section adds on top.
   *
   * The dialog keeps this as the row's own intent, so that unticking an action on
   * the `_default` row reveals a role's own rule again instead of discarding it.
   */
  ownAllowed: boolean;
  /**
   * The access comes from a rule this UI must not rewrite, because changing it
   * would affect other roles or entity types: an `all` wildcard subject (the
   * most common case, e.g. `admin_app`), a grouped subject
   * (`subject: ["A", "B"]`), a rule carrying `conditions`, an inverted (deny)
   * rule, or a rule the server manages itself (marked with
   * {@link SYSTEM_DEFAULT_RULE_REASON}).
   *
   * What the shared `_default` section grants is not reported here: the dialog
   * lets that section be edited alongside the roles, so it resolves those locks
   * against the edited state rather than the stored one.
   *
   * This is the *cause*, not the rendering: the dialog turns it into the
   * checkbox's `editable` flag and the tooltip explaining it.
   */
  lockedByAdvancedRule: boolean;
}

/**
 * The permission state of one row of the dialog - either a user role or the
 * shared `_default` section - for one feature entity type.
 */
export interface RoleFeaturePermission {
  /** the role's technical name as used as a key in the permissions config */
  role: string;
  actions: Record<CrudAction, FeatureActionPermission>;
  /**
   * Whether any checkbox of this row can be edited.
   *
   * `false` only when *every* action is decided by a rule this UI does not own -
   * a wildcard `all` subject, a grouped subject, a conditioned rule or an
   * inverted (deny) rule - because such rules cannot be changed without
   * affecting other entity types or roles.
   *
   * A rule covering only some actions locks just those checkboxes and leaves the
   * rest of the row editable, so a shared "read these four types" rule does not
   * stop an admin from granting the remaining actions for this feature.
   *
   * This applies to the `_default` row just like to a role row.
   */
  editable: boolean;
}

/**
 * The editable permission state of one feature entity type across the given roles.
 */
export interface FeaturePermissionState {
  entityType: string;

  /**
   * The shared `_default` section, shown as the first row so that checkboxes
   * disabled on the role rows have a visible reason - and editable like any
   * other row, matching the role administration (Admin > Roles).
   */
  defaultRules: RoleFeaturePermission;

  roles: RoleFeaturePermission[];
}

/** the actions of one row as sent back to {@link FeaturePermissionService.setPermissions} */
export interface RoleFeaturePermissionUpdate {
  role: string;
  actions: Record<CrudAction, boolean>;
}

/**
 * Read and edit the per-role permissions of a single feature (internal entity
 * type such as EmailTemplate, TemplateExport, ReportConfig, ...) against the
 * central `Config:Permissions` document.
 *
 * To keep the checkbox grid a faithful (non-destructive) projection of the
 * arbitrarily complex CASL rules, this service only ever reads and writes rules
 * that it "owns" for the exact entity type (single string subject, no conditions,
 * only feature actions). Any other rule - grouped subjects, conditions, wildcards
 * or inverted rules - is preserved untouched, and the roles it affects are
 * reported as not editable, so the UI can defer to the role administration.
 */
@Injectable({ providedIn: "root" })
export class FeaturePermissionService {
  private readonly permissionsConfig = inject(PermissionsConfigService);

  /**
   * Compute the current permission state of the given roles for one feature.
   * @param entityType the feature's ENTITY_TYPE (e.g. "EmailTemplate")
   * @param roleNames the roles to display (typically all assignable roles)
   */
  async getPermissions(
    entityType: string,
    roleNames: string[],
  ): Promise<FeaturePermissionState> {
    const rules = (await this.permissionsConfig.load())?.data ?? {};
    const defaultRules = this.getDefaultRules(rules);

    const roles = roleNames
      .filter((role) => !isReservedRuleConfigKey(role))
      .map((role) =>
        this.getRolePermission(
          role,
          rules[role] ?? [],
          defaultRules,
          entityType,
        ),
      );

    return {
      entityType,
      // the shared section is read and written like any other row; it cannot
      // inherit from itself, so it is resolved against its own rules alone
      defaultRules: this.getRolePermission(
        DEFAULT_SECTION_KEY,
        defaultRules,
        [],
        entityType,
      ),
      roles,
    };
  }

  private getRolePermission(
    role: string,
    roleRules: DatabaseRule[],
    defaultRules: DatabaseRule[],
    entityType: string,
  ): RoleFeaturePermission {
    // An inverted rule revokes access granted before it and cannot be expressed
    // through checkboxes. Because CASL resolves the rules in order, adding a
    // rule next to it could silently do nothing, so the row stays read-only.
    const hasInvertedRule = roleRules.some(
      (rule) => rule.inverted && this.affectsFeature(rule, entityType),
    );
    const effectiveRules = [...defaultRules, ...roleRules];
    const ownedRules = roleRules.filter((rule) =>
      this.isOwnedRule(rule, entityType),
    );
    // wildcard subjects, grouped subjects, conditions and rules the server
    // manages: kept as they are, because rewriting one would affect other
    // entity types or roles
    const unownedRules = roleRules.filter(
      (rule) => !this.isOwnedRule(rule, entityType),
    );

    const actions = this.mapActions<FeatureActionPermission>((action) => ({
      allowed: this.hasEffectiveAccess(effectiveRules, entityType, action),
      ownAllowed: ownedRules.some((rule) =>
        ruleCoversAction(rule, entityType, action),
      ),
      // a rule this UI cannot rewrite decides this single action - the other
      // actions of the same row stay editable, so that e.g. a shared
      // "read these four types" rule does not block granting "delete" here
      lockedByAdvancedRule:
        hasInvertedRule ||
        unownedRules.some((rule) => ruleCoversAction(rule, entityType, action)),
    }));

    return {
      role,
      actions,
      // read-only only when an advanced rule decides every action - what the
      // shared `_default` section grants never locks a row, and the dialog
      // resolves those single checkboxes against the section as it is edited
      editable: CRUD_ACTIONS.some(
        (action) => !actions[action].lockedByAdvancedRule,
      ),
    };
  }

  /**
   * Persist the updated permissions for the given rows (user roles and the
   * shared `_default` section).
   *
   * Only rules this service owns for the exact entity type are replaced; every
   * other rule (including `_public` and complex rules) is preserved.
   * Actions already granted through `_default` are never written as a role rule,
   * so that inherited access does not get duplicated into every role.
   * A timestamped backup of the previous config is stored before saving.
   *
   * @returns the backup Config that was created, so callers can offer an "undo".
   */
  async setPermissions(
    entityType: string,
    updates: RoleFeaturePermissionUpdate[],
  ): Promise<Config<DatabaseRules>> {
    const existing = await this.permissionsConfig.load();
    // the grants to suppress on the role rows are the ones that are in effect
    // *after* this save, because `_default` can be edited in the same go
    const grantedByDefault = this.getUpdatedDefaultGrants(
      updates,
      existing,
      entityType,
    );
    const config = this.ensurePermissionsConfig(existing);
    const updatedData = structuredClone(config.data);

    for (const { role, actions } of updates) {
      const isDefaultSection = role === DEFAULT_SECTION_KEY;
      // `_public` is never edited through this grid,
      // as they carry semantics the per-role checkboxes cannot represent
      if (!isDefaultSection && isReservedRuleConfigKey(role)) {
        continue;
      }

      const selected = CRUD_ACTIONS.filter(
        (action) =>
          actions[action] &&
          // the shared section is what grants the action in the first place
          (isDefaultSection || !grantedByDefault[action]),
      );

      // keep every rule we don't own, then re-add the selected actions
      const preserved = (updatedData[role] ?? []).filter(
        (rule) => !this.isOwnedRule(rule, entityType),
      );
      const updated = [...preserved];
      const grantedAction = this.toRuleAction(selected);
      if (grantedAction) {
        updated.push({ subject: entityType, action: grantedAction });
      }

      if (updated.length > 0) {
        updatedData[role] = updated;
      } else {
        delete updatedData[role];
      }
    }

    return this.permissionsConfig.saveWithBackup(config, updatedData);
  }

  /**
   * Which actions the shared `_default` section grants for this feature once the
   * given updates are saved: taken from the submitted `_default` row if it was
   * editable (and therefore sent along), and from the stored config otherwise.
   */
  private getUpdatedDefaultGrants(
    updates: RoleFeaturePermissionUpdate[],
    existing: Config<DatabaseRules> | null,
    entityType: string,
  ): Record<CrudAction, boolean> {
    const defaultUpdate = updates.find(
      ({ role }) => role === DEFAULT_SECTION_KEY,
    );
    if (defaultUpdate) {
      return this.mapActions((action) => defaultUpdate.actions[action]);
    }
    return this.getDefaultGrants(
      this.getDefaultRules(existing?.data ?? {}),
      entityType,
    );
  }

  /**
   * The user roles that already appear in the permissions config, excluding the
   * special `_default` (shared baseline) and `_public` (unauthenticated) keys.
   *
   * Used as a robust source of roles that does not depend on the Keycloak admin
   * API being reachable.
   */
  async getConfiguredRoleNames(): Promise<string[]> {
    const rules = (await this.permissionsConfig.load())?.data ?? {};
    return Object.keys(rules).filter((role) => !isReservedRuleConfigKey(role));
  }

  /**
   * The action to store for the given selection: `manage` if every feature action
   * is selected (keeping the shape an admin would write by hand), a plain string
   * for a single action and an array otherwise. `undefined` if nothing is selected.
   */
  private toRuleAction(
    selected: CrudAction[],
  ): EntityActionPermission | EntityActionPermission[] | undefined {
    if (selected.length === 0) {
      return undefined;
    }
    if (selected.length === CRUD_ACTIONS.length) {
      return "manage";
    }
    return selected.length === 1 ? selected[0] : [...selected];
  }

  /** rules in `_default` apply to every logged-in user, on top of their role rules */
  private getDefaultRules(rules: DatabaseRules): DatabaseRule[] {
    return rules[DEFAULT_SECTION_KEY] ?? [];
  }

  /** which actions the shared `_default` section grants for this feature */
  private getDefaultGrants(
    defaultRules: DatabaseRule[],
    entityType: string,
  ): Record<CrudAction, boolean> {
    return this.mapActions((action) =>
      this.hasEffectiveAccess(defaultRules, entityType, action),
    );
  }

  private mapActions<T>(
    valueFor: (action: CrudAction) => T,
  ): Record<CrudAction, T> {
    return Object.fromEntries(
      CRUD_ACTIONS.map((action) => [action, valueFor(action)]),
    ) as Record<CrudAction, T>;
  }

  /**
   * A rule is "owned" by this UI (and thus safe to read/rewrite) only if it grants
   * feature actions to exactly this entity type without any conditions or
   * inversion. Managed `[system-default]` rules (written by the backend to
   * guarantee a baseline) are never owned, so they are left untouched.
   */
  private isOwnedRule(rule: DatabaseRule, entityType: string): boolean {
    const actions = Array.isArray(rule.action) ? rule.action : [rule.action];
    return (
      !rule.inverted &&
      !rule.conditions &&
      !rule.reason?.includes(SYSTEM_DEFAULT_RULE_REASON) &&
      rule.subject === entityType &&
      actions.every(
        (action) =>
          action === "manage" || CRUD_ACTIONS.includes(action as CrudAction),
      )
    );
  }

  /**
   * Whether the rule has any say over this feature's access, no matter whether it
   * grants or denies it.
   */
  private affectsFeature(rule: DatabaseRule, entityType: string): boolean {
    return CRUD_ACTIONS.some((action) =>
      ruleCoversAction(rule, entityType, action),
    );
  }

  /**
   * Whether the given rules leave the role with the action in effect.
   *
   * CASL resolves overlapping rules so that the last matching one wins, so an
   * inverted rule revokes a grant that came before it while a later granting
   * rule re-enables access again. Ignoring that would make a row claim access
   * the user does not have. This UI cannot express an inverted rule, which is
   * why any role affected by one is rendered read-only.
   *
   * `rules` must be in the order the AbilityService applies them, i.e. the
   * shared `_default` section first and the role's own rules after it.
   */
  private hasEffectiveAccess(
    rules: DatabaseRule[],
    entityType: string,
    action: EntityActionPermission,
  ): boolean {
    const decidingRule = rules.findLast((rule) =>
      ruleCoversAction(rule, entityType, action),
    );
    return !!decidingRule && !decidingRule.inverted;
  }

  /**
   * The config document to write the updated rules into, created empty if this
   * instance does not define any permissions yet.
   *
   * Nothing is added around the rules saved here: a seeded `all` wildcard would
   * keep granting every action on this feature to every logged-in user (a rule
   * granting less never overrides a broader grant), so the restriction just
   * saved would have no effect at all.
   */
  private ensurePermissionsConfig(
    existing: Config<DatabaseRules> | null,
  ): Config<DatabaseRules> {
    if (existing?.data) {
      return existing;
    }

    const config = existing ?? new Config<DatabaseRules>(Config.PERMISSION_KEY);
    config.data = {};
    return config;
  }
}

results matching ""

    No results matching ""