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