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

Description

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.

Index

Properties

Properties

allowed
allowed: boolean
Type : boolean

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.

lockedByAdvancedRule
lockedByAdvancedRule: boolean
Type : 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 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.

ownAllowed
ownAllowed: boolean
Type : 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.

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 ""