@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.
- package/dist/domain/pr-review.d.ts +1 -1
- package/dist/domain/sessions.d.ts +45 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +245 -15
- package/dist/index.js.map +1 -1
- package/dist/model-catalog.d.ts +2 -0
- package/dist/session-authorization.d.ts +29 -1
- package/dist/site-session-origin.d.ts +7 -0
- package/package.json +12 -12
- package/src/domain/resources.ts +1 -1
- package/src/domain/scheduled-tasks.ts +71 -1
- package/src/domain/session-tool-policy.ts +1 -1
- package/src/domain/sessions.ts +201 -5
- package/src/index.ts +1 -0
- package/src/model-catalog.ts +7 -4
- package/src/session-authorization.ts +113 -11
- package/src/site-session-origin.ts +17 -0
|
@@ -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
|
-
|
|
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
|
|
246
|
-
//
|
|
247
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
+
}
|