Skip to content

Commit 993bbd4

Browse files
committed
fix: implement chat surface authorization logic and add tests
1 parent 96bb1b1 commit 993bbd4

4 files changed

Lines changed: 186 additions & 1 deletion

File tree

tests/chat_surface_service.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { jest } from '@jest/globals';
2+
import { ChatSurfaceService } from '../transport/surfaces/chatSurfaceService.js';
3+
4+
const OPTIONS = {
5+
sessionResource: {
6+
resourceId: 'sessions',
7+
idField: 'id',
8+
titleField: 'title',
9+
turnsField: 'turns',
10+
askerIdField: 'asker_id',
11+
createdAtField: 'created_at',
12+
},
13+
turnResource: {
14+
resourceId: 'turns',
15+
idField: 'id',
16+
sessionIdField: 'session_id',
17+
createdAtField: 'created_at',
18+
promptField: 'prompt',
19+
responseField: 'response',
20+
},
21+
chatExternalIdentityResource: {
22+
resourceId: 'external_identities',
23+
surfaces: {
24+
telegram: { provider: 'AdminForthAdapterTelegramOauth2' },
25+
},
26+
},
27+
} as any;
28+
29+
const ADAPTER = { name: 'telegram' } as any;
30+
const INCOMING = {
31+
surface: 'telegram',
32+
externalUserId: 'telegram-user-1',
33+
externalConversationId: 'telegram-chat-1',
34+
prompt: 'hello',
35+
} as any;
36+
37+
function buildRequest() {
38+
return {
39+
body: {},
40+
query: {},
41+
headers: {},
42+
cookies: [],
43+
requestUrl: '/adminapi/v1/agent/surface/telegram/webhook',
44+
response: {
45+
setHeader: jest.fn(),
46+
setStatus: jest.fn(),
47+
blobStream: jest.fn(),
48+
},
49+
} as any;
50+
}
51+
52+
function buildService(adminUserRecord: Record<string, unknown>, authorizationHooks: any[]) {
53+
const adminforth = {
54+
config: {
55+
auth: {
56+
usersResourceId: 'admin_users',
57+
usernameField: 'email',
58+
adminUserAuthorize: authorizationHooks,
59+
},
60+
resources: [{
61+
resourceId: 'admin_users',
62+
columns: [{ name: 'id', primaryKey: true }],
63+
}],
64+
},
65+
resource(resourceId: string) {
66+
if (resourceId === 'external_identities') {
67+
return {
68+
list: jest.fn().mockResolvedValue([{
69+
provider: 'AdminForthAdapterTelegramOauth2',
70+
externalUserId: 'telegram-user-1',
71+
adminUserId: 'user-1',
72+
}]),
73+
};
74+
}
75+
76+
return {
77+
get: jest.fn().mockResolvedValue(adminUserRecord),
78+
};
79+
},
80+
};
81+
const sessionStore = {
82+
getOrCreateChatSurfaceSession: jest.fn().mockResolvedValue('session-1'),
83+
};
84+
const handleTurn = jest.fn().mockResolvedValue(undefined);
85+
const service = new ChatSurfaceService(
86+
() => adminforth as any,
87+
OPTIONS,
88+
sessionStore as any,
89+
handleTurn,
90+
jest.fn(),
91+
);
92+
93+
return { service, sessionStore, handleTurn };
94+
}
95+
96+
describe('adminforth-agent chat surface authorization', () => {
97+
it('blocks a deactivated user before creating an agent session', async () => {
98+
const authorizationHook = jest.fn(async ({ adminUser }) => ({
99+
allowed: adminUser.dbUser.is_active,
100+
}));
101+
const { service, sessionStore, handleTurn } = buildService(
102+
{ id: 'user-1', email: 'user@example.com', is_active: false },
103+
[authorizationHook],
104+
);
105+
const sink = { emit: jest.fn(), close: jest.fn() };
106+
107+
await service.handleMessage(ADAPTER, INCOMING, sink as any, buildRequest());
108+
109+
expect(authorizationHook).toHaveBeenCalledWith(expect.objectContaining({
110+
adminUser: expect.objectContaining({ pk: 'user-1' }),
111+
extra: expect.objectContaining({
112+
meta: { chatSurface: 'telegram' },
113+
}),
114+
}));
115+
expect(sessionStore.getOrCreateChatSurfaceSession).not.toHaveBeenCalled();
116+
expect(handleTurn).not.toHaveBeenCalled();
117+
expect(sink.emit).toHaveBeenCalledWith({
118+
type: 'error',
119+
message: 'This chat account is not authorized to use AdminForth Agent.',
120+
});
121+
});
122+
123+
it('continues to the agent when all authorization hooks allow the user', async () => {
124+
const authorizationHook = jest.fn().mockResolvedValue({ allowed: true });
125+
const { service, sessionStore, handleTurn } = buildService(
126+
{ id: 'user-1', email: 'user@example.com', is_active: true },
127+
[authorizationHook],
128+
);
129+
const sink = { emit: jest.fn(), close: jest.fn() };
130+
131+
await service.handleMessage(ADAPTER, INCOMING, sink as any, buildRequest());
132+
133+
expect(sessionStore.getOrCreateChatSurfaceSession).toHaveBeenCalled();
134+
expect(handleTurn).toHaveBeenCalledWith(expect.objectContaining({
135+
sessionId: 'session-1',
136+
chatSurface: 'telegram',
137+
adminUser: expect.objectContaining({ pk: 'user-1' }),
138+
}));
139+
});
140+
});

transport/http/chatSurfaceEndpoints.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export function setupChatSurfaceEndpoints(ctx: ChatSurfaceEndpointsContext, serv
5454
const sink = await adapter.createEventSink(surfaceContext, incoming);
5555

5656
try {
57-
await ctx.handleChatSurfaceMessage(adapter, incoming, sink);
57+
await ctx.handleChatSurfaceMessage(adapter, incoming, sink, endpointInput);
5858
} finally {
5959
await sink.close?.();
6060
}

transport/http/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
ChatSurfaceEventSink,
44
ChatSurfaceIncomingMessage,
55
IAdminForth,
6+
IAdminForthEndpointHandlerInput,
67
} from "adminforth";
78
import type {
89
HandleEditTurnInput,
@@ -35,6 +36,7 @@ export type AgentEndpointsContext = {
3536
adapter: ChatSurfaceAdapter,
3637
incoming: ChatSurfaceIncomingMessage,
3738
sink: ChatSurfaceEventSink,
39+
request: IAdminForthEndpointHandlerInput,
3840
): Promise<void>;
3941
};
4042

transport/surfaces/chatSurfaceService.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type {
22
AdminUser,
3+
AdminUserAuthorizeFunction,
34
ChatSurfaceAdapter,
45
ChatSurfaceEventSink,
56
ChatSurfaceIncomingMessage,
67
IAdminForth,
8+
IAdminForthEndpointHandlerInput,
79
} from "adminforth";
810
import { Filters, logger } from "adminforth";
911
import type { AgentEventEmitter } from "../../domain/agentEvents.js";
@@ -281,10 +283,43 @@ export class ChatSurfaceService {
281283
]);
282284
}
283285

286+
private async isAdminUserAuthorized(
287+
adminUser: AdminUser,
288+
incoming: ChatSurfaceIncomingMessage,
289+
request: IAdminForthEndpointHandlerInput,
290+
) {
291+
const adminforth = this.getAdminforth();
292+
const hooks = adminforth.config.auth!.adminUserAuthorize as AdminUserAuthorizeFunction[];
293+
const extra = {
294+
body: request.body,
295+
query: request.query,
296+
headers: request.headers,
297+
cookies: request.cookies,
298+
requestUrl: request.requestUrl,
299+
meta: { chatSurface: incoming.surface },
300+
response: request.response,
301+
};
302+
303+
for (const hook of hooks) {
304+
const result = await hook({
305+
adminUser,
306+
response: request.response,
307+
adminforth,
308+
extra,
309+
});
310+
if (result?.allowed === false || result?.error) {
311+
return false;
312+
}
313+
}
314+
315+
return true;
316+
}
317+
284318
async handleMessage(
285319
adapter: ChatSurfaceAdapter,
286320
incoming: ChatSurfaceIncomingMessage,
287321
sink: ChatSurfaceEventSink,
322+
request: IAdminForthEndpointHandlerInput,
288323
) {
289324
if (await this.handleLink(incoming, sink)) {
290325
return;
@@ -310,6 +345,14 @@ export class ChatSurfaceService {
310345
dbUser: adminUserRecord,
311346
};
312347

348+
if (!await this.isAdminUserAuthorized(adminUser, incoming, request)) {
349+
await sink.emit({
350+
type: "error",
351+
message: "This chat account is not authorized to use AdminForth Agent.",
352+
});
353+
return;
354+
}
355+
313356
const incomingWithAudio = incoming as ChatSurfaceIncomingMessageWithAudio;
314357
if (incomingWithAudio.audio) {
315358
await this.handleAudioMessage(incomingWithAudio, sink as ChatSurfaceEventSinkWithAudio, adminUser);

0 commit comments

Comments
 (0)