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 { Brain } from '@mosaicstack/brain';
|
||||||
import type {
|
import type {
|
||||||
SetThinkingPayload,
|
SetThinkingPayload,
|
||||||
|
SlashCommandApprovalResultPayload,
|
||||||
SlashCommandPayload,
|
SlashCommandPayload,
|
||||||
SystemReloadPayload,
|
SystemReloadPayload,
|
||||||
RoutingDecisionInfo,
|
RoutingDecisionInfo,
|
||||||
@@ -429,6 +430,30 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
client.emit('command:result', result);
|
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 {
|
broadcastReload(payload: SystemReloadPayload): void {
|
||||||
this.server.emit('system:reload', payload);
|
this.server.emit('system:reload', payload);
|
||||||
this.logger.log('Broadcasted system:reload to all connected clients');
|
this.logger.log('Broadcasted system:reload to all connected clients');
|
||||||
|
|||||||
@@ -30,29 +30,32 @@ function createService(role: string): CommandAuthorizationService {
|
|||||||
describe('CommandAuthorizationService', () => {
|
describe('CommandAuthorizationService', () => {
|
||||||
it('consumes one exact actor-bound approval once', async (): Promise<void> => {
|
it('consumes one exact actor-bound approval once', async (): Promise<void> => {
|
||||||
const service = createService('admin');
|
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(
|
expect(
|
||||||
(await service.authorize(adminCommand, payload, 'admin-1', approval.approvalId)).allowed,
|
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
(await service.authorize(adminCommand, payload, 'admin-1', approval.approvalId)).allowed,
|
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an approval when the structured action is mutated', async (): Promise<void> => {
|
it('rejects an approval when the structured action is mutated', async (): Promise<void> => {
|
||||||
const service = createService('admin');
|
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' };
|
const mutated = { ...payload, conversationId: 'other-conversation' };
|
||||||
|
expect(approval).not.toBeNull();
|
||||||
expect(
|
expect(
|
||||||
(await service.authorize(adminCommand, mutated, 'admin-1', approval.approvalId)).allowed,
|
(await service.authorize(adminCommand, mutated, 'admin-1', approval!.approvalId)).allowed,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('denies an admin command to a member before approval is considered', async (): Promise<void> => {
|
it('denies an admin command to a member before approval is considered', async (): Promise<void> => {
|
||||||
const service = createService('member');
|
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(
|
expect(
|
||||||
(await service.authorize(adminCommand, payload, 'member-1', approval.approvalId)).allowed,
|
(await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,17 +55,20 @@ export class CommandAuthorizationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createApproval(
|
async createApproval(
|
||||||
command: string,
|
command: CommandDef,
|
||||||
payload: SlashCommandPayload,
|
payload: SlashCommandPayload,
|
||||||
actorId: string,
|
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 approvalId = randomUUID();
|
||||||
const expiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
|
const expiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
|
||||||
const approval: CommandApproval = {
|
const approval: CommandApproval = {
|
||||||
approvalId,
|
approvalId,
|
||||||
actionDigest: this.actionDigest(command, payload),
|
actionDigest: this.actionDigest(command.name, payload),
|
||||||
actorId,
|
actorId,
|
||||||
command,
|
command: command.name,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
};
|
};
|
||||||
await this.redis.set(this.key(approvalId), JSON.stringify(approval), 'EX', '300');
|
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||||
|
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
|
|
||||||
const registry = {
|
const registry = {
|
||||||
@@ -33,7 +34,7 @@ const authorization = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildExecutor(): CommandExecutorService {
|
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
|
||||||
return new CommandExecutorService(
|
return new CommandExecutorService(
|
||||||
registry as never,
|
registry as never,
|
||||||
{ getSession: vi.fn() } as never,
|
{ getSession: vi.fn() } as never,
|
||||||
@@ -44,10 +45,25 @@ function buildExecutor(): CommandExecutorService {
|
|||||||
null,
|
null,
|
||||||
null,
|
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', () => {
|
describe('TESS-M1-SEC-001 command authorization abuse cases', () => {
|
||||||
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
|
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(result.message).toContain('approval');
|
||||||
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
|
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(
|
private async handleModel(
|
||||||
args: string | null,
|
args: string | null,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
CommandManifestPayload,
|
CommandManifestPayload,
|
||||||
|
SlashCommandApprovalResultPayload,
|
||||||
SlashCommandPayload,
|
SlashCommandPayload,
|
||||||
SlashCommandResultPayload,
|
SlashCommandResultPayload,
|
||||||
SystemReloadPayload,
|
SystemReloadPayload,
|
||||||
@@ -116,6 +117,7 @@ export interface ServerToClientEvents {
|
|||||||
'session:info': (payload: SessionInfoPayload) => void;
|
'session:info': (payload: SessionInfoPayload) => void;
|
||||||
'commands:manifest': (payload: CommandManifestPayload) => void;
|
'commands:manifest': (payload: CommandManifestPayload) => void;
|
||||||
'command:result': (payload: SlashCommandResultPayload) => void;
|
'command:result': (payload: SlashCommandResultPayload) => void;
|
||||||
|
'command:approval': (payload: SlashCommandApprovalResultPayload) => void;
|
||||||
'system:reload': (payload: SystemReloadPayload) => void;
|
'system:reload': (payload: SystemReloadPayload) => void;
|
||||||
error: (payload: ErrorPayload) => void;
|
error: (payload: ErrorPayload) => void;
|
||||||
}
|
}
|
||||||
@@ -125,5 +127,6 @@ export interface ClientToServerEvents {
|
|||||||
message: (data: ChatMessagePayload) => void;
|
message: (data: ChatMessagePayload) => void;
|
||||||
'set:thinking': (data: SetThinkingPayload) => void;
|
'set:thinking': (data: SetThinkingPayload) => void;
|
||||||
'command:execute': (data: SlashCommandPayload) => void;
|
'command:execute': (data: SlashCommandPayload) => void;
|
||||||
|
'command:approve': (data: SlashCommandPayload) => void;
|
||||||
abort: (data: AbortPayload) => void;
|
abort: (data: AbortPayload) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,18 @@ export interface SlashCommandPayload {
|
|||||||
conversationId: string;
|
conversationId: string;
|
||||||
command: string;
|
command: string;
|
||||||
args?: 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 */
|
/** Server response to a slash command */
|
||||||
|
|||||||
Reference in New Issue
Block a user