fix(tess): wire command approvals through gateway
This commit is contained in:
71
apps/gateway/src/chat/chat.gateway-command-approval.spec.ts
Normal file
71
apps/gateway/src/chat/chat.gateway-command-approval.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||
import { ChatGateway } from './chat.gateway.js';
|
||||
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'gc',
|
||||
conversationId: 'conversation-1',
|
||||
approvalId: 'approval-1',
|
||||
};
|
||||
|
||||
function buildGateway(commandExecutor: {
|
||||
execute: ReturnType<typeof vi.fn>;
|
||||
createApproval: ReturnType<typeof vi.fn>;
|
||||
}): ChatGateway {
|
||||
return new ChatGateway(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
commandExecutor as never,
|
||||
{} as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ChatGateway command approval ingress', () => {
|
||||
it('passes the client approval ID through to command execution while deriving the actor server-side', async (): Promise<void> => {
|
||||
const commandExecutor = {
|
||||
execute: vi.fn().mockResolvedValue({ ...payload, success: true }),
|
||||
createApproval: vi.fn(),
|
||||
};
|
||||
const gateway = buildGateway(commandExecutor);
|
||||
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||
|
||||
await gateway.handleCommandExecute(client as never, payload);
|
||||
|
||||
expect(commandExecutor.execute).toHaveBeenCalledWith(payload, 'admin-1');
|
||||
expect(client.emit).toHaveBeenCalledWith(
|
||||
'command:result',
|
||||
expect.objectContaining({ success: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('issues a durable approval only for the authenticated actor', async (): Promise<void> => {
|
||||
const commandExecutor = {
|
||||
execute: vi.fn(),
|
||||
createApproval: vi.fn().mockResolvedValue({
|
||||
approvalId: 'approval-1',
|
||||
expiresAt: '2026-07-12T00:05:00.000Z',
|
||||
}),
|
||||
};
|
||||
const gateway = buildGateway(commandExecutor);
|
||||
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||
|
||||
await gateway.handleCommandApproval(client as never, {
|
||||
command: 'gc',
|
||||
conversationId: 'conversation-1',
|
||||
});
|
||||
|
||||
expect(commandExecutor.createApproval).toHaveBeenCalledWith(
|
||||
{ command: 'gc', conversationId: 'conversation-1' },
|
||||
'admin-1',
|
||||
);
|
||||
expect(client.emit).toHaveBeenCalledWith('command:approval', {
|
||||
command: 'gc',
|
||||
conversationId: 'conversation-1',
|
||||
success: true,
|
||||
approvalId: 'approval-1',
|
||||
expiresAt: '2026-07-12T00:05:00.000Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import type { Auth } from '@mosaicstack/auth';
|
||||
import type { Brain } from '@mosaicstack/brain';
|
||||
import type {
|
||||
SetThinkingPayload,
|
||||
SlashCommandApprovalResultPayload,
|
||||
SlashCommandPayload,
|
||||
SystemReloadPayload,
|
||||
RoutingDecisionInfo,
|
||||
@@ -429,6 +430,30 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
client.emit('command:result', result);
|
||||
}
|
||||
|
||||
@SubscribeMessage('command:approve')
|
||||
async handleCommandApproval(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: SlashCommandPayload,
|
||||
): Promise<void> {
|
||||
const scope = this.getClientScope(client);
|
||||
const approval = scope ? await this.commandExecutor.createApproval(payload, scope) : null;
|
||||
const result: SlashCommandApprovalResultPayload = approval
|
||||
? {
|
||||
command: payload.command,
|
||||
conversationId: payload.conversationId,
|
||||
success: true,
|
||||
approvalId: approval.approvalId,
|
||||
expiresAt: approval.expiresAt,
|
||||
}
|
||||
: {
|
||||
command: payload.command,
|
||||
conversationId: payload.conversationId,
|
||||
success: false,
|
||||
message: 'Not authorized to approve this command.',
|
||||
};
|
||||
client.emit('command:approval', result);
|
||||
}
|
||||
|
||||
broadcastReload(payload: SystemReloadPayload): void {
|
||||
this.server.emit('system:reload', payload);
|
||||
this.logger.log('Broadcasted system:reload to all connected clients');
|
||||
|
||||
@@ -30,29 +30,32 @@ function createService(role: string): CommandAuthorizationService {
|
||||
describe('CommandAuthorizationService', () => {
|
||||
it('consumes one exact actor-bound approval once', async (): Promise<void> => {
|
||||
const service = createService('admin');
|
||||
const approval = await service.createApproval('gc', payload, 'admin-1');
|
||||
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
|
||||
expect(approval).not.toBeNull();
|
||||
expect(
|
||||
(await service.authorize(adminCommand, payload, 'admin-1', approval.approvalId)).allowed,
|
||||
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(await service.authorize(adminCommand, payload, 'admin-1', approval.approvalId)).allowed,
|
||||
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an approval when the structured action is mutated', async (): Promise<void> => {
|
||||
const service = createService('admin');
|
||||
const approval = await service.createApproval('gc', payload, 'admin-1');
|
||||
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
|
||||
const mutated = { ...payload, conversationId: 'other-conversation' };
|
||||
expect(approval).not.toBeNull();
|
||||
expect(
|
||||
(await service.authorize(adminCommand, mutated, 'admin-1', approval.approvalId)).allowed,
|
||||
(await service.authorize(adminCommand, mutated, 'admin-1', approval!.approvalId)).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('denies an admin command to a member before approval is considered', async (): Promise<void> => {
|
||||
const service = createService('member');
|
||||
const approval = await service.createApproval('gc', payload, 'member-1');
|
||||
const approval = await service.createApproval(adminCommand, payload, 'member-1');
|
||||
expect(approval).toBeNull();
|
||||
expect(
|
||||
(await service.authorize(adminCommand, payload, 'member-1', approval.approvalId)).allowed,
|
||||
(await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,17 +55,20 @@ export class CommandAuthorizationService {
|
||||
}
|
||||
|
||||
async createApproval(
|
||||
command: string,
|
||||
command: CommandDef,
|
||||
payload: SlashCommandPayload,
|
||||
actorId: string,
|
||||
): Promise<CommandApproval> {
|
||||
): Promise<CommandApproval | null> {
|
||||
const role = await this.resolveRole(actorId);
|
||||
if (!role || command.scope !== 'admin' || !this.hasScope(role, command.scope)) return null;
|
||||
|
||||
const approvalId = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
|
||||
const approval: CommandApproval = {
|
||||
approvalId,
|
||||
actionDigest: this.actionDigest(command, payload),
|
||||
actionDigest: this.actionDigest(command.name, payload),
|
||||
actorId,
|
||||
command,
|
||||
command: command.name,
|
||||
expiresAt,
|
||||
};
|
||||
await this.redis.set(this.key(approvalId), JSON.stringify(approval), 'EX', '300');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
|
||||
const registry = {
|
||||
@@ -33,7 +34,7 @@ const authorization = {
|
||||
),
|
||||
};
|
||||
|
||||
function buildExecutor(): CommandExecutorService {
|
||||
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
|
||||
return new CommandExecutorService(
|
||||
registry as never,
|
||||
{ getSession: vi.fn() } as never,
|
||||
@@ -44,10 +45,25 @@ function buildExecutor(): CommandExecutorService {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
authorization as never,
|
||||
authorizationService as never,
|
||||
);
|
||||
}
|
||||
|
||||
function createDurableAuthorization(): CommandAuthorizationService {
|
||||
const entries = new Map<string, string>();
|
||||
const db = {
|
||||
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }) }),
|
||||
};
|
||||
const redis = {
|
||||
get: async (key: string) => entries.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
entries.set(key, value);
|
||||
},
|
||||
del: async (key: string) => Number(entries.delete(key)),
|
||||
};
|
||||
return new CommandAuthorizationService(db as never, redis);
|
||||
}
|
||||
|
||||
describe('TESS-M1-SEC-001 command authorization abuse cases', () => {
|
||||
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
|
||||
|
||||
@@ -70,4 +86,21 @@ describe('TESS-M1-SEC-001 command authorization abuse cases', () => {
|
||||
expect(result.message).toContain('approval');
|
||||
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('executes an admin command only after a valid durable approval is issued and supplied', async (): Promise<void> => {
|
||||
const executor = buildExecutor(createDurableAuthorization());
|
||||
|
||||
const denied = await executor.execute(payload, 'admin-1');
|
||||
const approval = await executor.createApproval(payload, 'admin-1');
|
||||
const approved = await executor.execute(
|
||||
{ ...payload, approvalId: approval?.approvalId },
|
||||
'admin-1',
|
||||
);
|
||||
|
||||
expect(denied.success).toBe(false);
|
||||
expect(denied.message).toContain('approval');
|
||||
expect(approval).not.toBeNull();
|
||||
expect(approved.success).toBe(true);
|
||||
expect(sessionGc.sweepOrphans).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -162,6 +162,14 @@ export class CommandExecutorService {
|
||||
}
|
||||
}
|
||||
|
||||
async createApproval(payload: SlashCommandPayload, scope: ActorTenantScope) {
|
||||
const def = this.registry
|
||||
.getManifest()
|
||||
.commands.find((command) => command.name === payload.command);
|
||||
if (!def || !this.authorization) return null;
|
||||
return this.authorization.createApproval(def, payload, scope.userId);
|
||||
}
|
||||
|
||||
private async handleModel(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
CommandManifestPayload,
|
||||
SlashCommandApprovalResultPayload,
|
||||
SlashCommandPayload,
|
||||
SlashCommandResultPayload,
|
||||
SystemReloadPayload,
|
||||
@@ -116,6 +117,7 @@ export interface ServerToClientEvents {
|
||||
'session:info': (payload: SessionInfoPayload) => void;
|
||||
'commands:manifest': (payload: CommandManifestPayload) => void;
|
||||
'command:result': (payload: SlashCommandResultPayload) => void;
|
||||
'command:approval': (payload: SlashCommandApprovalResultPayload) => void;
|
||||
'system:reload': (payload: SystemReloadPayload) => void;
|
||||
error: (payload: ErrorPayload) => void;
|
||||
}
|
||||
@@ -125,5 +127,6 @@ export interface ClientToServerEvents {
|
||||
message: (data: ChatMessagePayload) => void;
|
||||
'set:thinking': (data: SetThinkingPayload) => void;
|
||||
'command:execute': (data: SlashCommandPayload) => void;
|
||||
'command:approve': (data: SlashCommandPayload) => void;
|
||||
abort: (data: AbortPayload) => void;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,18 @@ export interface SlashCommandPayload {
|
||||
conversationId: string;
|
||||
command: string;
|
||||
args?: string;
|
||||
/** One-time server-issued approval for a privileged command execution. */
|
||||
approvalId?: string;
|
||||
}
|
||||
|
||||
/** Server response to a request to approve a privileged slash command. */
|
||||
export interface SlashCommandApprovalResultPayload {
|
||||
conversationId: string;
|
||||
command: string;
|
||||
success: boolean;
|
||||
approvalId?: string;
|
||||
expiresAt?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** Server response to a slash command */
|
||||
|
||||
Reference in New Issue
Block a user