@opengeni/core 2.7.5-canary.5 → 2.7.5-canary.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,9 +3,12 @@ import {
3
3
  SessionAuthorizationDecision,
4
4
  SessionAuthorizationListScope,
5
5
  type AccessGrant,
6
+ type SessionAgentAccess,
7
+ type SessionAgentAccessViewer,
6
8
  type SessionAuthorizationOperation,
7
9
  type SessionAuthorizationSurface,
8
10
  type SessionAuthorizationTarget,
11
+ type SessionEndUser,
9
12
  } from "@opengeni/contracts";
10
13
  import {
11
14
  getSessionAuthorityProjection,
@@ -48,17 +51,83 @@ export type ResolvedSessionAuthorization = {
48
51
  reauthorizeAfterMs: number | null;
49
52
  };
50
53
 
54
+ /** The frozen agent-access facts of one session, as the pairwise rule sees them. */
55
+ export type SessionAgentAccessFacts = {
56
+ agentAccess: SessionAgentAccess;
57
+ endUser: SessionEndUser | null;
58
+ };
59
+
51
60
  type ResolvedSessionAuthorizationActor = {
52
61
  actor: SessionAuthorizationActor;
53
62
  callerParentSessionId: string | null;
63
+ /** The caller session's own access facts; null for non-agent principals. */
64
+ callerAccess: SessionAgentAccessFacts | null;
54
65
  };
55
66
 
67
+ function sameEndUser(left: SessionEndUser | null, right: SessionEndUser | null): boolean {
68
+ return left !== null && right !== null && left.source === right.source && left.id === right.id;
69
+ }
70
+
71
+ /**
72
+ * The agent-to-agent reach rule (migration 0427) for one caller/target pair
73
+ * that live in DIFFERENT root trees. A caller always keeps its own tree, so
74
+ * this is never consulted for same-root access. The most restrictive side
75
+ * wins: a `session` side denies everything; a `user` side requires both
76
+ * sessions to carry the same non-null end-user label; two `workspace` sides
77
+ * are today's behaviour. Humans and API keys never pass through here.
78
+ */
79
+ export function agentAccessPermitsCrossTreeAccess(
80
+ caller: SessionAgentAccessFacts,
81
+ target: SessionAgentAccessFacts,
82
+ ): boolean {
83
+ if (caller.agentAccess === "session" || target.agentAccess === "session") return false;
84
+ if (caller.agentAccess === "user" || target.agentAccess === "user") {
85
+ return sameEndUser(caller.endUser, target.endUser);
86
+ }
87
+ return true;
88
+ }
89
+
90
+ /**
91
+ * The in-database list scope for one calling attempt. A `session` caller is
92
+ * pinned to its own root tree when the host does not narrow it. Every caller
93
+ * carries the viewer so `sessionAuthorizationScopeFilter` intersects the host
94
+ * scope with the same pairwise rule in SQL, including for session callers.
95
+ */
96
+ export function agentAccessListScopeForViewer(
97
+ viewer: SessionAgentAccessViewer,
98
+ hostScope: SessionAuthorizationListScope | null = null,
99
+ ): SessionAuthorizationListScope {
100
+ if (viewer.agentAccess === "session" && (!hostScope || hostScope.kind === "all")) {
101
+ return {
102
+ kind: "scoped",
103
+ rootSessionIds: [viewer.callerRootSessionId],
104
+ sessionIds: [],
105
+ agentAccessViewer: viewer,
106
+ };
107
+ }
108
+ if (!hostScope || hostScope.kind === "all") {
109
+ return { kind: "all", agentAccessViewer: viewer };
110
+ }
111
+ return {
112
+ kind: "scoped",
113
+ rootSessionIds: [...new Set(hostScope.rootSessionIds)],
114
+ sessionIds: [...new Set(hostScope.sessionIds)],
115
+ agentAccessViewer: viewer,
116
+ };
117
+ }
118
+
56
119
  type ResolvedSessionAuthorizationTarget = {
57
120
  target: SessionAuthorizationTarget;
58
121
  parentSessionId: string | null;
59
122
  };
60
123
 
61
- function grantHasAgentAttemptAuthority(grant: AccessGrant): boolean {
124
+ /**
125
+ * Whether a grant acts with live agent-attempt authority: an explicit
126
+ * `agent_attempt` principal kind, or (for legacy tokens without one) any
127
+ * worker-signed exact attempt claim. Shared by the session seam and by the
128
+ * narrowing fences that let a human widen a session an agent may only narrow.
129
+ */
130
+ export function grantHasAgentAttemptAuthority(grant: AccessGrant): boolean {
62
131
  const hasAgentAttemptClaim =
63
132
  grant.metadata?.["turnId"] !== undefined ||
64
133
  grant.metadata?.["attemptId"] !== undefined ||
@@ -192,6 +261,22 @@ export async function requireSessionAuthorization(
192
261
  : actor.callerRootSessionId === target.rootSessionId;
193
262
  if (!allowed) throw new SessionAuthorizationDeniedError("forbidden");
194
263
  }
264
+ // Agent-access scope (migration 0427): an attempt always keeps its own root
265
+ // tree; across trees the most restrictive of the caller's and the target's
266
+ // declared reach wins. Both sides are read from durable session rows, never
267
+ // from the request, and an embedding-host port cannot widen this.
268
+ if (actor.kind === "agent_attempt" && target.rootSessionId !== actor.callerRootSessionId) {
269
+ const callerAccess = resolvedActor.callerAccess;
270
+ if (
271
+ !callerAccess ||
272
+ !agentAccessPermitsCrossTreeAccess(callerAccess, {
273
+ agentAccess: authority.agentAccess,
274
+ endUser: authority.endUser,
275
+ })
276
+ ) {
277
+ throw new SessionAuthorizationDeniedError("forbidden");
278
+ }
279
+ }
195
280
  if (!port) {
196
281
  return {
197
282
  actor,
@@ -241,10 +326,20 @@ export async function requireSessionAuthorizationListScope(
241
326
  const port = deps.sessionAuthorization;
242
327
  const isAgentAttempt = grantHasAgentAttemptAuthority(grant);
243
328
  if (!port && !isAgentAttempt) return null;
244
- const { actor } = await resolveSessionAuthorizationActor(deps.db, grant);
245
- // Standalone agents may retain compact workspace discovery, but only while
246
- // the signed caller attempt is still the exact live attempt.
247
- if (!port) return null;
329
+ const { actor, callerAccess } = await resolveSessionAuthorizationActor(deps.db, grant);
330
+ // Standalone agents retain compact workspace discovery only while the signed
331
+ // caller attempt is still the exact live attempt, and only within their own
332
+ // declared agent-access reach (migration 0427). The viewer is derived from
333
+ // the caller session row and applied as one SQL predicate by every list.
334
+ const viewer: SessionAgentAccessViewer | null =
335
+ actor.kind === "agent_attempt" && callerAccess
336
+ ? {
337
+ callerRootSessionId: actor.callerRootSessionId,
338
+ agentAccess: callerAccess.agentAccess,
339
+ endUser: callerAccess.endUser,
340
+ }
341
+ : null;
342
+ if (!port) return viewer ? agentAccessListScopeForViewer(viewer) : null;
248
343
  let rawScope: unknown;
249
344
  try {
250
345
  rawScope = await port.resolveListScope({
@@ -260,12 +355,17 @@ export async function requireSessionAuthorizationListScope(
260
355
  if (!parsed.success) {
261
356
  throw new SessionAuthorizationUnavailableError({ cause: parsed.error });
262
357
  }
263
- if (parsed.data.kind === "all") return parsed.data;
264
- return {
265
- kind: "scoped",
266
- rootSessionIds: [...new Set(parsed.data.rootSessionIds)],
267
- sessionIds: [...new Set(parsed.data.sessionIds)],
268
- };
358
+ // A host never supplies the viewer: OpenGeni resolved it above from durable
359
+ // state, so a host-returned value is dropped before the intersection.
360
+ const hostScope: SessionAuthorizationListScope =
361
+ parsed.data.kind === "all"
362
+ ? { kind: "all" }
363
+ : {
364
+ kind: "scoped",
365
+ rootSessionIds: [...new Set(parsed.data.rootSessionIds)],
366
+ sessionIds: [...new Set(parsed.data.sessionIds)],
367
+ };
368
+ return viewer ? agentAccessListScopeForViewer(viewer, hostScope) : hostScope;
269
369
  }
270
370
 
271
371
  async function resolveSessionAuthorizationTarget(
@@ -299,6 +399,7 @@ async function resolveSessionAuthorizationActor(
299
399
  ...(grant.subjectLabel ? { subjectLabel: grant.subjectLabel } : {}),
300
400
  }),
301
401
  callerParentSessionId: null,
402
+ callerAccess: null,
302
403
  };
303
404
  }
304
405
  if (
@@ -341,5 +442,6 @@ async function resolveSessionAuthorizationActor(
341
442
  (turn.initiator.kind === "subject" ? turn.initiator.subjectId : null),
342
443
  }),
343
444
  callerParentSessionId: callerSession.parentSessionId,
445
+ callerAccess: { agentAccess: callerSession.agentAccess, endUser: callerSession.endUser },
344
446
  };
345
447
  }
@@ -0,0 +1,17 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ export type SiteSessionOrigin = { siteId: string; title: string };
4
+ const origins = new AsyncLocalStorage<SiteSessionOrigin>();
5
+
6
+ /** Request-local provenance, set only after the API validates a published Site version. */
7
+ export function withSiteSessionOrigin<T>(origin: SiteSessionOrigin, run: () => T): T {
8
+ return origins.run(origin, run);
9
+ }
10
+
11
+ export function sessionCreationMetadata(
12
+ metadata: Record<string, unknown>,
13
+ ): Record<string, unknown> {
14
+ const { _opengeniSiteOrigin: _ignored, ...rest } = metadata;
15
+ const origin = origins.getStore();
16
+ return origin ? { ...rest, _opengeniSiteOrigin: origin } : rest;
17
+ }