@memberjunction/server 5.50.0 → 5.51.1
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/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +6 -1
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/auth/index.d.ts +8 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +53 -13
- package/dist/auth/index.js.map +1 -1
- package/dist/config.d.ts +24 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +19 -2
- package/dist/context.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/realtimeWidget/widgetGuestElevation.d.ts +22 -0
- package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
- package/dist/realtimeWidget/widgetGuestElevation.js +36 -0
- package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +21 -0
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +75 -16
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/ReportResolver.d.ts.map +1 -1
- package/dist/resolvers/ReportResolver.js +2 -1
- package/dist/resolvers/ReportResolver.js.map +1 -1
- package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
- package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
- package/dist/rest/OAuthCallbackHandler.js +129 -43
- package/dist/rest/OAuthCallbackHandler.js.map +1 -1
- package/package.json +89 -89
- package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
- package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +420 -0
- package/src/__tests__/SessionManager.test.ts +62 -0
- package/src/__tests__/newUsers.test.ts +729 -0
- package/src/__tests__/widgetGuestElevation.test.ts +70 -2
- package/src/agentSessions/SessionManager.ts +6 -1
- package/src/auth/index.ts +56 -15
- package/src/config.ts +12 -0
- package/src/context.ts +19 -2
- package/src/index.ts +3 -1
- package/src/realtimeWidget/widgetGuestElevation.ts +41 -0
- package/src/resolvers/RealtimeClientSessionResolver.ts +84 -16
- package/src/resolvers/ReportResolver.ts +2 -1
- package/src/resolvers/__tests__/ReportResolver.test.ts +232 -0
- package/src/rest/OAuthCallbackHandler.ts +149 -41
|
@@ -1,6 +1,22 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import type { DatabaseProviderBase, UserInfo } from '@memberjunction/core';
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
// Controls what UserCache.Instance.GetSystemUser() serves — undefined simulates an unpopulated
|
|
5
|
+
// cache (no system user available), the fail-closed path.
|
|
6
|
+
const getSystemUserMock = vi.fn<[], UserInfo | undefined>();
|
|
7
|
+
vi.mock('@memberjunction/sqlserver-dataprovider', () => ({
|
|
8
|
+
UserCache: {
|
|
9
|
+
get Instance() {
|
|
10
|
+
return { GetSystemUser: getSystemUserMock };
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
resolveWidgetGuestRunContext,
|
|
17
|
+
elevateUserPayload,
|
|
18
|
+
ResolveScopedAnonymousRunUser,
|
|
19
|
+
} from '../realtimeWidget/widgetGuestElevation.js';
|
|
4
20
|
import type { UserPayload } from '../types.js';
|
|
5
21
|
|
|
6
22
|
/** A provider stub — the guard paths under test return before ever touching the provider. */
|
|
@@ -31,6 +47,58 @@ describe('widgetGuestElevation — resolveWidgetGuestRunContext (guard paths, no
|
|
|
31
47
|
});
|
|
32
48
|
});
|
|
33
49
|
|
|
50
|
+
describe('widgetGuestElevation — ResolveScopedAnonymousRunUser (issue #3371)', () => {
|
|
51
|
+
const systemUser = { ID: 'system-1', Email: 'system@system.org' } as UserInfo;
|
|
52
|
+
|
|
53
|
+
/** Builds a UserInfo carrying the given per-session guest flags. */
|
|
54
|
+
function userWith(flags: Partial<UserInfo>): UserInfo {
|
|
55
|
+
return { ID: 'anon-1', Email: 'anonymous@magic-link.local', ...flags } as UserInfo;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
getSystemUserMock.mockReset();
|
|
60
|
+
getSystemUserMock.mockReturnValue(systemUser);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns the caller unchanged for a normal authenticated user', () => {
|
|
64
|
+
const named = userWith({ IsMagicLinkAnonymous: false, MagicLinkScope: { ResourceID: 'res-1' } });
|
|
65
|
+
expect(ResolveScopedAnonymousRunUser(named)).toBe(named);
|
|
66
|
+
expect(getSystemUserMock).not.toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns the caller unchanged for an anonymous session with no resource scope', () => {
|
|
70
|
+
const unscoped = userWith({ IsMagicLinkAnonymous: true, MagicLinkScope: undefined });
|
|
71
|
+
expect(ResolveScopedAnonymousRunUser(unscoped)).toBe(unscoped);
|
|
72
|
+
expect(getSystemUserMock).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns the caller unchanged for a scoped anonymous session with an empty ResourceID', () => {
|
|
76
|
+
const emptyScope = userWith({ IsMagicLinkAnonymous: true, MagicLinkScope: { ResourceID: '' } });
|
|
77
|
+
expect(ResolveScopedAnonymousRunUser(emptyScope)).toBe(emptyScope);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns the caller unchanged for a PUBLIC WEB-WIDGET guest (widget guests keep their own path)', () => {
|
|
81
|
+
const widgetGuest = userWith({
|
|
82
|
+
IsMagicLinkAnonymous: true,
|
|
83
|
+
MagicLinkScope: { ResourceID: 'res-1' },
|
|
84
|
+
WidgetGuestContext: { WidgetID: 'widget-1' },
|
|
85
|
+
});
|
|
86
|
+
expect(ResolveScopedAnonymousRunUser(widgetGuest)).toBe(widgetGuest);
|
|
87
|
+
expect(getSystemUserMock).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('FAILS CLOSED to the caller when no system user is available', () => {
|
|
91
|
+
getSystemUserMock.mockReturnValue(undefined);
|
|
92
|
+
const scopedAnon = userWith({ IsMagicLinkAnonymous: true, MagicLinkScope: { ResourceID: 'res-1' } });
|
|
93
|
+
expect(ResolveScopedAnonymousRunUser(scopedAnon)).toBe(scopedAnon);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('returns the SYSTEM user for a scoped anonymous (non-widget) magic-link session', () => {
|
|
97
|
+
const scopedAnon = userWith({ IsMagicLinkAnonymous: true, MagicLinkScope: { ResourceID: 'res-1' } });
|
|
98
|
+
expect(ResolveScopedAnonymousRunUser(scopedAnon)).toBe(systemUser);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
34
102
|
describe('widgetGuestElevation — elevateUserPayload', () => {
|
|
35
103
|
it('swaps in the elevated principal while preserving the guest sessionId for PubSub routing', () => {
|
|
36
104
|
const guestPayload = payloadWith({ IsMagicLinkAnonymous: true });
|
|
@@ -8,6 +8,7 @@ import { AIAgentPermissionHelper } from '@memberjunction/ai-engine-base';
|
|
|
8
8
|
import { RealtimeClientSessionService, RealtimeChannelServerHost } from '@memberjunction/ai-agents';
|
|
9
9
|
import { GetHostInstanceID } from './HostInstance.js';
|
|
10
10
|
import { writeReturningVisitorRecap } from './ReturningVisitorRecap.js';
|
|
11
|
+
import { ResolveScopedAnonymousRunUser } from '../realtimeWidget/widgetGuestElevation.js';
|
|
11
12
|
|
|
12
13
|
/** Entity names — centralised so the `MJ:`-prefix convention is applied in exactly one place. */
|
|
13
14
|
const SESSION_ENTITY = 'MJ: AI Agent Sessions';
|
|
@@ -230,10 +231,14 @@ export class SessionManager {
|
|
|
230
231
|
return;
|
|
231
232
|
}
|
|
232
233
|
try {
|
|
234
|
+
// SCOPED-ANONYMOUS ELEVATION (issue #3371): the runs were CREATED under the system user
|
|
235
|
+
// for a scoped anonymous session, so an owner-initiated (or error) close must finalize
|
|
236
|
+
// under it too — the caller's role holds no grants on the AI run entities. Janitor and
|
|
237
|
+
// shutdown sweeps already close as the system user and pass through unchanged.
|
|
233
238
|
await new RealtimeClientSessionService().FinalizeCoAgentRun(
|
|
234
239
|
config.coAgentRunID ?? null,
|
|
235
240
|
config.promptRunID ?? null,
|
|
236
|
-
contextUser,
|
|
241
|
+
ResolveScopedAnonymousRunUser(contextUser),
|
|
237
242
|
provider,
|
|
238
243
|
true,
|
|
239
244
|
config.coAgentRunStepID ?? null,
|
package/src/auth/index.ts
CHANGED
|
@@ -186,6 +186,42 @@ export const getSystemUser = async (dataSource?: sql.ConnectionPool, attemptCach
|
|
|
186
186
|
return systemUser;
|
|
187
187
|
};
|
|
188
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Extracts the lowercased domain portion of an email address.
|
|
191
|
+
*
|
|
192
|
+
* @returns the domain, or an empty string when the value is not an email address (e.g. an IdP that
|
|
193
|
+
* issues a bare username). Callers MUST treat an empty result as "cannot be authorized"
|
|
194
|
+
* rather than as a wildcard.
|
|
195
|
+
*/
|
|
196
|
+
const extractEmailDomain = (email: string): string => {
|
|
197
|
+
const parts = email.split('@');
|
|
198
|
+
// Reject anything that isn't exactly local@domain — a value with 0 or 2+ '@' is not an address we
|
|
199
|
+
// can make a trust decision about.
|
|
200
|
+
if (parts.length !== 2) return '';
|
|
201
|
+
return parts[1].toLowerCase().trim();
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Tests a domain against `userHandling.newUserAuthorizedDomains`, honoring `*` wildcards.
|
|
206
|
+
*
|
|
207
|
+
* Note that a pattern is matched in full, so `*.example.com` matches `mail.example.com` but NOT
|
|
208
|
+
* `example.com` — list both if you need both.
|
|
209
|
+
*/
|
|
210
|
+
const isDomainAuthorized = (domain: string): boolean =>
|
|
211
|
+
configInfo.userHandling.newUserAuthorizedDomains.some((pattern) => {
|
|
212
|
+
// Convert wildcard domain patterns to regular expressions
|
|
213
|
+
const regex = new RegExp('^' + pattern.toLowerCase().trim().replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
|
|
214
|
+
return regex.test(domain);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Resolves a verified identity to an MJ `UserInfo`, optionally auto-provisioning a new user.
|
|
219
|
+
*
|
|
220
|
+
* @param requestDomain the hostname parsed from the request's `Origin` header. **Not used for any
|
|
221
|
+
* authorization decision** — it is spoofable on non-browser requests, and new-user domain
|
|
222
|
+
* authorization runs against the verified JWT's email domain instead. Retained for audit
|
|
223
|
+
* logging and for the recursive retry call.
|
|
224
|
+
*/
|
|
189
225
|
export const verifyUserRecord = async (
|
|
190
226
|
email?: string,
|
|
191
227
|
firstName?: string,
|
|
@@ -206,23 +242,21 @@ export const verifyUserRecord = async (
|
|
|
206
242
|
});
|
|
207
243
|
|
|
208
244
|
if (!user) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
//
|
|
245
|
+
// NOTE: `requestDomain` (parsed from the spoofable `Origin` header) is deliberately NOT part of
|
|
246
|
+
// this condition. It was previously required here, which meant a non-browser client sending no
|
|
247
|
+
// Origin could never auto-provision while an attacker simply forged one — it gated entry without
|
|
248
|
+
// authorizing anything. Authorization happens below, against the verified identity's email domain.
|
|
249
|
+
if (configInfo.userHandling.autoCreateNewUsers && firstName && lastName) {
|
|
250
|
+
// SECURITY: authorize against the EMAIL DOMAIN of the cryptographically-verified identity,
|
|
251
|
+
// NOT the request `Origin` header. Origin is trivially spoofable on non-browser / bearer-token
|
|
252
|
+
// requests, so gating on it let a holder of any valid IdP token auto-provision an account under
|
|
253
|
+
// an authorized domain by sending a forged Origin. The email comes from the verified JWT.
|
|
254
|
+
const emailDomain: string = extractEmailDomain(email);
|
|
216
255
|
let passesDomainCheck: boolean =
|
|
217
256
|
configInfo.userHandling.newUserLimitedToAuthorizedDomains ===
|
|
218
257
|
false; /*in this first condition, we are set up to NOT care about domain */
|
|
219
|
-
if (!passesDomainCheck
|
|
220
|
-
|
|
221
|
-
passesDomainCheck = configInfo.userHandling.newUserAuthorizedDomains.some((pattern) => {
|
|
222
|
-
// Convert wildcard domain patterns to regular expressions
|
|
223
|
-
const regex = new RegExp('^' + pattern.toLowerCase().trim().replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
|
|
224
|
-
return regex.test(requestDomain?.toLowerCase().trim());
|
|
225
|
-
});
|
|
258
|
+
if (!passesDomainCheck) {
|
|
259
|
+
passesDomainCheck = emailDomain.length > 0 && isDomainAuthorized(emailDomain);
|
|
226
260
|
}
|
|
227
261
|
|
|
228
262
|
if (passesDomainCheck) {
|
|
@@ -248,9 +282,16 @@ export const verifyUserRecord = async (
|
|
|
248
282
|
UserCache.Instance.Users.push(user);
|
|
249
283
|
console.warn(` >>> New user ${email} created successfully!`);
|
|
250
284
|
}
|
|
285
|
+
} else if (emailDomain.length === 0) {
|
|
286
|
+
// The verified identity carries no email domain at all — typically an IdP that issues a bare
|
|
287
|
+
// `preferred_username` with no `email` claim. There is nothing to match against, so the gate
|
|
288
|
+
// denies rather than falling back to anything spoofable.
|
|
289
|
+
console.warn(
|
|
290
|
+
`User ${email} not found in cache and will NOT be auto-created: the verified identity has no email domain (no '@'), so it cannot be matched against newUserAuthorizedDomains. This usually means the identity provider issues a username rather than an email address — configure it to emit an 'email' claim, or set newUserLimitedToAuthorizedDomains to false to disable domain checking.`
|
|
291
|
+
);
|
|
251
292
|
} else {
|
|
252
293
|
console.warn(
|
|
253
|
-
`User ${email} not found in cache.
|
|
294
|
+
`User ${email} not found in cache. Email domain '${emailDomain}' does not match any of the domains in the newUserAuthorizedDomains setting. NOTE: this check is against the EMAIL DOMAIN of the verified identity, NOT the browser Origin — if newUserAuthorizedDomains lists frontend hostnames (e.g. 'app.example.com'), replace them with email domains (e.g. 'example.com'). To ignore domain, make sure you set the newUserLimitedToAuthorizedDomains setting to false. In this case we are NOT creating a new user.`
|
|
254
295
|
);
|
|
255
296
|
}
|
|
256
297
|
}
|
package/src/config.ts
CHANGED
|
@@ -7,7 +7,19 @@ const explorer = cosmiconfigSync('mj', { searchStrategy: 'global' });
|
|
|
7
7
|
|
|
8
8
|
const userHandlingInfoSchema = z.object({
|
|
9
9
|
autoCreateNewUsers: z.boolean().optional().default(false),
|
|
10
|
+
/** When true, auto-provisioning is restricted to the domains in `newUserAuthorizedDomains`. */
|
|
10
11
|
newUserLimitedToAuthorizedDomains: z.boolean().optional().default(false),
|
|
12
|
+
/**
|
|
13
|
+
* Authorized **email domains** for auto-provisioned users — e.g. `['example.com', '*.example.org']`.
|
|
14
|
+
*
|
|
15
|
+
* These are matched against the domain of the email address in the verified identity token, NOT
|
|
16
|
+
* against the browser `Origin` / frontend hostname. If you are upgrading from a build that
|
|
17
|
+
* compared these to the request origin, replace any frontend hostnames here (`app.example.com`,
|
|
18
|
+
* `localhost`) with the email domains your users actually sign in with.
|
|
19
|
+
*
|
|
20
|
+
* `*` wildcards are supported and match in full: `*.example.com` matches `mail.example.com` but
|
|
21
|
+
* NOT `example.com` — list both if you need both.
|
|
22
|
+
*/
|
|
11
23
|
newUserAuthorizedDomains: z.array(z.string()).optional().default([]),
|
|
12
24
|
newUserRoles: z.array(z.string()).optional().default([]),
|
|
13
25
|
updateCacheWhenNotFound: z.boolean().optional().default(false),
|
package/src/context.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { IncomingMessage } from 'http';
|
|
2
|
+
import { createHash, timingSafeEqual } from 'crypto';
|
|
2
3
|
import { default as jwt } from 'jsonwebtoken';
|
|
3
4
|
import 'reflect-metadata';
|
|
4
5
|
import { Subject, firstValueFrom } from 'rxjs';
|
|
@@ -319,7 +320,13 @@ const verifyAsync = async (issuer: string, token: string): Promise<jwt.JwtPayloa
|
|
|
319
320
|
return;
|
|
320
321
|
}
|
|
321
322
|
|
|
322
|
-
const verifyOptions: jwt.VerifyOptions = {
|
|
323
|
+
const verifyOptions: jwt.VerifyOptions = {
|
|
324
|
+
// SECURITY: explicitly pin the accepted signature algorithms to the asymmetric family.
|
|
325
|
+
// The signing key here comes from the issuer's JWKS (an RSA/EC public key), so without an
|
|
326
|
+
// explicit allow-list a future key-format change or library regression could reintroduce
|
|
327
|
+
// classic `alg=none` / RS256->HS256 confusion attacks. Pinning fails such tokens closed.
|
|
328
|
+
algorithms: ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'PS256'],
|
|
329
|
+
};
|
|
323
330
|
if (Array.isArray(options.audience)) {
|
|
324
331
|
verifyOptions.audience = options.audience as [string, ...string[]];
|
|
325
332
|
} else {
|
|
@@ -420,7 +427,17 @@ export const getUserPayload = async (
|
|
|
420
427
|
// Check for system API key (x-mj-api-key header)
|
|
421
428
|
// This authenticates as the system user for system-level operations
|
|
422
429
|
if (systemApiKey && systemApiKey != String(undefined)) {
|
|
423
|
-
|
|
430
|
+
// SECURITY: compare the superadmin system API key in constant time. A plain `===`
|
|
431
|
+
// short-circuits on the first differing byte, leaking a timing side-channel that could
|
|
432
|
+
// be used to recover the key byte-by-byte. Hash both sides to fixed-length digests so
|
|
433
|
+
// timingSafeEqual never throws on length mismatch and the comparison is length-agnostic.
|
|
434
|
+
// The Uint8Array wrappers are required by the @types/node version this line is pinned to
|
|
435
|
+
// (20.14.2, via the root `overrides`), whose `timingSafeEqual` takes NodeJS.ArrayBufferView —
|
|
436
|
+
// a type its own non-generic `Buffer` does not satisfy under TypeScript 5.9's lib. Copying
|
|
437
|
+
// two fixed-length 32-byte digests is content-independent, so the comparison stays constant time.
|
|
438
|
+
const systemKeyDigest = new Uint8Array(createHash('sha256').update(String(systemApiKey)).digest());
|
|
439
|
+
const providedKeyDigest = new Uint8Array(createHash('sha256').update(String(apiKey)).digest());
|
|
440
|
+
if (timingSafeEqual(systemKeyDigest, providedKeyDigest)) {
|
|
424
441
|
const systemUser = await getSystemUser(readOnlyDataSource);
|
|
425
442
|
return {
|
|
426
443
|
userRecord: systemUser,
|
package/src/index.ts
CHANGED
|
@@ -1038,7 +1038,9 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
1038
1038
|
const { callbackRouter, authenticatedRouter } = createOAuthCallbackHandler({
|
|
1039
1039
|
publicUrl: oauthPublicUrl,
|
|
1040
1040
|
successRedirectUrl: `${oauthPublicUrl}/oauth/success`,
|
|
1041
|
-
errorRedirectUrl: `${oauthPublicUrl}/oauth/error
|
|
1041
|
+
errorRedirectUrl: `${oauthPublicUrl}/oauth/error`,
|
|
1042
|
+
// Constrains where a caller-supplied frontendReturnUrl may point (open-redirect guard).
|
|
1043
|
+
allowedFrontendOrigins: configInfo.cors?.allowedOrigins ?? ['*']
|
|
1042
1044
|
});
|
|
1043
1045
|
oauthAuthenticatedRouter = authenticatedRouter;
|
|
1044
1046
|
|
|
@@ -77,6 +77,47 @@ export async function resolveWidgetGuestRunContext(
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Returns the identity a realtime session's AI-RUN-ENTITY work (delegated tool dispatch +
|
|
82
|
+
* observability run creation/append/finalize) should execute as: the trusted SYSTEM principal when
|
|
83
|
+
* the caller is a SCOPED anonymous magic-link session, else the caller unchanged (issue #3371).
|
|
84
|
+
*
|
|
85
|
+
* A scoped anonymous session (`IsMagicLinkAnonymous` + `MagicLinkScope.ResourceID`) holds only the
|
|
86
|
+
* narrow relay grants its invite's role carries — deliberately NOT the AI run entities, whose rows
|
|
87
|
+
* leak the rendered system prompt. Unlike {@link resolveWidgetGuestRunContext} no widget instance is
|
|
88
|
+
* required: on the realtime path the agent authority is already server-side (the session config's
|
|
89
|
+
* `targetAgentID`, `CanRun`-gated at session start), so there is no client-supplied agent id to pin.
|
|
90
|
+
*
|
|
91
|
+
* PUBLIC WEB-WIDGET guests are deliberately EXCLUDED (returned unchanged): their seeded role writes
|
|
92
|
+
* run rows under the guest principal, which the `Widget Guest: Own Agent Runs` RLS read filter
|
|
93
|
+
* depends on — elevating them here would silently break that read-side control.
|
|
94
|
+
*
|
|
95
|
+
* Fails CLOSED: when no system user is available the caller is returned unchanged, so the request
|
|
96
|
+
* fails exactly as it would today rather than proceeding unelevated-but-assumed-elevated.
|
|
97
|
+
*
|
|
98
|
+
* Ownership/RLS gates must NEVER use this — they stay on the caller; this only changes who the
|
|
99
|
+
* work RUNS AS after ownership is proven.
|
|
100
|
+
*/
|
|
101
|
+
export function ResolveScopedAnonymousRunUser(contextUser: UserInfo): UserInfo {
|
|
102
|
+
const scopeId = contextUser?.MagicLinkScope?.ResourceID;
|
|
103
|
+
if (!contextUser?.IsMagicLinkAnonymous || !scopeId || contextUser.WidgetGuestContext?.WidgetID) {
|
|
104
|
+
return contextUser;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const systemUser = UserCache.Instance.GetSystemUser();
|
|
108
|
+
if (!systemUser) {
|
|
109
|
+
LogError(
|
|
110
|
+
'[Realtime] Cannot elevate scoped-anonymous run work: no system user available; ' +
|
|
111
|
+
`falling back to the anonymous caller for scope ${scopeId}.`,
|
|
112
|
+
);
|
|
113
|
+
return contextUser;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Deliberately silent on success — callers include per-utterance/per-usage-delta relays, so a
|
|
117
|
+
// per-call log line would flood a live session's log. The dispatch path logs the elevation once.
|
|
118
|
+
return systemUser;
|
|
119
|
+
}
|
|
120
|
+
|
|
80
121
|
/**
|
|
81
122
|
* Builds an elevated {@link UserPayload} that runs subsequent agent work as `elevatedUser` while
|
|
82
123
|
* preserving the guest's `sessionId` — so progress/streaming PubSub still routes to the guest's
|
|
@@ -60,7 +60,7 @@ import { ResolverBase } from '../generic/ResolverBase.js';
|
|
|
60
60
|
import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
|
|
61
61
|
import { GetReadWriteProvider } from '../util.js';
|
|
62
62
|
import { SessionManager } from '../agentSessions/index.js';
|
|
63
|
-
import { resolveWidgetGuestRunContext } from '../realtimeWidget/widgetGuestElevation.js';
|
|
63
|
+
import { resolveWidgetGuestRunContext, ResolveScopedAnonymousRunUser } from '../realtimeWidget/widgetGuestElevation.js';
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
66
|
* Progress steps worth narrating to the realtime model — mirrors the normal agent-run path's filter
|
|
@@ -495,13 +495,28 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
495
495
|
const session = await this.loadOwnedActiveSession(agentSessionId, contextUser, provider);
|
|
496
496
|
const config = this.readSessionConfig(session);
|
|
497
497
|
|
|
498
|
+
// SCOPED-ANONYMOUS ELEVATION (issue #3371): once ownership is proven above, the delegated
|
|
499
|
+
// run + its AI-run-entity writes execute as the system user for a scoped anonymous caller
|
|
500
|
+
// (the caller's role deliberately holds no grants on the run entities). The lead
|
|
501
|
+
// targetAgentID comes from the session config and was CanRun-gated at start; the colleague
|
|
502
|
+
// union is gated just below, against the CALLER, so elevation never widens agent authority.
|
|
503
|
+
const runUser = ResolveScopedAnonymousRunUser(contextUser);
|
|
504
|
+
if (runUser !== contextUser) {
|
|
505
|
+
LogStatus(
|
|
506
|
+
`ExecuteRealtimeSessionTool: dispatching relayed tool '${toolName}' for session ${agentSessionId} ` +
|
|
507
|
+
'under the system user (scoped-anonymous caller).',
|
|
508
|
+
);
|
|
509
|
+
}
|
|
498
510
|
const { ResultJson, PausedRunID, Artifacts } = await this.clientSessionService.ExecuteRelayedTool(
|
|
499
511
|
{
|
|
500
512
|
AgentSessionID: agentSessionId,
|
|
501
513
|
TargetAgentID: config.targetAgentID,
|
|
502
514
|
// Multi-target (Move 4): the session's persisted allowed-agent union — a model-named
|
|
503
515
|
// colleague in the call is validated against this; absent ⇒ single-target behavior.
|
|
504
|
-
AllowedAgents: config.allowedAgents,
|
|
516
|
+
AllowedAgents: await this.filterAllowedAgentsByCanRun(config.allowedAgents, contextUser),
|
|
517
|
+
// Attribution follows the VISITOR even when `runUser` is elevated: the delegated run
|
|
518
|
+
// row and its context-memory scope must stay the person's, not the system user's.
|
|
519
|
+
AttributionUserID: contextUser.ID,
|
|
505
520
|
// Nest the delegated target-agent run under the co-agent observability run (when present).
|
|
506
521
|
ParentRunID: config.coAgentRunID,
|
|
507
522
|
Call: { CallID: callId, ToolName: toolName, Arguments: argsJson },
|
|
@@ -509,7 +524,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
509
524
|
// Resume a previously-paused delegated run (if any) with the user's answer.
|
|
510
525
|
ResumeRunID: config.pendingFeedbackRunID,
|
|
511
526
|
},
|
|
512
|
-
|
|
527
|
+
runUser,
|
|
513
528
|
provider,
|
|
514
529
|
);
|
|
515
530
|
|
|
@@ -519,7 +534,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
519
534
|
|
|
520
535
|
// Junction-link any artifacts the delegated run produced into the session's conversation
|
|
521
536
|
// history (best-effort) — so chat, session review, and resume carryover can all see them.
|
|
522
|
-
|
|
537
|
+
// Runs as `runUser`: the junction entity is not among an anonymous caller's relay grants.
|
|
538
|
+
await this.linkDelegatedArtifactsToConversation(session, Artifacts, runUser, provider);
|
|
523
539
|
|
|
524
540
|
await this.sessionManager.Heartbeat(agentSessionId, contextUser, provider);
|
|
525
541
|
return ResultJson;
|
|
@@ -625,6 +641,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
625
641
|
}
|
|
626
642
|
// Mirror the turn onto the co-agent's long-lived prompt run so its Messages capture the full
|
|
627
643
|
// conversation (run-viewer observability parity). Best-effort — never fails the transcript relay.
|
|
644
|
+
// The prompt-run write runs as the scoped-anonymous elevated user (issue #3371) — the visible
|
|
645
|
+
// Conversation Detail above deliberately stays on the caller.
|
|
628
646
|
const promptRunID = this.readPromptRunID(session);
|
|
629
647
|
if (promptRunID) {
|
|
630
648
|
await this.clientSessionService.AppendPromptRunMessage(
|
|
@@ -632,7 +650,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
632
650
|
this.mapTranscriptRoleToChatRole(role),
|
|
633
651
|
text,
|
|
634
652
|
replacesPrevious ?? false,
|
|
635
|
-
contextUser,
|
|
653
|
+
ResolveScopedAnonymousRunUser(contextUser),
|
|
636
654
|
provider,
|
|
637
655
|
);
|
|
638
656
|
}
|
|
@@ -691,13 +709,18 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
691
709
|
return { Success: false, ErrorMessage: 'Recording consent was not granted.' };
|
|
692
710
|
}
|
|
693
711
|
|
|
712
|
+
// SCOPED-ANONYMOUS ELEVATION (issue #3371): past the ownership + consent gates, the store is
|
|
713
|
+
// server-side plumbing over entities (MJ: AI Agents read, MJ: Files, the file-session link)
|
|
714
|
+
// the caller's narrow relay role deliberately does not hold. Attribution flows through the
|
|
715
|
+
// session link, so nothing here depends on the caller's identity.
|
|
716
|
+
const runUser = ResolveScopedAnonymousRunUser(contextUser);
|
|
694
717
|
try {
|
|
695
|
-
const agent = await provider.GetEntityObject<MJAIAgentEntity>('MJ: AI Agents',
|
|
718
|
+
const agent = await provider.GetEntityObject<MJAIAgentEntity>('MJ: AI Agents', runUser);
|
|
696
719
|
if (!(await agent.Load(session.AgentID))) {
|
|
697
720
|
return { Success: false, ErrorMessage: `Co-agent ${session.AgentID} for the session could not be loaded.` };
|
|
698
721
|
}
|
|
699
722
|
|
|
700
|
-
const accountID = await resolveRecordingStorageAccountID(agent,
|
|
723
|
+
const accountID = await resolveRecordingStorageAccountID(agent, runUser, provider);
|
|
701
724
|
if (!accountID) {
|
|
702
725
|
return { Success: false, ErrorMessage: 'No recording storage account is configured for this agent.' };
|
|
703
726
|
}
|
|
@@ -714,7 +737,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
714
737
|
StartedAt: session.RecordingStartedAt ?? new Date(),
|
|
715
738
|
StorageAccountID: accountID,
|
|
716
739
|
SessionID: agentSessionId,
|
|
717
|
-
ContextUser:
|
|
740
|
+
ContextUser: runUser,
|
|
718
741
|
Provider: provider,
|
|
719
742
|
// Sanitized capture-time waveform peaks → persisted as a peaks.json sidecar.
|
|
720
743
|
Peaks: this.sanitizePeaks(peaks),
|
|
@@ -722,7 +745,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
722
745
|
|
|
723
746
|
// Canonical consolidated file written — drop the crash-recovery shards (best-effort).
|
|
724
747
|
if (fileID) {
|
|
725
|
-
await deleteRealtimeRecordingSegments(agentSessionId, accountID,
|
|
748
|
+
await deleteRealtimeRecordingSegments(agentSessionId, accountID, runUser);
|
|
726
749
|
}
|
|
727
750
|
|
|
728
751
|
return {
|
|
@@ -762,11 +785,13 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
762
785
|
try {
|
|
763
786
|
const { contextUser, provider } = this.requireUserAndProvider(ctx.userPayload, ctx.providers);
|
|
764
787
|
const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
|
|
765
|
-
|
|
788
|
+
// Scoped-anonymous elevation (issue #3371) — same rationale as UploadRealtimeRecording.
|
|
789
|
+
const runUser = ResolveScopedAnonymousRunUser(contextUser);
|
|
790
|
+
const agent = await provider.GetEntityObject<MJAIAgentEntity>('MJ: AI Agents', runUser);
|
|
766
791
|
if (!(await agent.Load(session.AgentID))) {
|
|
767
792
|
return false;
|
|
768
793
|
}
|
|
769
|
-
const accountID = await resolveRecordingStorageAccountID(agent,
|
|
794
|
+
const accountID = await resolveRecordingStorageAccountID(agent, runUser, provider);
|
|
770
795
|
if (!accountID) {
|
|
771
796
|
return false;
|
|
772
797
|
}
|
|
@@ -780,7 +805,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
780
805
|
Audio: buffer,
|
|
781
806
|
MimeType: mimeType,
|
|
782
807
|
StorageAccountID: accountID,
|
|
783
|
-
ContextUser:
|
|
808
|
+
ContextUser: runUser,
|
|
784
809
|
});
|
|
785
810
|
} catch (error) {
|
|
786
811
|
LogError(`RealtimeClientSessionResolver.UploadRealtimeRecordingSegment failed for session ${agentSessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -815,7 +840,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
815
840
|
'assistant',
|
|
816
841
|
this.formatToolTurn(toolName, argsJson, resultJson),
|
|
817
842
|
false,
|
|
818
|
-
contextUser,
|
|
843
|
+
ResolveScopedAnonymousRunUser(contextUser),
|
|
819
844
|
provider,
|
|
820
845
|
);
|
|
821
846
|
}
|
|
@@ -913,7 +938,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
913
938
|
}
|
|
914
939
|
// Delegate to the service so usage writes share the per-run serialization with transcript-message
|
|
915
940
|
// appends — otherwise the frequent usage save clobbers freshly-appended Messages (and vice-versa).
|
|
916
|
-
|
|
941
|
+
// Runs as the scoped-anonymous elevated user (issue #3371) — the caller's role holds no prompt-run grants.
|
|
942
|
+
return this.clientSessionService.AccumulatePromptRunUsage(
|
|
943
|
+
promptRunID, inputDelta, outputDelta, ResolveScopedAnonymousRunUser(contextUser), provider,
|
|
944
|
+
);
|
|
917
945
|
}
|
|
918
946
|
|
|
919
947
|
/** Clamps a relayed token delta: negative / non-finite values become 0. */
|
|
@@ -1123,6 +1151,39 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
1123
1151
|
}
|
|
1124
1152
|
}
|
|
1125
1153
|
|
|
1154
|
+
/**
|
|
1155
|
+
* Narrows a session's colleague union (`allowedAgents`) to the agents the CALLER may run.
|
|
1156
|
+
*
|
|
1157
|
+
* {@link assertCanRunTarget} gates the LEAD target at session start, but the union it travels
|
|
1158
|
+
* with was never gated at all — a model-named colleague resolves straight to a delegated run.
|
|
1159
|
+
* That was survivable while the run carried the caller's own identity, because base-agent
|
|
1160
|
+
* re-checks `CanRun` against `contextUser`. Once the run user is elevated for a scoped anonymous
|
|
1161
|
+
* caller (issue #3371) that check sees the SYSTEM user, so this is the only remaining place the
|
|
1162
|
+
* caller's own authority is applied to a colleague. It therefore runs for EVERY caller, elevated
|
|
1163
|
+
* or not — the authorization identity must never depend on the elevation decision.
|
|
1164
|
+
*
|
|
1165
|
+
* `HasPermission` reads AIEngineBase's in-memory caches (no DB round trip) and already fails
|
|
1166
|
+
* closed on error, so an unresolvable agent drops OUT of the union rather than becoming runnable.
|
|
1167
|
+
* A filtered-out colleague is not an error: the delegation layer reports it as "not available in
|
|
1168
|
+
* this session" and lists what remains, which is the same answer the model gets for a typo.
|
|
1169
|
+
*
|
|
1170
|
+
* @param allowedAgents The session's persisted colleague union (absent/empty ⇒ single-target).
|
|
1171
|
+
* @param contextUser The ORIGINAL caller — never the elevated run user.
|
|
1172
|
+
* @returns The subset the caller may run, preserving order.
|
|
1173
|
+
*/
|
|
1174
|
+
private async filterAllowedAgentsByCanRun(
|
|
1175
|
+
allowedAgents: RealtimeAllowedAgent[] | undefined,
|
|
1176
|
+
contextUser: UserInfo,
|
|
1177
|
+
): Promise<RealtimeAllowedAgent[] | undefined> {
|
|
1178
|
+
if (!allowedAgents || allowedAgents.length === 0) {
|
|
1179
|
+
return allowedAgents;
|
|
1180
|
+
}
|
|
1181
|
+
const verdicts = await Promise.all(
|
|
1182
|
+
allowedAgents.map((a) => AIAgentPermissionHelper.HasPermission(a.agentId, contextUser, 'run')),
|
|
1183
|
+
);
|
|
1184
|
+
return allowedAgents.filter((_, i) => verdicts[i]);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1126
1187
|
/**
|
|
1127
1188
|
* Resolves the AUTHORITATIVE target agent id under the co-agent's PAIRING CONSTRAINTS
|
|
1128
1189
|
* (`MJ: AI Agent Co Agents`, ordered by `Sequence`):
|
|
@@ -1584,7 +1645,12 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
1584
1645
|
ApplicationID: applicationId,
|
|
1585
1646
|
AppContext: appContext,
|
|
1586
1647
|
},
|
|
1587
|
-
|
|
1648
|
+
// SCOPED-ANONYMOUS ELEVATION (issue #3371): the prepare creates the co-agent
|
|
1649
|
+
// observability AIAgentRun/AIPromptRun/run-step, which a scoped anonymous caller's role
|
|
1650
|
+
// deliberately cannot write. `UserID` above stays the CALLER's id, so run attribution
|
|
1651
|
+
// and memory scope remain the visitor's. Authorization (CanRun, runtime overrides)
|
|
1652
|
+
// already ran on the caller in StartRealtimeClientSession.
|
|
1653
|
+
ResolveScopedAnonymousRunUser(contextUser),
|
|
1588
1654
|
provider,
|
|
1589
1655
|
);
|
|
1590
1656
|
|
|
@@ -2190,7 +2256,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
2190
2256
|
detail.HiddenToUser = true;
|
|
2191
2257
|
detail.Message = 'Artifacts produced during a realtime session (system anchor).';
|
|
2192
2258
|
detail.AgentSessionID = session.ID;
|
|
2193
|
-
|
|
2259
|
+
// Attribute the anchor to the SESSION owner, not the (possibly elevated) writer — identical
|
|
2260
|
+
// for every non-elevated caller, whose ownership of the session is already proven.
|
|
2261
|
+
detail.UserID = session.UserID;
|
|
2194
2262
|
if (await detail.Save()) {
|
|
2195
2263
|
return detail.ID;
|
|
2196
2264
|
}
|
|
@@ -98,9 +98,10 @@ export class ReportResolverExtended extends ResolverBase {
|
|
|
98
98
|
ON
|
|
99
99
|
cd.ConversationID = c.ID
|
|
100
100
|
WHERE
|
|
101
|
-
cd.ID
|
|
101
|
+
cd.ID=@ConversationDetailID`;
|
|
102
102
|
|
|
103
103
|
const request = new mssql.Request(dataSource);
|
|
104
|
+
request.input('ConversationDetailID', mssql.UniqueIdentifier, ConversationDetailID);
|
|
104
105
|
const result = await request.query(sql);
|
|
105
106
|
if (!result || !result.recordset || result.recordset.length === 0) throw new Error('Unable to retrieve converation details');
|
|
106
107
|
const skipData: { title?: string; reportTitle?: string; userExplanation?: string; messages?: unknown[] } = JSON.parse(result.recordset[0].Message);
|