Source: src/js/models/sysmeta/AccessRule.js

define([
  "common/XMLUtilities",
  "common/ValueUtilities",
  "common/ValidationUtilities",
  "models/sysmeta/SysMetaSchema",
], (XMLUtilities, ValueUtilities, ValidationUtilities, SysMetaSchema) => {
  const { normalizeStringArray, dedupeArray, isNonEmptyString } =
    ValueUtilities;
  const { PERMISSIONS, normalizePermission } = SysMetaSchema;
  const { fixParsedValue, createValidationIssue } = ValidationUtilities;
  // Define the expected child element sequence for an `allow` element in the
  // access policy XML.
  const ACCESS_RULE_CHILD_SEQUENCE = [
    { name: "subject", minOccurs: 1, maxOccurs: Infinity },
    { name: "permission", minOccurs: 1, maxOccurs: Infinity },
  ];

  /**
   * Access control rule that binds one or more subjects to one or more
   * permissions.
   * @class AccessRule
   * @classcategory Models/SysMeta
   * @since 2.39.0
   */
  class AccessRule {
    /**
     * Create an AccessRule instance. Accepts both the canonical
     * `{ subjects, permissions }` shape and the legacy
     * `collections/AccessPolicy` AccessRule shape
     * (`{ subject, read, write, changePermission }`), so legacy access-policy
     * editing UIs can round-trip through the typed model.
     * @param {object} [data] Initial access rule values.
     * @param {Array<string>} [data.subjects] Subjects granted access.
     * @param {Array<string>} [data.permissions] Granted permissions.
     * @param {string} [data.subject] Legacy single-subject form
     * @param {boolean} [data.read] Legacy read permission flag
     * @param {boolean} [data.write] Legacy write permission flag
     * @param {boolean} [data.changePermission] Legacy changePermission flag
     */
    constructor(data = {}) {
      this.subjects = normalizeStringArray(
        data.subjects != null ? data.subjects : data.subject,
      );

      let { permissions } = data;
      if (
        permissions == null &&
        (data.read != null ||
          data.write != null ||
          data.changePermission != null)
      ) {
        permissions = [
          data.read ? "read" : null,
          data.write ? "write" : null,
          data.changePermission ? "changePermission" : null,
        ].filter(Boolean);
      }

      this.permissions = dedupeArray(
        normalizeStringArray(permissions)
          .map((permission) => normalizePermission(permission))
          .filter((permission) => permission !== null),
      );
    }

    /**
     * Create an AccessRule from an `allow` XML element.
     * @param {Element} element XML element to parse.
     * @returns {AccessRule} Parsed access rule instance.
     */
    static fromElement(element) {
      const context = "AccessRule XML";
      XMLUtilities.requireAllowedAttributeNames(element, [], context);
      XMLUtilities.requireDirectChildSequence(
        element,
        ACCESS_RULE_CHILD_SEQUENCE,
        context,
      );

      return new AccessRule({
        subjects: XMLUtilities.getDirectChildTexts(element, "subject"),
        permissions: XMLUtilities.getDirectChildTexts(element, "permission"),
      });
    }

    /**
     * Parse an `allow` XML element, dropping it when invalid.
     * Unknown child nodes and ordering issues are ignored silently.
     * @param {Element|null} element XML element to parse.
     * @param {Array<object>} [parseWarnings] Lossy parse warnings.
     * @param {string} [path] Field path used in warnings and validation.
     * @returns {AccessRule|null} Parsed access rule or null when invalid.
     */
    static parse(element, parseWarnings = [], path = "accessPolicy") {
      if (!element) return null;

      const rule = new AccessRule({
        subjects: XMLUtilities.getDirectChildTexts(element, "subject"),
        permissions: XMLUtilities.getDirectChildTexts(element, "permission"),
      });
      return fixParsedValue({
        value: rule,
        path,
        parseWarnings,
        invalidMessage:
          "Ignored invalid accessPolicy rule while parsing system metadata.",
        validate: (candidate) => candidate.validate(path),
        fallback: () => null,
      });
    }

    /**
     * Return the first subject in the rule.
     * @returns {string|null} First subject value, if present.
     */
    get subject() {
      return this.subjects[0] ?? null;
    }

    /**
     * Whether the rule grants read access.
     * @returns {boolean} `true` when read permission is present.
     */
    get read() {
      return this.permissions.includes("read");
    }

    /**
     * Whether the rule grants write access.
     * @returns {boolean} `true` when write permission is present.
     */
    get write() {
      return this.permissions.includes("write");
    }

    /**
     * Whether the rule grants change-permission access.
     * @returns {boolean} `true` when changePermission is present.
     */
    get changePermission() {
      return this.permissions.includes("changePermission");
    }

    /** @returns {boolean} `true` when the rule has at least one permission. */
    hasPermissions() {
      return this.permissions.length > 0;
    }

    /** @returns {boolean} `true` when the rule has at least one subject. */
    hasSubject() {
      return this.subjects.length > 0;
    }

    /** @returns {boolean} `true` when the rule lacks a subject or permissions. */
    isEmpty() {
      return !this.hasPermissions() || !this.hasSubject();
    }

    /**
     * Validate the access rule state.
     * @param {string} [path] Base path used in validation errors. Defaults to
     * `accessPolicy`.
     * @returns {Array<object>} Validation errors for invalid fields.
     */
    validate(path = "accessPolicy") {
      const errors = [];

      if (!this.hasSubject()) {
        errors.push(
          createValidationIssue({
            field: `${path}.subjects`,
            message: "At least one subject is required.",
          }),
        );
      }

      this.subjects.forEach((subject, index) => {
        if (!isNonEmptyString(subject)) {
          errors.push(
            createValidationIssue({
              field: `${path}.subjects[${index}]`,
              message: "Subjects must be non-empty strings.",
            }),
          );
        }
      });

      if (!this.hasPermissions()) {
        errors.push(
          createValidationIssue({
            field: `${path}.permissions`,
            message: "At least one permission is required.",
          }),
        );
      }

      this.permissions.forEach((permission, index) => {
        if (!PERMISSIONS.includes(permission)) {
          errors.push(
            createValidationIssue({
              field: `${path}.permissions[${index}]`,
              message: `Permissions must be one of: ${PERMISSIONS.join(", ")}.`,
            }),
          );
        }
      });

      return errors;
    }

    /**
     * Serialize the access rule to an `allow` XML element.
     * @param {XMLDocument} doc XML document used to create elements.
     * @returns {Element|null} Serialized XML element, or `null` when empty.
     */
    toElement(doc) {
      if (this.isEmpty()) return null;

      const element = doc.createElement("allow");

      this.subjects.forEach((subject) => {
        XMLUtilities.appendTextElement(doc, element, "subject", subject);
      });

      this.permissions.forEach((permission) => {
        XMLUtilities.appendTextElement(doc, element, "permission", permission);
      });

      return element;
    }

    /**
     * Return a JSON-serializable representation of the access rule.
     * @returns {object} Plain access rule data.
     */
    toJSON() {
      return {
        subjects: [...this.subjects],
        permissions: [...this.permissions],
      };
    }

    /**
     * Check whether this rule grants the requested action to a subject.
     * @param {"read"|"write"|"changePermission"} [action] Permission to check
     * @param {string|Array<string>|null} [subject] Subject or candidate subjects
     * Defaults to `public` when omitted.
     * @returns {boolean} `true` when a matching subject has the requested
     * permission directly or through the DataONE permission hierarchy.
     */
    isAuthorized(action = "write", subject = null) {
      const subjects = normalizeStringArray(subject ?? "public");
      const normalAction = normalizePermission(action);
      if (
        !normalAction ||
        !subjects.some((candidate) => this.subjects.includes(candidate))
      ) {
        return false;
      }

      const hasPermission = (permission) =>
        this.permissions.includes(permission);

      if (normalAction === "read") {
        return (
          hasPermission("read") ||
          hasPermission("write") ||
          hasPermission("changePermission")
        );
      }
      if (normalAction === "write") {
        return hasPermission("write") || hasPermission("changePermission");
      }
      return (
        normalAction === "changePermission" && hasPermission("changePermission")
      );
    }
  }

  return AccessRule;
});