269 lines
8.2 KiB
TypeScript
269 lines
8.2 KiB
TypeScript
import { createHash, randomUUID } from 'node:crypto';
|
|
import { Inject, Injectable } from '@nestjs/common';
|
|
import { eq, users as usersTable, type Db } from '@mosaicstack/db';
|
|
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
|
|
import { DB } from '../database/database.module.js';
|
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
|
|
|
export type CommandRole = 'admin' | 'member' | 'viewer';
|
|
|
|
export interface CommandApproval {
|
|
approvalId: string;
|
|
actionDigest: string;
|
|
actorId: string;
|
|
command: string;
|
|
expiresAt: string;
|
|
}
|
|
|
|
/** Exact immutable binding for a privileged runtime termination. */
|
|
export interface RuntimeTerminationApprovalAction {
|
|
providerId: string;
|
|
sessionId: string;
|
|
actorId: string;
|
|
tenantId: string;
|
|
channelId: string;
|
|
correlationId: string;
|
|
/** Provisioned roster identity; isolates approvals between interaction agents. */
|
|
agentName: string;
|
|
}
|
|
|
|
export interface RuntimeTerminationApproval extends RuntimeTerminationApprovalAction {
|
|
approvalId: string;
|
|
actionDigest: string;
|
|
expiresAt: string;
|
|
}
|
|
|
|
export interface CommandAuthorizationResult {
|
|
allowed: boolean;
|
|
reason?: string;
|
|
}
|
|
|
|
@Injectable()
|
|
export class CommandAuthorizationService {
|
|
constructor(
|
|
@Inject(DB) private readonly db: Db,
|
|
@Inject(COMMANDS_REDIS)
|
|
private readonly redis: {
|
|
get(key: string): Promise<string | null>;
|
|
set(key: string, value: string, ...args: string[]): Promise<unknown>;
|
|
del(key: string): Promise<number>;
|
|
},
|
|
) {}
|
|
|
|
async authorize(
|
|
command: CommandDef,
|
|
payload: SlashCommandPayload,
|
|
actorId: string,
|
|
approvalId?: string,
|
|
): Promise<CommandAuthorizationResult> {
|
|
const role = await this.resolveRole(actorId);
|
|
if (!role || !this.hasScope(role, command.scope)) {
|
|
return { allowed: false, reason: 'not authorized for this command scope' };
|
|
}
|
|
if (command.scope !== 'admin') return { allowed: true };
|
|
if (!approvalId) return { allowed: false, reason: 'durable approval is required' };
|
|
const actionDigest = this.actionDigest(command.name, payload);
|
|
const approved = await this.consumeApproval(approvalId, actorId, actionDigest);
|
|
return approved
|
|
? { allowed: true }
|
|
: {
|
|
allowed: false,
|
|
reason: 'approval is invalid, expired, replayed, or does not match this action',
|
|
};
|
|
}
|
|
|
|
async createApproval(
|
|
command: CommandDef,
|
|
payload: SlashCommandPayload,
|
|
actorId: string,
|
|
): 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.name, payload),
|
|
actorId,
|
|
command: command.name,
|
|
expiresAt,
|
|
};
|
|
await this.redis.set(this.key(approvalId), JSON.stringify(approval), 'EX', '300');
|
|
return approval;
|
|
}
|
|
|
|
/**
|
|
* Uses the same `interaction:command-approval:*` store and one-time deletion rule as
|
|
* command approvals. This deliberately avoids a parallel approval database.
|
|
*/
|
|
async createRuntimeTerminationApproval(
|
|
action: RuntimeTerminationApprovalAction,
|
|
): Promise<RuntimeTerminationApproval | null> {
|
|
if (!this.hasRuntimeTerminationAction(action)) return null;
|
|
const role = await this.resolveRole(action.actorId);
|
|
if (role !== 'admin') return null;
|
|
|
|
const approval: RuntimeTerminationApproval = {
|
|
approvalId: randomUUID(),
|
|
actionDigest: this.runtimeActionDigest(action),
|
|
...action,
|
|
expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
|
|
};
|
|
await this.redis.set(
|
|
this.runtimeKey(action.agentName, approval.approvalId),
|
|
JSON.stringify(approval),
|
|
'EX',
|
|
'300',
|
|
);
|
|
return approval;
|
|
}
|
|
|
|
async consumeRuntimeTerminationApproval(
|
|
approvalId: string,
|
|
action: RuntimeTerminationApprovalAction,
|
|
): Promise<boolean> {
|
|
const encoded = await this.redis.get(this.runtimeKey(action.agentName, approvalId));
|
|
if (!encoded) return false;
|
|
let approval: unknown;
|
|
try {
|
|
approval = JSON.parse(encoded);
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (
|
|
!this.isRuntimeTerminationApproval(approval) ||
|
|
approval.actionDigest !== this.runtimeActionDigest(action) ||
|
|
approval.actorId !== action.actorId ||
|
|
approval.tenantId !== action.tenantId ||
|
|
!this.isUnexpired(approval.expiresAt)
|
|
) {
|
|
return false;
|
|
}
|
|
if ((await this.resolveRole(approval.actorId)) !== 'admin') return false;
|
|
return (await this.redis.del(this.runtimeKey(action.agentName, approvalId))) === 1;
|
|
}
|
|
|
|
private async resolveRole(actorId: string): Promise<CommandRole | null> {
|
|
const [user] = await this.db
|
|
.select({ role: usersTable.role })
|
|
.from(usersTable)
|
|
.where(eq(usersTable.id, actorId))
|
|
.limit(1);
|
|
const role = user?.role;
|
|
return role === 'admin' || role === 'member' || role === 'viewer' ? role : null;
|
|
}
|
|
|
|
private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean {
|
|
if (role === 'admin') return true;
|
|
return role === 'member' && (scope === 'core' || scope === 'agent');
|
|
}
|
|
|
|
private async consumeApproval(
|
|
approvalId: string,
|
|
actorId: string,
|
|
actionDigest: string,
|
|
): Promise<boolean> {
|
|
const key = this.key(approvalId);
|
|
const encoded = await this.redis.get(key);
|
|
if (!encoded) return false;
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(encoded);
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (
|
|
!this.isCommandApproval(parsed) ||
|
|
parsed.actorId !== actorId ||
|
|
parsed.actionDigest !== actionDigest ||
|
|
!this.isUnexpired(parsed.expiresAt)
|
|
)
|
|
return false;
|
|
return (await this.redis.del(key)) === 1;
|
|
}
|
|
|
|
private actionDigest(command: string, payload: SlashCommandPayload): string {
|
|
return createHash('sha256')
|
|
.update(
|
|
JSON.stringify({
|
|
command,
|
|
args: payload.args?.trim() ?? '',
|
|
conversationId: payload.conversationId,
|
|
}),
|
|
)
|
|
.digest('hex');
|
|
}
|
|
|
|
private hasRuntimeTerminationAction(action: RuntimeTerminationApprovalAction): boolean {
|
|
return [
|
|
action.providerId,
|
|
action.sessionId,
|
|
action.actorId,
|
|
action.tenantId,
|
|
action.channelId,
|
|
action.correlationId,
|
|
action.agentName,
|
|
].every((value: string): boolean => value.trim().length > 0);
|
|
}
|
|
|
|
private runtimeActionDigest(action: RuntimeTerminationApprovalAction): string {
|
|
return createHash('sha256')
|
|
.update(
|
|
JSON.stringify({
|
|
providerId: action.providerId,
|
|
sessionId: action.sessionId,
|
|
actorId: action.actorId,
|
|
tenantId: action.tenantId,
|
|
channelId: action.channelId,
|
|
correlationId: action.correlationId,
|
|
agentName: action.agentName,
|
|
}),
|
|
)
|
|
.digest('hex');
|
|
}
|
|
|
|
private isUnexpired(expiresAt: unknown): expiresAt is string {
|
|
if (typeof expiresAt !== 'string') return false;
|
|
const expiresAtMs = Date.parse(expiresAt);
|
|
return Number.isFinite(expiresAtMs) && expiresAtMs > Date.now();
|
|
}
|
|
|
|
private isCommandApproval(value: unknown): value is CommandApproval {
|
|
return (
|
|
typeof value === 'object' &&
|
|
value !== null &&
|
|
'approvalId' in value &&
|
|
'actionDigest' in value &&
|
|
'actorId' in value &&
|
|
'expiresAt' in value &&
|
|
'command' in value
|
|
);
|
|
}
|
|
|
|
private isRuntimeTerminationApproval(value: unknown): value is RuntimeTerminationApproval {
|
|
return (
|
|
typeof value === 'object' &&
|
|
value !== null &&
|
|
'approvalId' in value &&
|
|
'actionDigest' in value &&
|
|
'actorId' in value &&
|
|
'tenantId' in value &&
|
|
'providerId' in value &&
|
|
'sessionId' in value &&
|
|
'channelId' in value &&
|
|
'correlationId' in value &&
|
|
'agentName' in value &&
|
|
'expiresAt' in value
|
|
);
|
|
}
|
|
|
|
private key(approvalId: string): string {
|
|
return `interaction:command-approval:${approvalId}`;
|
|
}
|
|
|
|
private runtimeKey(agentName: string, approvalId: string): string {
|
|
return `agent:${encodeURIComponent(agentName)}:command-approval:${approvalId}`;
|
|
}
|
|
}
|