Skip to content
Source
Sign inSign up

commandPermissions.test.ts

567 lines22.5 KB

1import { requestGuarded, runStreamGuarded } from "../rpc/client.ts";
2import { EnvironmentSupervisor } from "../connection/supervisor.ts";
3import * as SubscriptionRef from "effect/SubscriptionRef";
4import * as Option from "effect/Option";
5import * as Stream from "effect/Stream";
6import type { RpcSession } from "../rpc/session.ts";
7import { describe, expect, it } from "@effect/vitest";
8import { vi } from "vite-plus/test";
9import {
10 AuthAccessReadScope,
11 AuthAccessWriteScope,
12 AuthEnvironmentMaintainScope,
13 AuthOrchestrationOperateScope,
14 AuthOrchestrationReadScope,
15 AuthPreviewOperateScope,
16 AuthProvidersManageScope,
17 AuthSettingsWriteScope,
18 AuthSourceControlWriteScope,
19 ThreadId,
20 EnvironmentId,
21 ORCHESTRATION_V2_WS_METHODS,
22 ScheduledTaskId,
23 WS_METHODS,
24 type AuthSessionState,
25 type PeerLinkSummary,
26} from "@t3tools/contracts";
27import * as Cause from "effect/Cause";
28import * as DateTime from "effect/DateTime";
29import * as Effect from "effect/Effect";
30import * as Fiber from "effect/Fiber";
31import * as Layer from "effect/Layer";
32import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity";
33import { EnvironmentRegistry } from "../connection/registry.ts";
34import { createCommandPermissions } from "./commandPermissions.ts";
35import { createEnvironmentRpcCommand } from "./runtime.ts";
36import { AVAILABLE_CONNECTION_STATE, BearerConnectionTarget } from "../connection/model.ts";
37import { createPeerLinkEnvironmentAtoms } from "./peerLinks.ts";
38​
39vi.mock("./session.ts", () => ({
40 createEnvironmentSessionAtoms: () => ({ sessionStateAtom: sessions }),
41}));
42const sessions = Atom.family((_id: EnvironmentId) =>
43 Atom.make<AsyncResult.AsyncResult<AuthSessionState, string>>(AsyncResult.initial()),
44);
45const env = EnvironmentId.make("target");
46const other = EnvironmentId.make("other");
47const grant = (allowed: boolean): AuthSessionState => ({
48 authenticated: true,
49 auth: {
50 policy: "remote-reachable",
51 bootstrapMethods: [],
52 sessionMethods: [],
53 sessionCookieName: "test",
54 },
55 scopes: allowed ? [AuthOrchestrationOperateScope] : [],
56 permissions: allowed ? [AuthOrchestrationOperateScope] : [],
57});
58const runtime = Atom.runtime(
59 Layer.succeed(EnvironmentRegistry, {
60 run: (_id: EnvironmentId, effect: Effect.Effect<unknown>) => effect,
61 } as unknown as EnvironmentRegistry["Service"]),
62);
63const permissions = createCommandPermissions(runtime, WS_METHODS.scheduledTasksDelete);
64const setup = Effect.gen(function* () {
65 const registry = AtomRegistry.make();
66 registry.mount(sessions(env));
67 yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose()));
68 return registry;
69});
70​
71describe("command permissions", () => {
72 it.effect("requires the destination read grant to refresh providers", () =>
73 Effect.gen(function* () {
74 const registry = yield* setup;
75 const refresh = createCommandPermissions(runtime, WS_METHODS.serverRefreshProviders);
76 registry.set(sessions(env), AsyncResult.success(grant(true)));
77 expect(registry.get(refresh.permissionAtom(env))).toBe(false);
78 expect((yield* refresh.authorize(registry, env).pipe(Effect.flip))._tag).toBe(
79 "EnvironmentAuthorizationError",
80 );
81 registry.set(
82 sessions(env),
83 AsyncResult.success({
84 ...grant(false),
85 scopes: [AuthOrchestrationReadScope],
86 permissions: [AuthOrchestrationReadScope],
87 }),
88 );
89 expect(registry.get(refresh.permissionAtom(env))).toBe(true);
90 yield* refresh.authorize(registry, env);
91 registry.set(sessions(other), AsyncResult.success(grant(false)));
92 expect(registry.get(refresh.permissionAtom(other))).toBe(false);
93 expect((yield* refresh.authorize(registry, other).pipe(Effect.flip))._tag).toBe(
94 "EnvironmentAuthorizationError",
95 );
96 registry.set(sessions(env), AsyncResult.success(grant(false)));
97 expect(registry.get(refresh.permissionAtom(env))).toBe(false);
98 expect((yield* refresh.authorize(registry, env).pipe(Effect.flip))._tag).toBe(
99 "EnvironmentAuthorizationError",
100 );
101 }),
102 );
103​
104 it.effect("requires the destination preview grant to close a browser tab", () =>
105 Effect.gen(function* () {
106 const registry = yield* setup;
107 const close = createCommandPermissions(runtime, WS_METHODS.previewClose);
108 registry.set(sessions(env), AsyncResult.success(grant(true)));
109 expect(registry.get(close.permissionAtom(env))).toBe(false);
110 expect((yield* close.authorize(registry, env).pipe(Effect.flip))._tag).toBe(
111 "EnvironmentAuthorizationError",
112 );
113 registry.set(
114 sessions(env),
115 AsyncResult.success({
116 ...grant(false),
117 scopes: [AuthPreviewOperateScope],
118 permissions: [AuthPreviewOperateScope],
119 }),
120 );
121 expect(registry.get(close.permissionAtom(env))).toBe(true);
122 yield* close.authorize(registry, env);
123 registry.set(sessions(other), AsyncResult.success(grant(false)));
124 expect(registry.get(close.permissionAtom(other))).toBe(false);
125 expect((yield* close.authorize(registry, other).pipe(Effect.flip))._tag).toBe(
126 "EnvironmentAuthorizationError",
127 );
128 }),
129 );
130​
131 it.effect("requires the destination provider grant to remove an incomplete connection", () =>
132 Effect.gen(function* () {
133 const registry = yield* setup;
134 const remove = createCommandPermissions(runtime, WS_METHODS.providerAuthRemoveMethod);
135 registry.set(sessions(env), AsyncResult.success(grant(true)));
136 expect(registry.get(remove.permissionAtom(env))).toBe(false);
137 expect((yield* remove.authorize(registry, env).pipe(Effect.flip))._tag).toBe(
138 "EnvironmentAuthorizationError",
139 );
140 registry.set(
141 sessions(env),
142 AsyncResult.success({
143 ...grant(false),
144 scopes: [AuthProvidersManageScope],
145 permissions: [AuthProvidersManageScope],
146 }),
147 );
148 expect(registry.get(remove.permissionAtom(env))).toBe(true);
149 yield* remove.authorize(registry, env);
150 registry.set(sessions(other), AsyncResult.success(grant(false)));
151 expect(registry.get(remove.permissionAtom(other))).toBe(false);
152 }),
153 );
154​
155 it.effect.each([
156 { method: WS_METHODS.serverRunStorageCleanup, scope: AuthSettingsWriteScope },
157 { method: WS_METHODS.serverReclaimDatabaseSpace, scope: AuthEnvironmentMaintainScope },
158 ])("requires the destination $scope grant for $method", ({ method, scope }) =>
159 Effect.gen(function* () {
160 const registry = yield* setup;
161 const command = createCommandPermissions(runtime, method);
162 registry.set(sessions(env), AsyncResult.success(grant(true)));
163 expect(registry.get(command.permissionAtom(env))).toBe(false);
164 expect((yield* command.authorize(registry, env).pipe(Effect.flip))._tag).toBe(
165 "EnvironmentAuthorizationError",
166 );
167 registry.set(
168 sessions(env),
169 AsyncResult.success({ ...grant(true), scopes: [scope], permissions: [scope] }),
170 );
171 expect(registry.get(command.permissionAtom(env))).toBe(true);
172 yield* command.authorize(registry, env);
173 registry.set(sessions(other), AsyncResult.success(grant(false)));
174 expect(registry.get(command.permissionAtom(other))).toBe(false);
175 }),
176 );
177​
178 it.effect.each([WS_METHODS.serverCheckOtlpEndpoint, WS_METHODS.serverCheckSecretSource])(
179 "requires the destination settings grant for %s",
180 (method) =>
181 Effect.gen(function* () {
182 const registry = yield* setup;
183 const check = createCommandPermissions(runtime, method);
184 registry.set(sessions(env), AsyncResult.success(grant(true)));
185 expect(registry.get(check.permissionAtom(env))).toBe(false);
186 registry.set(
187 sessions(env),
188 AsyncResult.success({
189 ...grant(false),
190 scopes: [AuthSettingsWriteScope],
191 permissions: [AuthSettingsWriteScope],
192 }),
193 );
194 expect(registry.get(check.permissionAtom(env))).toBe(true);
195 yield* check.authorize(registry, env);
196 }),
197 );
198​
199 it.effect.each([WS_METHODS.scheduledTasksDelete, ORCHESTRATION_V2_WS_METHODS.stopWorkflow])(
200 "uses the target grant for both availability and execution of %s",
201 (method) =>
202 Effect.gen(function* () {
203 const registry = yield* setup;
204 const permissions = createCommandPermissions(runtime, method);
205 registry.set(sessions(env), AsyncResult.success(grant(true)));
206 expect(registry.get(permissions.permissionAtom(env))).toBe(true);
207 expect(permissions.permissionAtom(env)).toBe(permissions.permissionAtom(env));
208 yield* permissions.authorize(registry, env);
209 registry.set(sessions(other), AsyncResult.success(grant(false)));
210 expect(registry.get(permissions.permissionAtom(other))).toBe(false);
211 const denied = yield* permissions.authorize(registry, other).pipe(Effect.flip);
212 expect(denied._tag).toBe("EnvironmentAuthorizationError");
213 }),
214 );
215 it.effect("waits for an initial grant", () =>
216 Effect.gen(function* () {
217 const registry = yield* setup;
218 const fiber = yield* permissions.authorize(registry, env).pipe(Effect.forkChild);
219 yield* Effect.yieldNow;
220 registry.set(sessions(env), AsyncResult.success(grant(true)));
221 yield* Fiber.join(fiber);
222 }),
223 );
224 it.effect(
225 "denies failed and unauthenticated sessions, but accepts a cached refreshing grant",
226 () =>
227 Effect.gen(function* () {
228 const registry = yield* setup;
229 registry.set(sessions(env), AsyncResult.failure(Cause.fail("offline")));
230 expect((yield* permissions.authorize(registry, env).pipe(Effect.flip))._tag).toBe(
231 "EnvironmentAuthorizationError",
232 );
233 registry.set(sessions(env), AsyncResult.success({ ...grant(true), authenticated: false }));
234 expect((yield* permissions.authorize(registry, env).pipe(Effect.flip))._tag).toBe(
235 "EnvironmentAuthorizationError",
236 );
237 registry.set(sessions(env), AsyncResult.waiting(AsyncResult.success(grant(true))));
238 yield* permissions.authorize(registry, env);
239 }),
240 );
241 it("rechecks permission after waiting in a serial command lane", async () => {
242 const registry = AtomRegistry.make();
243 const unmount = registry.mount(sessions(env));
244 registry.set(sessions(env), AsyncResult.success(grant(true)));
245 let release!: () => void;
246 let started!: () => void;
247 const entered = new Promise<void>((resolve) => {
248 started = resolve;
249 });
250 const gate = new Promise<void>((resolve) => {
251 release = resolve;
252 });
253 let calls = 0;
254 const command = createEnvironmentRpcCommand(runtime, {
255 label: "test.delete",
256 tag: WS_METHODS.scheduledTasksDelete,
257 concurrency: { mode: "serial", key: () => "target" },
258 execute: () =>
259 Effect.promise(async () => {
260 calls++;
261 started();
262 await gate;
263 return { id: ScheduledTaskId.make("task") };
264 }),
265 });
266 const target = { environmentId: env, input: { id: ScheduledTaskId.make("task") } };
267 try {
268 const first = command.run(registry, target);
269 await entered;
270 const second = command.run(registry, target);
271 registry.set(sessions(env), AsyncResult.success(grant(false)));
272 release();
273 expect((await first)._tag).toBe("Success");
274 expect((await second)._tag).toBe("Failure");
275 expect(calls).toBe(1);
276 } finally {
277 unmount();
278 registry.dispose();
279 }
280 });
281});
282​
283it.effect(
284 "requires source control alone for git, and both grants when attaching a worktree to a thread",
285 () =>
286 Effect.gen(function* () {
287 const registry = yield* setup;
288 registry.set(sessions(env), AsyncResult.success(grant(true)));
289 const git = createCommandPermissions(runtime, WS_METHODS.vcsInit);
290 expect((yield* git.authorize(registry, env).pipe(Effect.flip)).requiredPermission).toBe(
291 AuthSourceControlWriteScope,
292 );
293 registry.set(
294 sessions(env),
295 AsyncResult.success({
296 ...grant(false),
297 scopes: [AuthSourceControlWriteScope],
298 permissions: [AuthSourceControlWriteScope],
299 }),
300 );
301 yield* git.authorize(registry, env);
302 const prepare = createCommandPermissions(runtime, WS_METHODS.gitPreparePullRequestThread);
303 const input = {
304 cwd: "/repo",
305 reference: "123",
306 mode: "worktree",
307 threadId: ThreadId.make("thread"),
308 };
309 expect(registry.get(prepare.permissionAtom(env, input))).toBe(false);
310 expect((yield* prepare.authorize(registry, env, input).pipe(Effect.flip)).requiredScope).toBe(
311 AuthOrchestrationOperateScope,
312 );
313 yield* prepare.authorize(registry, env, { ...input, threadId: undefined });
314 registry.set(
315 sessions(env),
316 AsyncResult.success({
317 ...grant(true),
318 scopes: [AuthSourceControlWriteScope, AuthOrchestrationOperateScope],
319 permissions: [AuthSourceControlWriteScope, AuthOrchestrationOperateScope],
320 }),
321 );
322 expect(registry.get(prepare.permissionAtom(env, input))).toBe(true);
323 yield* prepare.authorize(registry, env, input);
324 }),
325);
326​
327it.effect.each(["commit", "push", "commit_push", "create_pr", "commit_push_pr"] as const)(
328 "%s needs thread read permission only when generating a PR from a thread",
329 (action) =>
330 Effect.gen(function* () {
331 const registry = yield* setup;
332 const command = createCommandPermissions(runtime, WS_METHODS.gitRunStackedAction);
333 const input = { actionId: "action", cwd: "/repo", action };
334 const withThread = { ...input, threadId: ThreadId.make("thread") };
335 const session = {
336 ...grant(false),
337 scopes: [AuthSourceControlWriteScope],
338 permissions: [AuthSourceControlWriteScope],
339 };
340 registry.set(sessions(env), AsyncResult.success(session));
341 expect(registry.get(command.permissionAtom(env, input))).toBe(true);
342 yield* command.authorize(registry, env, input);
343 const readsThread = action === "create_pr" || action === "commit_push_pr";
344 expect(registry.get(command.permissionAtom(env, withThread))).toBe(!readsThread);
345 if (readsThread) {
346 expect(yield* command.authorize(registry, env, withThread).pipe(Effect.flip)).toMatchObject(
347 { requiredPermission: AuthOrchestrationReadScope },
348 );
349 } else {
350 yield* command.authorize(registry, env, withThread);
351 }
352 registry.set(
353 sessions(env),
354 AsyncResult.success({
355 ...session,
356 scopes: [AuthSourceControlWriteScope, AuthOrchestrationReadScope],
357 permissions: [AuthSourceControlWriteScope, AuthOrchestrationReadScope],
358 }),
359 );
360 expect(registry.get(command.permissionAtom(env, withThread))).toBe(true);
361 yield* command.authorize(registry, env, withThread);
362 }),
363);
364​
365it.effect(
366 "refreshes linked environments after permitted writes and blocks a read-only session",
367 () =>
368 Effect.gen(function* () {
369 const registry = yield* setup;
370 const peer: PeerLinkSummary = {
371 environmentId: EnvironmentId.make("peer"),
372 label: "Box",
373 urls: ["https://box.example.test"],
374 access: "auto",
375 linkedAt: DateTime.makeUnsafe("2026-10-01T12:00:00Z"),
376 expiresAt: DateTime.makeUnsafe("2026-10-31T12:00:00Z"),
377 lastReachedAt: null,
378 lastError: null,
379 status: "reachable",
380 };
381 let links: ReadonlyArray<PeerLinkSummary> = [];
382 const client = {
383 [WS_METHODS.peerLinksList]: () => Effect.sync(() => ({ links })),
384 [WS_METHODS.peerLinksLink]: vi.fn(() =>
385 Effect.sync(() => {
386 links = [peer];
387 return peer;
388 }),
389 ),
390 [WS_METHODS.peerLinksUnlink]: vi.fn(() =>
391 Effect.sync(() => {
392 links = [];
393 return { removed: true };
394 }),
395 ),
396 };
397 const supervisor = {
398 target: new BearerConnectionTarget({
399 environmentId: env,
400 label: "Test",
401 connectionId: "test",
402 }),
403 session: yield* SubscriptionRef.make(Option.some({ client } as unknown as RpcSession)),
404 state: yield* SubscriptionRef.make({
405 ...AVAILABLE_CONNECTION_STATE,
406 phase: "connected" as const,
407 }),
408 } as EnvironmentSupervisor["Service"];
409 const peerRuntime = Atom.runtime(
410 Layer.succeed(EnvironmentRegistry, {
411 run: (id, effect) => {
412 expect(id).toBe(env);
413 return Effect.provideService(effect, EnvironmentSupervisor, supervisor);
414 },
415 followStream: (id, stream) => {
416 expect(id).toBe(env);
417 return Stream.provideService(stream, EnvironmentSupervisor, supervisor);
418 },
419 } as EnvironmentRegistry["Service"]),
420 );
421 const peerLinks = createPeerLinkEnvironmentAtoms(peerRuntime);
422 const list = peerLinks.list({ environmentId: env, input: {} });
423 registry.mount(list);
424 const readLinks = AtomRegistry.getResult(registry, list, { suspendOnWaiting: true });
425 const setAccess = (writable: boolean) =>
426 registry.set(
427 sessions(env),
428 AsyncResult.success({
429 ...grant(false),
430 scopes: [AuthAccessReadScope, ...(writable ? [AuthAccessWriteScope] : [])],
431 permissions: [AuthAccessReadScope, ...(writable ? [AuthAccessWriteScope] : [])],
432 }),
433 );
434 registry.mount(sessions(other));
435 registry.set(
436 sessions(other),
437 AsyncResult.success({
438 ...grant(false),
439 scopes: [AuthAccessWriteScope],
440 permissions: [AuthAccessWriteScope],
441 }),
442 );
443 setAccess(false);
444 expect(yield* readLinks).toEqual({ links: [] });
445 const linkInput = { url: peer.urls[0]!, pairingCode: "test-code", access: peer.access };
446 const link = Effect.promise(() =>
447 peerLinks.link.run(registry, { environmentId: env, input: linkInput }),
448 );
449 const unlink = Effect.promise(() =>
450 peerLinks.unlink.run(registry, {
451 environmentId: env,
452 input: { environmentId: peer.environmentId },
453 }),
454 );
455 expect(registry.get(peerLinks.link.permissionAtom(env))).toBe(false);
456 const deniedLink = yield* link;
457 expect(deniedLink._tag).toBe("Failure");
458 if (deniedLink._tag === "Failure") {
459 expect(Cause.squash(deniedLink.cause)).toMatchObject({
460 _tag: "EnvironmentAuthorizationError",
461 requiredPermission: AuthAccessWriteScope,
462 });
463 }
464 expect(client[WS_METHODS.peerLinksLink]).not.toHaveBeenCalled();
465 setAccess(true);
466 expect(registry.get(peerLinks.link.permissionAtom(env))).toBe(true);
467 expect((yield* link)._tag).toBe("Success");
468 expect(client[WS_METHODS.peerLinksLink]).toHaveBeenCalledExactlyOnceWith(linkInput);
469 expect(yield* readLinks).toEqual({ links: [peer] });
470​
471 setAccess(false);
472 expect(registry.get(peerLinks.unlink.permissionAtom(env))).toBe(false);
473 const deniedUnlink = yield* unlink;
474 expect(deniedUnlink._tag).toBe("Failure");
475 if (deniedUnlink._tag === "Failure") {
476 expect(Cause.squash(deniedUnlink.cause)).toMatchObject({
477 _tag: "EnvironmentAuthorizationError",
478 requiredPermission: AuthAccessWriteScope,
479 });
480 }
481 expect(client[WS_METHODS.peerLinksUnlink]).not.toHaveBeenCalled();
482 expect(yield* readLinks).toEqual({ links: [peer] });
483 setAccess(true);
484 expect(registry.get(peerLinks.unlink.permissionAtom(env))).toBe(true);
485 expect((yield* unlink)._tag).toBe("Success");
486 expect(client[WS_METHODS.peerLinksUnlink]).toHaveBeenCalledExactlyOnceWith({
487 environmentId: peer.environmentId,
488 });
489 expect(yield* readLinks).toEqual({ links: [] });
490 }),
491);
492​
493it.effect("requires access management to answer a link request", () =>
494 Effect.gen(function* () {
495 const registry = yield* setup;
496 registry.set(sessions(env), AsyncResult.success(grant(true)));
497 const answer = createCommandPermissions(runtime, WS_METHODS.peerLinksAnswerRequest);
498 expect(registry.get(answer.permissionAtom(env))).toBe(false);
499 expect((yield* answer.authorize(registry, env).pipe(Effect.flip)).requiredPermission).toBe(
500 AuthAccessWriteScope,
501 );
502 registry.set(
503 sessions(env),
504 AsyncResult.success({
505 ...grant(false),
506 scopes: [AuthAccessWriteScope],
507 permissions: [AuthAccessWriteScope],
508 }),
509 );
510 expect(registry.get(answer.permissionAtom(env))).toBe(true);
511 yield* answer.authorize(registry, env);
512 }),
513);
514​
515it.effect("honors exact empty permissions and preserves legacy parent grants", () =>
516 Effect.gen(function* () {
517 const registry = yield* setup;
518 const git = createCommandPermissions(runtime, WS_METHODS.vcsInit);
519 registry.set(sessions(env), AsyncResult.success({ ...grant(true), permissions: [] }));
520 expect(registry.get(git.permissionAtom(env))).toBe(false);
521 const denied = yield* git.authorize(registry, env).pipe(Effect.flip);
522 expect(denied).toMatchObject({
523 requiredPermission: AuthSourceControlWriteScope,
524 requiredScope: AuthOrchestrationOperateScope,
525 });
526 const { permissions: _exact, ...legacy } = grant(true);
527 registry.set(sessions(env), AsyncResult.success(legacy));
528 expect(registry.get(git.permissionAtom(env))).toBe(true);
529 yield* git.authorize(registry, env);
530 }),
531);
532​
533it.effect("rejects protected unary and streamed RPCs outside a guarded command", () =>
534 Effect.gen(function* () {
535 let writes = 0;
536 const session = {
537 client: {
538 [WS_METHODS.scheduledTasksDelete]: () =>
539 Effect.sync(() => {
540 writes++;
541 return { id: ScheduledTaskId.make("task") };
542 }),
543 [WS_METHODS.gitRunStackedAction]: () =>
544 Stream.fromEffect(
545 Effect.sync(() => {
546 writes++;
547 }),
548 ),
549 },
550 } as unknown as RpcSession;
551 const supervisor = {
552 target: { environmentId: env, label: "target" },
553 session: yield* SubscriptionRef.make(Option.some(session)),
554 } as unknown as EnvironmentSupervisor["Service"];
555 const unary = yield* requestGuarded(WS_METHODS.scheduledTasksDelete, {
556 id: ScheduledTaskId.make("task"),
557 }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor), Effect.flip);
558 expect(unary._tag).toBe("EnvironmentAuthorizationError");
559 const streamed = yield* runStreamGuarded(WS_METHODS.gitRunStackedAction, {
560 actionId: "test-action",
561 cwd: "/repo",
562 action: "commit",
563 }).pipe(Stream.runDrain, Effect.provideService(EnvironmentSupervisor, supervisor), Effect.flip);
564 expect(streamed._tag).toBe("EnvironmentAuthorizationError");
565 expect(writes).toBe(0);
566 }),
567);