permission.ts raw

   1  import { IdentityId, PermissionId } from '../value-objects';
   2  import type {
   3    PermissionSnapshot,
   4    ExtensionMethod,
   5    PermissionPolicy,
   6  } from '../repositories/permission-repository';
   7  
   8  /**
   9   * Permission entity - represents an authorization decision for
  10   * a specific identity, host, and method combination.
  11   *
  12   * Permissions are immutable once created - to change a permission,
  13   * delete it and create a new one.
  14   */
  15  export class Permission {
  16    private readonly _id: PermissionId;
  17    private readonly _identityId: IdentityId;
  18    private readonly _host: string;
  19    private readonly _method: ExtensionMethod;
  20    private readonly _policy: PermissionPolicy;
  21    private readonly _kind?: number;
  22  
  23    private constructor(
  24      id: PermissionId,
  25      identityId: IdentityId,
  26      host: string,
  27      method: ExtensionMethod,
  28      policy: PermissionPolicy,
  29      kind?: number
  30    ) {
  31      this._id = id;
  32      this._identityId = identityId;
  33      this._host = host;
  34      this._method = method;
  35      this._policy = policy;
  36      this._kind = kind;
  37    }
  38  
  39    // ─────────────────────────────────────────────────────────────────────────
  40    // Factory Methods
  41    // ─────────────────────────────────────────────────────────────────────────
  42  
  43    /**
  44     * Create an "allow" permission.
  45     */
  46    static allow(
  47      identityId: IdentityId,
  48      host: string,
  49      method: ExtensionMethod,
  50      kind?: number
  51    ): Permission {
  52      return new Permission(
  53        PermissionId.generate(),
  54        identityId,
  55        Permission.normalizeHost(host),
  56        method,
  57        'allow',
  58        kind
  59      );
  60    }
  61  
  62    /**
  63     * Create a "deny" permission.
  64     */
  65    static deny(
  66      identityId: IdentityId,
  67      host: string,
  68      method: ExtensionMethod,
  69      kind?: number
  70    ): Permission {
  71      return new Permission(
  72        PermissionId.generate(),
  73        identityId,
  74        Permission.normalizeHost(host),
  75        method,
  76        'deny',
  77        kind
  78      );
  79    }
  80  
  81    /**
  82     * Create a permission with explicit policy.
  83     */
  84    static create(
  85      identityId: IdentityId,
  86      host: string,
  87      method: ExtensionMethod,
  88      policy: PermissionPolicy,
  89      kind?: number
  90    ): Permission {
  91      return new Permission(
  92        PermissionId.generate(),
  93        identityId,
  94        Permission.normalizeHost(host),
  95        method,
  96        policy,
  97        kind
  98      );
  99    }
 100  
 101    /**
 102     * Reconstitute a permission from storage.
 103     */
 104    static fromSnapshot(snapshot: PermissionSnapshot): Permission {
 105      return new Permission(
 106        PermissionId.from(snapshot.id),
 107        IdentityId.from(snapshot.identityId),
 108        snapshot.host,
 109        snapshot.method,
 110        snapshot.methodPolicy,
 111        snapshot.kind
 112      );
 113    }
 114  
 115    // ─────────────────────────────────────────────────────────────────────────
 116    // Getters
 117    // ─────────────────────────────────────────────────────────────────────────
 118  
 119    get id(): PermissionId {
 120      return this._id;
 121    }
 122  
 123    get identityId(): IdentityId {
 124      return this._identityId;
 125    }
 126  
 127    get host(): string {
 128      return this._host;
 129    }
 130  
 131    get method(): ExtensionMethod {
 132      return this._method;
 133    }
 134  
 135    get policy(): PermissionPolicy {
 136      return this._policy;
 137    }
 138  
 139    get kind(): number | undefined {
 140      return this._kind;
 141    }
 142  
 143    // ─────────────────────────────────────────────────────────────────────────
 144    // Behavior
 145    // ─────────────────────────────────────────────────────────────────────────
 146  
 147    /**
 148     * Check if this permission allows the action.
 149     */
 150    isAllowed(): boolean {
 151      return this._policy === 'allow';
 152    }
 153  
 154    /**
 155     * Check if this permission denies the action.
 156     */
 157    isDenied(): boolean {
 158      return this._policy === 'deny';
 159    }
 160  
 161    /**
 162     * Check if this permission matches the given criteria.
 163     * For signEvent with kind specified, also checks the kind.
 164     */
 165    matches(
 166      identityId: IdentityId,
 167      host: string,
 168      method: ExtensionMethod,
 169      kind?: number
 170    ): boolean {
 171      if (!this._identityId.equals(identityId)) {
 172        return false;
 173      }
 174  
 175      if (this._host !== Permission.normalizeHost(host)) {
 176        return false;
 177      }
 178  
 179      if (this._method !== method) {
 180        return false;
 181      }
 182  
 183      // For signEvent, handle kind matching
 184      if (method === 'signEvent') {
 185        // If this permission has no kind, it matches all kinds
 186        if (this._kind === undefined) {
 187          return true;
 188        }
 189        // If checking a specific kind, must match exactly
 190        return this._kind === kind;
 191      }
 192  
 193      return true;
 194    }
 195  
 196    /**
 197     * Check if this permission applies to a specific event kind.
 198     * Only relevant for signEvent method.
 199     */
 200    appliesToKind(kind: number): boolean {
 201      if (this._method !== 'signEvent') {
 202        return false;
 203      }
 204      // No kind restriction means applies to all
 205      if (this._kind === undefined) {
 206        return true;
 207      }
 208      return this._kind === kind;
 209    }
 210  
 211    /**
 212     * Check if this is a blanket permission (no kind restriction).
 213     */
 214    isBlanketPermission(): boolean {
 215      return this._method === 'signEvent' && this._kind === undefined;
 216    }
 217  
 218    // ─────────────────────────────────────────────────────────────────────────
 219    // Persistence
 220    // ─────────────────────────────────────────────────────────────────────────
 221  
 222    /**
 223     * Convert to a snapshot for persistence.
 224     */
 225    toSnapshot(): PermissionSnapshot {
 226      const snapshot: PermissionSnapshot = {
 227        id: this._id.value,
 228        identityId: this._identityId.value,
 229        host: this._host,
 230        method: this._method,
 231        methodPolicy: this._policy,
 232      };
 233  
 234      if (this._kind !== undefined) {
 235        snapshot.kind = this._kind;
 236      }
 237  
 238      return snapshot;
 239    }
 240  
 241    // ─────────────────────────────────────────────────────────────────────────
 242    // Equality
 243    // ─────────────────────────────────────────────────────────────────────────
 244  
 245    /**
 246     * Check equality based on permission ID.
 247     */
 248    equals(other: Permission): boolean {
 249      return this._id.equals(other._id);
 250    }
 251  
 252    // ─────────────────────────────────────────────────────────────────────────
 253    // Helpers
 254    // ─────────────────────────────────────────────────────────────────────────
 255  
 256    private static normalizeHost(host: string): string {
 257      return host.toLowerCase().trim();
 258    }
 259  }
 260  
 261  /**
 262   * Permission checker - evaluates permissions for a request.
 263   * This encapsulates the permission checking logic.
 264   */
 265  export class PermissionChecker {
 266    constructor(private readonly permissions: Permission[]) {}
 267  
 268    /**
 269     * Check if an action is allowed.
 270     *
 271     * @returns true if allowed, false if denied, undefined if no matching permission
 272     */
 273    check(
 274      identityId: IdentityId,
 275      host: string,
 276      method: ExtensionMethod,
 277      kind?: number
 278    ): boolean | undefined {
 279      const matching = this.permissions.filter((p) =>
 280        p.matches(identityId, host, method, kind)
 281      );
 282  
 283      if (matching.length === 0) {
 284        return undefined;
 285      }
 286  
 287      // For signEvent with kind, check specific rules
 288      // Kind-specific rules take priority over blanket rules
 289      if (method === 'signEvent' && kind !== undefined) {
 290        // Check for specific kind deny first (takes priority)
 291        if (matching.some((p) => p.kind === kind && p.isDenied())) {
 292          return false;
 293        }
 294  
 295        // Check for specific kind allow
 296        if (matching.some((p) => p.kind === kind && p.isAllowed())) {
 297          return true;
 298        }
 299  
 300        // Fall back to blanket allow (no kind restriction)
 301        if (matching.some((p) => p.isBlanketPermission() && p.isAllowed())) {
 302          return true;
 303        }
 304  
 305        // Fall back to blanket deny
 306        if (matching.some((p) => p.isBlanketPermission() && p.isDenied())) {
 307          return false;
 308        }
 309  
 310        // No specific rule found
 311        return undefined;
 312      }
 313  
 314      // For other methods, all matching permissions must allow
 315      return matching.every((p) => p.isAllowed());
 316    }
 317  
 318    /**
 319     * Get all permissions for a specific identity.
 320     */
 321    forIdentity(identityId: IdentityId): Permission[] {
 322      return this.permissions.filter((p) => p.identityId.equals(identityId));
 323    }
 324  
 325    /**
 326     * Get all permissions for a specific host.
 327     */
 328    forHost(host: string): Permission[] {
 329      const normalizedHost = host.toLowerCase().trim();
 330      return this.permissions.filter((p) => p.host === normalizedHost);
 331    }
 332  }
 333