- Backend: - Migration 032: agent_gov_level, status, incidents, permissions tables - Domain types for governance layer - Permission Engine with all governance checks - Governance Service (promote/demote/roles) - Revocation Service (revoke/suspend/reinstate) - Audit Service (events filtering and stats) - Incidents Service (create/assign/escalate/resolve) - REST API routes for governance, audit, incidents - Frontend: - TypeScript types for governance - API clients for governance, audit, incidents - GovernanceLevelBadge component - CityGovernancePanel component - AuditDashboard component - IncidentsList component with detail modal Based on: Agent_Governance_Protocol_v1.md
351 lines
10 KiB
TypeScript
351 lines
10 KiB
TypeScript
/**
|
|
* Revocation Service
|
|
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
|
|
*
|
|
* Handles agent revocation, suspension, and reinstatement
|
|
*/
|
|
|
|
import { db } from '../../infra/db/client';
|
|
import { logger } from '../../infra/logger/logger';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import {
|
|
RevokeAgentRequest,
|
|
RevocationType,
|
|
RevocationEffect,
|
|
AgentRevocation,
|
|
GovernanceScope,
|
|
} from '../../domain/governance/types';
|
|
import { permissionEngine, buildContext } from './permissions';
|
|
import { governanceService } from './governance.service';
|
|
|
|
export class RevocationService {
|
|
/**
|
|
* Revoke an agent
|
|
*/
|
|
async revokeAgent(request: RevokeAgentRequest): Promise<{
|
|
success: boolean;
|
|
revocationId?: string;
|
|
error?: string;
|
|
}> {
|
|
const context = await buildContext(request.actorId, request.scope);
|
|
|
|
// Check permission
|
|
const canRevoke = await permissionEngine.canRevokeAgent(context, request.targetId);
|
|
|
|
if (!canRevoke.allowed) {
|
|
logger.warn(`Revocation denied: ${request.actorId} → ${request.targetId}`, {
|
|
reason: canRevoke.reason,
|
|
});
|
|
return { success: false, error: canRevoke.reason };
|
|
}
|
|
|
|
try {
|
|
const revocationId = uuidv4();
|
|
|
|
// Get target agent DAIS ID
|
|
const agent = await db.query<{ dais_identity_id: string }>(
|
|
`SELECT dais_identity_id FROM agents WHERE id = $1`,
|
|
[request.targetId]
|
|
);
|
|
|
|
const daisId = agent.rows[0]?.dais_identity_id;
|
|
|
|
// Determine what to revoke based on type
|
|
const effect = this.getRevocationEffect(request.revocationType);
|
|
|
|
// Start transaction
|
|
await db.query('BEGIN');
|
|
|
|
try {
|
|
// Update agent status
|
|
const status = request.revocationType === 'shadow' ? 'active' : 'revoked';
|
|
|
|
await db.query(
|
|
`UPDATE agents
|
|
SET status = $1,
|
|
revoked_at = now(),
|
|
revoked_by = $2,
|
|
revocation_reason = $3,
|
|
revocation_type = $4,
|
|
updated_at = now()
|
|
WHERE id = $5`,
|
|
[status, request.actorId, request.reason, request.revocationType, request.targetId]
|
|
);
|
|
|
|
// Invalidate DAIS keys if applicable
|
|
if (effect.daisKeysInvalidated && daisId) {
|
|
await db.query(
|
|
`UPDATE dais_keys
|
|
SET revoked = true,
|
|
revoked_at = now(),
|
|
revoked_by = $1,
|
|
revoked_reason = $2
|
|
WHERE dais_id = $3 AND revoked = false`,
|
|
[request.actorId, request.reason, daisId]
|
|
);
|
|
}
|
|
|
|
// Terminate assignments if applicable
|
|
if (effect.assignmentsTerminated) {
|
|
await db.query(
|
|
`UPDATE agent_assignments
|
|
SET end_ts = now()
|
|
WHERE agent_id = $1 AND end_ts IS NULL`,
|
|
[request.targetId]
|
|
);
|
|
}
|
|
|
|
// Create revocation record
|
|
await db.query(
|
|
`INSERT INTO agent_revocations (
|
|
id, agent_id, dais_id, revoked_by, revocation_type, reason, scope,
|
|
keys_invalidated, wallet_disabled, room_access_revoked,
|
|
node_privileges_removed, assignments_terminated, reversible
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
|
|
[
|
|
revocationId,
|
|
request.targetId,
|
|
daisId,
|
|
request.actorId,
|
|
request.revocationType,
|
|
request.reason,
|
|
request.scope,
|
|
effect.daisKeysInvalidated,
|
|
effect.walletSigningDisabled,
|
|
effect.roomAccessRevoked,
|
|
effect.nodePrivilegesRemoved,
|
|
effect.assignmentsTerminated,
|
|
request.revocationType !== 'hard',
|
|
]
|
|
);
|
|
|
|
await db.query('COMMIT');
|
|
|
|
// Log governance event
|
|
await governanceService.logEvent('agent.revoked', request.actorId, request.targetId, request.scope, {
|
|
revocationId,
|
|
revocationType: request.revocationType,
|
|
reason: request.reason,
|
|
effect,
|
|
});
|
|
|
|
logger.info(`Agent revoked: ${request.targetId}`, {
|
|
actorId: request.actorId,
|
|
revocationType: request.revocationType,
|
|
});
|
|
|
|
return { success: true, revocationId };
|
|
} catch (error) {
|
|
await db.query('ROLLBACK');
|
|
throw error;
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to revoke agent', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Suspend an agent temporarily
|
|
*/
|
|
async suspendAgent(
|
|
actorId: string,
|
|
targetId: string,
|
|
reason: string,
|
|
scope: GovernanceScope,
|
|
durationHours?: number
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
const context = await buildContext(actorId, scope);
|
|
|
|
// Check permission
|
|
const canRevoke = await permissionEngine.canRevokeAgent(context, targetId);
|
|
if (!canRevoke.allowed) {
|
|
return { success: false, error: canRevoke.reason };
|
|
}
|
|
|
|
try {
|
|
await db.query(
|
|
`UPDATE agents
|
|
SET status = 'suspended',
|
|
revoked_at = now(),
|
|
revoked_by = $1,
|
|
revocation_reason = $2,
|
|
revocation_type = 'soft',
|
|
updated_at = now()
|
|
WHERE id = $3`,
|
|
[actorId, reason, targetId]
|
|
);
|
|
|
|
await governanceService.logEvent('agent.revoked', actorId, targetId, scope, {
|
|
action: 'suspended',
|
|
reason,
|
|
durationHours,
|
|
});
|
|
|
|
logger.info(`Agent suspended: ${targetId}`, { actorId, durationHours });
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Failed to suspend agent', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reinstate a revoked/suspended agent
|
|
*/
|
|
async reinstateAgent(
|
|
actorId: string,
|
|
targetId: string,
|
|
scope: GovernanceScope,
|
|
reason?: string
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
const context = await buildContext(actorId, scope);
|
|
|
|
// Check actor has sufficient level
|
|
if (context.actorLevel !== 'city_governance' && context.actorLevel !== 'district_lead') {
|
|
return { success: false, error: 'Only City Governance or District Lead can reinstate agents' };
|
|
}
|
|
|
|
try {
|
|
// Check if revocation is reversible
|
|
const revocation = await db.query<{ reversible: boolean; revocation_type: RevocationType }>(
|
|
`SELECT reversible, revocation_type FROM agent_revocations
|
|
WHERE agent_id = $1 AND reversed_at IS NULL
|
|
ORDER BY created_at DESC LIMIT 1`,
|
|
[targetId]
|
|
);
|
|
|
|
if (revocation.rows.length === 0) {
|
|
return { success: false, error: 'No active revocation found' };
|
|
}
|
|
|
|
if (!revocation.rows[0].reversible) {
|
|
return { success: false, error: 'This revocation is not reversible (hard revocation)' };
|
|
}
|
|
|
|
await db.query('BEGIN');
|
|
|
|
try {
|
|
// Restore agent status
|
|
await db.query(
|
|
`UPDATE agents
|
|
SET status = 'active',
|
|
revoked_at = NULL,
|
|
revoked_by = NULL,
|
|
revocation_reason = NULL,
|
|
revocation_type = NULL,
|
|
updated_at = now()
|
|
WHERE id = $1`,
|
|
[targetId]
|
|
);
|
|
|
|
// Mark revocation as reversed
|
|
await db.query(
|
|
`UPDATE agent_revocations
|
|
SET reversed_at = now(), reversed_by = $1
|
|
WHERE agent_id = $2 AND reversed_at IS NULL`,
|
|
[actorId, targetId]
|
|
);
|
|
|
|
await db.query('COMMIT');
|
|
|
|
await governanceService.logEvent('agent.reinstated', actorId, targetId, scope, {
|
|
reason,
|
|
});
|
|
|
|
logger.info(`Agent reinstated: ${targetId}`, { actorId });
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
await db.query('ROLLBACK');
|
|
throw error;
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to reinstate agent', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Revoke DAIS keys
|
|
*/
|
|
async revokeDaisKeys(
|
|
actorId: string,
|
|
daisId: string,
|
|
reason: string
|
|
): Promise<{ success: boolean; keysRevoked: number; error?: string }> {
|
|
try {
|
|
// Get actor level
|
|
const actorLevel = await permissionEngine.getAgentLevel(actorId);
|
|
|
|
// Only City Governance, District Lead, or Orchestrator can revoke keys
|
|
if (!['city_governance', 'district_lead', 'orchestrator'].includes(actorLevel)) {
|
|
return { success: false, keysRevoked: 0, error: 'Insufficient permissions to revoke keys' };
|
|
}
|
|
|
|
const result = await db.query(
|
|
`UPDATE dais_keys
|
|
SET revoked = true, revoked_at = now(), revoked_by = $1, revoked_reason = $2
|
|
WHERE dais_id = $3 AND revoked = false
|
|
RETURNING id`,
|
|
[actorId, reason, daisId]
|
|
);
|
|
|
|
logger.info(`DAIS keys revoked: ${daisId}`, { actorId, count: result.rowCount });
|
|
|
|
return { success: true, keysRevoked: result.rowCount || 0 };
|
|
} catch (error) {
|
|
logger.error('Failed to revoke DAIS keys', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get revocation history for an agent
|
|
*/
|
|
async getRevocationHistory(agentId: string): Promise<AgentRevocation[]> {
|
|
const result = await db.query<AgentRevocation>(
|
|
`SELECT * FROM agent_revocations WHERE agent_id = $1 ORDER BY created_at DESC`,
|
|
[agentId]
|
|
);
|
|
|
|
return result.rows;
|
|
}
|
|
|
|
/**
|
|
* Get revocation effect based on type
|
|
*/
|
|
private getRevocationEffect(type: RevocationType): RevocationEffect {
|
|
switch (type) {
|
|
case 'hard':
|
|
return {
|
|
daisKeysInvalidated: true,
|
|
walletSigningDisabled: true,
|
|
roomAccessRevoked: true,
|
|
nodePrivilegesRemoved: true,
|
|
assignmentsTerminated: true,
|
|
};
|
|
case 'soft':
|
|
return {
|
|
daisKeysInvalidated: false,
|
|
walletSigningDisabled: true,
|
|
roomAccessRevoked: true,
|
|
nodePrivilegesRemoved: true,
|
|
assignmentsTerminated: true,
|
|
};
|
|
case 'shadow':
|
|
return {
|
|
daisKeysInvalidated: false,
|
|
walletSigningDisabled: false,
|
|
roomAccessRevoked: false,
|
|
nodePrivilegesRemoved: false,
|
|
assignmentsTerminated: false,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
export const revocationService = new RevocationService();
|
|
|