feat(governance): Governance Engine MVP implementation
- 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
This commit is contained in:
291
backend/services/governance/audit.service.ts
Normal file
291
backend/services/governance/audit.service.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Audit Service
|
||||
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
|
||||
*
|
||||
* Provides access to governance events for audit and analysis
|
||||
*/
|
||||
|
||||
import { db } from '../../infra/db/client';
|
||||
import { logger } from '../../infra/logger/logger';
|
||||
import {
|
||||
GovernanceEvent,
|
||||
GovernanceEventType,
|
||||
AuditEventFilter,
|
||||
GovernanceScope,
|
||||
} from '../../domain/governance/types';
|
||||
|
||||
export interface AuditEventRow {
|
||||
id: string;
|
||||
event_type: GovernanceEventType;
|
||||
subject: string;
|
||||
actor_id: string;
|
||||
target_id: string;
|
||||
scope: GovernanceScope;
|
||||
payload: Record<string, unknown>;
|
||||
version: string;
|
||||
status: 'pending' | 'published' | 'failed';
|
||||
created_at: Date;
|
||||
published_at?: Date;
|
||||
}
|
||||
|
||||
export class AuditService {
|
||||
/**
|
||||
* Get audit events with filters
|
||||
*/
|
||||
async getEvents(filter: AuditEventFilter): Promise<{
|
||||
events: GovernanceEvent[];
|
||||
total: number;
|
||||
}> {
|
||||
const conditions: string[] = [];
|
||||
const params: (string | Date | number)[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filter.eventType) {
|
||||
conditions.push(`event_type = $${paramIndex++}`);
|
||||
params.push(filter.eventType);
|
||||
}
|
||||
|
||||
if (filter.actorId) {
|
||||
conditions.push(`actor_id = $${paramIndex++}`);
|
||||
params.push(filter.actorId);
|
||||
}
|
||||
|
||||
if (filter.targetId) {
|
||||
conditions.push(`target_id = $${paramIndex++}`);
|
||||
params.push(filter.targetId);
|
||||
}
|
||||
|
||||
if (filter.scope) {
|
||||
conditions.push(`scope = $${paramIndex++}`);
|
||||
params.push(filter.scope);
|
||||
}
|
||||
|
||||
if (filter.createdAtFrom) {
|
||||
conditions.push(`created_at >= $${paramIndex++}`);
|
||||
params.push(filter.createdAtFrom);
|
||||
}
|
||||
|
||||
if (filter.createdAtTo) {
|
||||
conditions.push(`created_at <= $${paramIndex++}`);
|
||||
params.push(filter.createdAtTo);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Get total count
|
||||
const countResult = await db.query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM event_outbox ${whereClause}`,
|
||||
params
|
||||
);
|
||||
|
||||
const total = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
// Get events
|
||||
const limit = filter.limit || 50;
|
||||
const offset = filter.offset || 0;
|
||||
|
||||
params.push(limit, offset);
|
||||
|
||||
const result = await db.query<AuditEventRow>(
|
||||
`SELECT id, event_type, subject, actor_id, target_id, scope, payload, version, status, created_at, published_at
|
||||
FROM event_outbox
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||
params
|
||||
);
|
||||
|
||||
const events: GovernanceEvent[] = result.rows.map(row => ({
|
||||
id: row.id,
|
||||
eventType: row.event_type,
|
||||
subject: row.subject,
|
||||
actorId: row.actor_id,
|
||||
targetId: row.target_id,
|
||||
scope: row.scope,
|
||||
payload: row.payload,
|
||||
version: row.version,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
publishedAt: row.published_at,
|
||||
}));
|
||||
|
||||
return { events, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single event by ID
|
||||
*/
|
||||
async getEvent(eventId: string): Promise<GovernanceEvent | null> {
|
||||
const result = await db.query<AuditEventRow>(
|
||||
`SELECT id, event_type, subject, actor_id, target_id, scope, payload, version, status, created_at, published_at
|
||||
FROM event_outbox
|
||||
WHERE id = $1`,
|
||||
[eventId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
id: row.id,
|
||||
eventType: row.event_type,
|
||||
subject: row.subject,
|
||||
actorId: row.actor_id,
|
||||
targetId: row.target_id,
|
||||
scope: row.scope,
|
||||
payload: row.payload,
|
||||
version: row.version,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
publishedAt: row.published_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events by actor
|
||||
*/
|
||||
async getEventsByActor(actorId: string, limit = 50): Promise<GovernanceEvent[]> {
|
||||
const result = await this.getEvents({ actorId, limit });
|
||||
return result.events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events by target
|
||||
*/
|
||||
async getEventsByTarget(targetId: string, limit = 50): Promise<GovernanceEvent[]> {
|
||||
const result = await this.getEvents({ targetId, limit });
|
||||
return result.events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events by scope
|
||||
*/
|
||||
async getEventsByScope(scope: GovernanceScope, limit = 50): Promise<GovernanceEvent[]> {
|
||||
const result = await this.getEvents({ scope, limit });
|
||||
return result.events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event statistics
|
||||
*/
|
||||
async getEventStats(
|
||||
fromDate?: Date,
|
||||
toDate?: Date
|
||||
): Promise<{
|
||||
totalEvents: number;
|
||||
eventsByType: Record<string, number>;
|
||||
eventsByDay: Array<{ date: string; count: number }>;
|
||||
topActors: Array<{ actorId: string; count: number }>;
|
||||
}> {
|
||||
const conditions: string[] = [];
|
||||
const params: (string | Date)[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (fromDate) {
|
||||
conditions.push(`created_at >= $${paramIndex++}`);
|
||||
params.push(fromDate);
|
||||
}
|
||||
|
||||
if (toDate) {
|
||||
conditions.push(`created_at <= $${paramIndex++}`);
|
||||
params.push(toDate);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Total events
|
||||
const totalResult = await db.query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM event_outbox ${whereClause}`,
|
||||
params
|
||||
);
|
||||
|
||||
const totalEvents = parseInt(totalResult.rows[0].count, 10);
|
||||
|
||||
// Events by type
|
||||
const byTypeResult = await db.query<{ event_type: string; count: string }>(
|
||||
`SELECT event_type, COUNT(*) as count
|
||||
FROM event_outbox ${whereClause}
|
||||
GROUP BY event_type`,
|
||||
params
|
||||
);
|
||||
|
||||
const eventsByType: Record<string, number> = {};
|
||||
byTypeResult.rows.forEach(row => {
|
||||
eventsByType[row.event_type] = parseInt(row.count, 10);
|
||||
});
|
||||
|
||||
// Events by day
|
||||
const byDayResult = await db.query<{ date: string; count: string }>(
|
||||
`SELECT DATE(created_at) as date, COUNT(*) as count
|
||||
FROM event_outbox ${whereClause}
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date DESC
|
||||
LIMIT 30`,
|
||||
params
|
||||
);
|
||||
|
||||
const eventsByDay = byDayResult.rows.map(row => ({
|
||||
date: row.date,
|
||||
count: parseInt(row.count, 10),
|
||||
}));
|
||||
|
||||
// Top actors
|
||||
const topActorsResult = await db.query<{ actor_id: string; count: string }>(
|
||||
`SELECT actor_id, COUNT(*) as count
|
||||
FROM event_outbox ${whereClause} AND actor_id IS NOT NULL
|
||||
GROUP BY actor_id
|
||||
ORDER BY count DESC
|
||||
LIMIT 10`,
|
||||
params
|
||||
);
|
||||
|
||||
const topActors = topActorsResult.rows.map(row => ({
|
||||
actorId: row.actor_id,
|
||||
count: parseInt(row.count, 10),
|
||||
}));
|
||||
|
||||
return {
|
||||
totalEvents,
|
||||
eventsByType,
|
||||
eventsByDay,
|
||||
topActors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get governance events for specific entity
|
||||
*/
|
||||
async getEntityHistory(
|
||||
entityType: 'agent' | 'microdao' | 'district' | 'node' | 'room',
|
||||
entityId: string,
|
||||
limit = 50
|
||||
): Promise<GovernanceEvent[]> {
|
||||
const result = await db.query<AuditEventRow>(
|
||||
`SELECT id, event_type, subject, actor_id, target_id, scope, payload, version, status, created_at, published_at
|
||||
FROM event_outbox
|
||||
WHERE target_id = $1 OR actor_id = $1 OR scope LIKE $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3`,
|
||||
[entityId, `%${entityId}%`, limit]
|
||||
);
|
||||
|
||||
return result.rows.map(row => ({
|
||||
id: row.id,
|
||||
eventType: row.event_type,
|
||||
subject: row.subject,
|
||||
actorId: row.actor_id,
|
||||
targetId: row.target_id,
|
||||
scope: row.scope,
|
||||
payload: row.payload,
|
||||
version: row.version,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
publishedAt: row.published_at,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export const auditService = new AuditService();
|
||||
|
||||
316
backend/services/governance/governance.service.ts
Normal file
316
backend/services/governance/governance.service.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Governance Service
|
||||
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
|
||||
*
|
||||
* Handles agent promotion, demotion, and role management
|
||||
*/
|
||||
|
||||
import { db } from '../../infra/db/client';
|
||||
import { logger } from '../../infra/logger/logger';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
AgentGovLevel,
|
||||
AgentStatus,
|
||||
PromoteAgentRequest,
|
||||
GovernanceEvent,
|
||||
GovernanceEventType,
|
||||
GovernanceScope,
|
||||
GOV_LEVEL_TO_NUM,
|
||||
} from '../../domain/governance/types';
|
||||
import { permissionEngine, buildContext } from './permissions';
|
||||
|
||||
export class GovernanceService {
|
||||
/**
|
||||
* Promote an agent to a new level
|
||||
*/
|
||||
async promoteAgent(request: PromoteAgentRequest): Promise<{ success: boolean; error?: string }> {
|
||||
const context = await buildContext(request.actorId, request.scope);
|
||||
|
||||
// Check permission
|
||||
const canPromote = await permissionEngine.canPromoteAgent(
|
||||
context,
|
||||
request.targetId,
|
||||
request.newLevel
|
||||
);
|
||||
|
||||
if (!canPromote.allowed) {
|
||||
logger.warn(`Promotion denied: ${request.actorId} → ${request.targetId}`, {
|
||||
reason: canPromote.reason,
|
||||
});
|
||||
return { success: false, error: canPromote.reason };
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current level for event
|
||||
const currentLevel = await permissionEngine.getAgentLevel(request.targetId);
|
||||
|
||||
// Update agent level
|
||||
await db.query(
|
||||
`UPDATE agents
|
||||
SET gov_level = $1, updated_at = now()
|
||||
WHERE id = $2`,
|
||||
[request.newLevel, request.targetId]
|
||||
);
|
||||
|
||||
// If promoting to orchestrator, update DAIS trust level
|
||||
if (request.newLevel === 'orchestrator' || request.newLevel === 'district_lead' || request.newLevel === 'city_governance') {
|
||||
await db.query(
|
||||
`UPDATE dais_identities
|
||||
SET trust_level = 'orchestrator', updated_at = now()
|
||||
WHERE id = (SELECT dais_identity_id FROM agents WHERE id = $1)`,
|
||||
[request.targetId]
|
||||
);
|
||||
}
|
||||
|
||||
// Log governance event
|
||||
await this.logEvent('agent.promoted', request.actorId, request.targetId, request.scope, {
|
||||
previousLevel: currentLevel,
|
||||
newLevel: request.newLevel,
|
||||
reason: request.reason,
|
||||
});
|
||||
|
||||
logger.info(`Agent promoted: ${request.targetId} → ${request.newLevel}`, {
|
||||
actorId: request.actorId,
|
||||
previousLevel: currentLevel,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Failed to promote agent', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Demote an agent to a lower level
|
||||
*/
|
||||
async demoteAgent(
|
||||
actorId: string,
|
||||
targetId: string,
|
||||
newLevel: AgentGovLevel,
|
||||
scope: GovernanceScope,
|
||||
reason?: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const context = await buildContext(actorId, scope);
|
||||
const targetLevel = await permissionEngine.getAgentLevel(targetId);
|
||||
const actorLevelNum = GOV_LEVEL_TO_NUM[context.actorLevel];
|
||||
const targetLevelNum = GOV_LEVEL_TO_NUM[targetLevel];
|
||||
const newLevelNum = GOV_LEVEL_TO_NUM[newLevel];
|
||||
|
||||
// Verify demotion is valid
|
||||
if (targetLevelNum >= actorLevelNum) {
|
||||
return { success: false, error: 'Cannot demote agent at same or higher level' };
|
||||
}
|
||||
|
||||
if (newLevelNum >= targetLevelNum) {
|
||||
return { success: false, error: 'New level must be lower than current level' };
|
||||
}
|
||||
|
||||
if (actorLevelNum < 4) {
|
||||
return { success: false, error: 'Only Core-team and above can demote agents' };
|
||||
}
|
||||
|
||||
try {
|
||||
await db.query(
|
||||
`UPDATE agents
|
||||
SET gov_level = $1, updated_at = now()
|
||||
WHERE id = $2`,
|
||||
[newLevel, targetId]
|
||||
);
|
||||
|
||||
// Update DAIS trust level if demoted from orchestrator
|
||||
if (['orchestrator', 'district_lead', 'city_governance'].includes(targetLevel)) {
|
||||
await db.query(
|
||||
`UPDATE dais_identities
|
||||
SET trust_level = 'verified', updated_at = now()
|
||||
WHERE id = (SELECT dais_identity_id FROM agents WHERE id = $1)`,
|
||||
[targetId]
|
||||
);
|
||||
}
|
||||
|
||||
await this.logEvent('agent.demoted', actorId, targetId, scope, {
|
||||
previousLevel: targetLevel,
|
||||
newLevel,
|
||||
reason,
|
||||
});
|
||||
|
||||
logger.info(`Agent demoted: ${targetId} → ${newLevel}`, { actorId });
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Failed to demote agent', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent roles and permissions
|
||||
*/
|
||||
async getAgentRoles(agentId: string): Promise<{
|
||||
level: AgentGovLevel;
|
||||
status: AgentStatus;
|
||||
powers: string[];
|
||||
assignments: Array<{
|
||||
microdaoId: string;
|
||||
role: string;
|
||||
scope: string;
|
||||
}>;
|
||||
}> {
|
||||
const level = await permissionEngine.getAgentLevel(agentId);
|
||||
const powers = permissionEngine.getPowersForLevel(level);
|
||||
|
||||
const agentData = await db.query<{ status: AgentStatus }>(
|
||||
`SELECT status FROM agents WHERE id = $1`,
|
||||
[agentId]
|
||||
);
|
||||
|
||||
const assignments = await db.query<{
|
||||
target_microdao_id: string;
|
||||
role: string;
|
||||
scope: string;
|
||||
}>(
|
||||
`SELECT target_microdao_id, role, scope
|
||||
FROM agent_assignments
|
||||
WHERE agent_id = $1 AND end_ts IS NULL`,
|
||||
[agentId]
|
||||
);
|
||||
|
||||
return {
|
||||
level,
|
||||
status: agentData.rows[0]?.status || 'active',
|
||||
powers,
|
||||
assignments: assignments.rows.map(a => ({
|
||||
microdaoId: a.target_microdao_id,
|
||||
role: a.role,
|
||||
scope: a.scope,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agents by governance level
|
||||
*/
|
||||
async getAgentsByLevel(level: AgentGovLevel, limit = 50): Promise<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
level: AgentGovLevel;
|
||||
status: AgentStatus;
|
||||
homeMicrodaoId?: string;
|
||||
}>> {
|
||||
const result = await db.query<{
|
||||
id: string;
|
||||
name: string;
|
||||
gov_level: AgentGovLevel;
|
||||
status: AgentStatus;
|
||||
home_microdao_id: string;
|
||||
}>(
|
||||
`SELECT id, name, gov_level, status, home_microdao_id
|
||||
FROM agents
|
||||
WHERE gov_level = $1 AND status = 'active'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $2`,
|
||||
[level, limit]
|
||||
);
|
||||
|
||||
return result.rows.map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
level: r.gov_level,
|
||||
status: r.status,
|
||||
homeMicrodaoId: r.home_microdao_id,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get city governance agents
|
||||
*/
|
||||
async getCityGovernanceAgents(): Promise<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}>> {
|
||||
const result = await db.query<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>(
|
||||
`SELECT id, name FROM agents
|
||||
WHERE gov_level = 'city_governance' AND status = 'active'`
|
||||
);
|
||||
|
||||
const roleMap: Record<string, string> = {
|
||||
daarwizz: 'Mayor',
|
||||
dario: 'Community',
|
||||
daria: 'Tech Governance',
|
||||
};
|
||||
|
||||
return result.rows.map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
role: roleMap[r.id] || 'City Agent',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get district lead agents
|
||||
*/
|
||||
async getDistrictLeadAgents(districtId?: string): Promise<Array<{
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
districtId: string;
|
||||
districtName: string;
|
||||
}>> {
|
||||
let query = `
|
||||
SELECT a.id as agent_id, a.name as agent_name,
|
||||
m.id as district_id, m.name as district_name
|
||||
FROM agents a
|
||||
JOIN microdaos m ON m.primary_orchestrator_agent_id = a.id
|
||||
WHERE m.dao_type = 'district' AND a.status = 'active'
|
||||
`;
|
||||
|
||||
const params: string[] = [];
|
||||
|
||||
if (districtId) {
|
||||
query += ` AND m.id = $1`;
|
||||
params.push(districtId);
|
||||
}
|
||||
|
||||
const result = await db.query<{
|
||||
agent_id: string;
|
||||
agent_name: string;
|
||||
district_id: string;
|
||||
district_name: string;
|
||||
}>(query, params);
|
||||
|
||||
return result.rows.map(r => ({
|
||||
agentId: r.agent_id,
|
||||
agentName: r.agent_name,
|
||||
districtId: r.district_id,
|
||||
districtName: r.district_name,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log governance event to event_outbox
|
||||
*/
|
||||
async logEvent(
|
||||
eventType: GovernanceEventType,
|
||||
actorId: string,
|
||||
targetId: string,
|
||||
scope: GovernanceScope,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const eventId = uuidv4();
|
||||
const subject = `dagion.governance.${eventType}`;
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO event_outbox (id, event_type, subject, actor_id, target_id, scope, payload, version)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, '1.0')`,
|
||||
[eventId, eventType, subject, actorId, targetId, scope, JSON.stringify(payload)]
|
||||
);
|
||||
|
||||
logger.debug(`Logged governance event: ${eventType}`, { eventId, actorId, targetId });
|
||||
}
|
||||
}
|
||||
|
||||
export const governanceService = new GovernanceService();
|
||||
|
||||
552
backend/services/governance/incidents.service.ts
Normal file
552
backend/services/governance/incidents.service.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
/**
|
||||
* Incidents Service
|
||||
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
|
||||
*
|
||||
* Handles incident creation, escalation, and resolution
|
||||
*/
|
||||
|
||||
import { db } from '../../infra/db/client';
|
||||
import { logger } from '../../infra/logger/logger';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
Incident,
|
||||
IncidentStatus,
|
||||
IncidentPriority,
|
||||
EscalationLevel,
|
||||
TargetScopeType,
|
||||
IncidentHistory,
|
||||
CreateIncidentRequest,
|
||||
AssignIncidentRequest,
|
||||
EscalateIncidentRequest,
|
||||
ResolveIncidentRequest,
|
||||
} from '../../domain/governance/types';
|
||||
import { governanceService } from './governance.service';
|
||||
|
||||
interface IncidentRow {
|
||||
id: string;
|
||||
created_by_dais_id: string;
|
||||
target_scope_type: TargetScopeType;
|
||||
target_scope_id: string;
|
||||
status: IncidentStatus;
|
||||
priority: IncidentPriority;
|
||||
assigned_to_dais_id: string | null;
|
||||
escalation_level: EscalationLevel;
|
||||
title: string;
|
||||
description: string | null;
|
||||
resolution: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
resolved_at: Date | null;
|
||||
closed_at: Date | null;
|
||||
}
|
||||
|
||||
export class IncidentsService {
|
||||
/**
|
||||
* Create a new incident
|
||||
*/
|
||||
async createIncident(request: CreateIncidentRequest): Promise<Incident> {
|
||||
const id = uuidv4();
|
||||
|
||||
// Determine initial escalation level based on target scope
|
||||
const escalationLevel = this.determineInitialEscalation(request.targetScopeType);
|
||||
|
||||
try {
|
||||
const result = await db.query<IncidentRow>(
|
||||
`INSERT INTO incidents (
|
||||
id, created_by_dais_id, target_scope_type, target_scope_id,
|
||||
status, priority, escalation_level, title, description, metadata
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *`,
|
||||
[
|
||||
id,
|
||||
request.createdByDaisId,
|
||||
request.targetScopeType,
|
||||
request.targetScopeId,
|
||||
'open',
|
||||
request.priority || 'medium',
|
||||
escalationLevel,
|
||||
request.title,
|
||||
request.description || null,
|
||||
JSON.stringify(request.metadata || {}),
|
||||
]
|
||||
);
|
||||
|
||||
const incident = this.mapRowToIncident(result.rows[0]);
|
||||
|
||||
// Log history
|
||||
await this.addHistory(id, 'created', request.createdByDaisId, null, {
|
||||
title: request.title,
|
||||
priority: request.priority || 'medium',
|
||||
escalationLevel,
|
||||
});
|
||||
|
||||
// Log governance event
|
||||
await governanceService.logEvent(
|
||||
'incident.created',
|
||||
request.createdByDaisId,
|
||||
id,
|
||||
`${request.targetScopeType}:${request.targetScopeId}`,
|
||||
{ title: request.title, priority: request.priority }
|
||||
);
|
||||
|
||||
logger.info(`Incident created: ${id}`, { title: request.title });
|
||||
|
||||
return incident;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create incident', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get incident by ID
|
||||
*/
|
||||
async getIncident(incidentId: string): Promise<Incident | null> {
|
||||
const result = await db.query<IncidentRow>(
|
||||
`SELECT * FROM incidents WHERE id = $1`,
|
||||
[incidentId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.mapRowToIncident(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* List incidents with filters
|
||||
*/
|
||||
async listIncidents(filters: {
|
||||
status?: IncidentStatus;
|
||||
priority?: IncidentPriority;
|
||||
escalationLevel?: EscalationLevel;
|
||||
targetScopeType?: TargetScopeType;
|
||||
targetScopeId?: string;
|
||||
assignedToDaisId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ incidents: Incident[]; total: number }> {
|
||||
const conditions: string[] = [];
|
||||
const params: (string | number)[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters.status) {
|
||||
conditions.push(`status = $${paramIndex++}`);
|
||||
params.push(filters.status);
|
||||
}
|
||||
|
||||
if (filters.priority) {
|
||||
conditions.push(`priority = $${paramIndex++}`);
|
||||
params.push(filters.priority);
|
||||
}
|
||||
|
||||
if (filters.escalationLevel) {
|
||||
conditions.push(`escalation_level = $${paramIndex++}`);
|
||||
params.push(filters.escalationLevel);
|
||||
}
|
||||
|
||||
if (filters.targetScopeType) {
|
||||
conditions.push(`target_scope_type = $${paramIndex++}`);
|
||||
params.push(filters.targetScopeType);
|
||||
}
|
||||
|
||||
if (filters.targetScopeId) {
|
||||
conditions.push(`target_scope_id = $${paramIndex++}`);
|
||||
params.push(filters.targetScopeId);
|
||||
}
|
||||
|
||||
if (filters.assignedToDaisId) {
|
||||
conditions.push(`assigned_to_dais_id = $${paramIndex++}`);
|
||||
params.push(filters.assignedToDaisId);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Get total count
|
||||
const countResult = await db.query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM incidents ${whereClause}`,
|
||||
params
|
||||
);
|
||||
|
||||
const total = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
// Get incidents
|
||||
const limit = filters.limit || 50;
|
||||
const offset = filters.offset || 0;
|
||||
|
||||
params.push(limit, offset);
|
||||
|
||||
const result = await db.query<IncidentRow>(
|
||||
`SELECT * FROM incidents ${whereClause}
|
||||
ORDER BY
|
||||
CASE priority
|
||||
WHEN 'critical' THEN 1
|
||||
WHEN 'high' THEN 2
|
||||
WHEN 'medium' THEN 3
|
||||
WHEN 'low' THEN 4
|
||||
END,
|
||||
created_at DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||
params
|
||||
);
|
||||
|
||||
const incidents = result.rows.map(row => this.mapRowToIncident(row));
|
||||
|
||||
return { incidents, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign incident to an agent
|
||||
*/
|
||||
async assignIncident(request: AssignIncidentRequest): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const incident = await this.getIncident(request.incidentId);
|
||||
|
||||
if (!incident) {
|
||||
return { success: false, error: 'Incident not found' };
|
||||
}
|
||||
|
||||
if (incident.status === 'closed') {
|
||||
return { success: false, error: 'Cannot assign closed incident' };
|
||||
}
|
||||
|
||||
const oldValue = { assignedToDaisId: incident.assignedToDaisId };
|
||||
|
||||
await db.query(
|
||||
`UPDATE incidents
|
||||
SET assigned_to_dais_id = $1,
|
||||
status = 'in_progress',
|
||||
updated_at = now()
|
||||
WHERE id = $2`,
|
||||
[request.assignedToDaisId, request.incidentId]
|
||||
);
|
||||
|
||||
await this.addHistory(
|
||||
request.incidentId,
|
||||
'assigned',
|
||||
request.actorDaisId,
|
||||
oldValue,
|
||||
{ assignedToDaisId: request.assignedToDaisId }
|
||||
);
|
||||
|
||||
await governanceService.logEvent(
|
||||
'incident.assigned',
|
||||
request.actorDaisId,
|
||||
request.incidentId,
|
||||
'city',
|
||||
{ assignedTo: request.assignedToDaisId }
|
||||
);
|
||||
|
||||
logger.info(`Incident assigned: ${request.incidentId} → ${request.assignedToDaisId}`);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Failed to assign incident', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escalate incident to higher level
|
||||
*/
|
||||
async escalateIncident(request: EscalateIncidentRequest): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const incident = await this.getIncident(request.incidentId);
|
||||
|
||||
if (!incident) {
|
||||
return { success: false, error: 'Incident not found' };
|
||||
}
|
||||
|
||||
if (incident.status === 'closed' || incident.status === 'resolved') {
|
||||
return { success: false, error: 'Cannot escalate resolved/closed incident' };
|
||||
}
|
||||
|
||||
const levelOrder: EscalationLevel[] = ['microdao', 'district', 'city'];
|
||||
const currentIndex = levelOrder.indexOf(incident.escalationLevel);
|
||||
const newIndex = levelOrder.indexOf(request.newLevel);
|
||||
|
||||
if (newIndex <= currentIndex) {
|
||||
return { success: false, error: 'Can only escalate to higher level' };
|
||||
}
|
||||
|
||||
const oldValue = { escalationLevel: incident.escalationLevel };
|
||||
|
||||
await db.query(
|
||||
`UPDATE incidents
|
||||
SET escalation_level = $1,
|
||||
updated_at = now()
|
||||
WHERE id = $2`,
|
||||
[request.newLevel, request.incidentId]
|
||||
);
|
||||
|
||||
await this.addHistory(
|
||||
request.incidentId,
|
||||
'escalated',
|
||||
request.actorDaisId,
|
||||
oldValue,
|
||||
{ escalationLevel: request.newLevel, reason: request.reason }
|
||||
);
|
||||
|
||||
await governanceService.logEvent(
|
||||
'incident.escalated',
|
||||
request.actorDaisId,
|
||||
request.incidentId,
|
||||
'city',
|
||||
{
|
||||
fromLevel: incident.escalationLevel,
|
||||
toLevel: request.newLevel,
|
||||
reason: request.reason,
|
||||
}
|
||||
);
|
||||
|
||||
logger.info(`Incident escalated: ${request.incidentId} → ${request.newLevel}`);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Failed to escalate incident', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve incident
|
||||
*/
|
||||
async resolveIncident(request: ResolveIncidentRequest): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const incident = await this.getIncident(request.incidentId);
|
||||
|
||||
if (!incident) {
|
||||
return { success: false, error: 'Incident not found' };
|
||||
}
|
||||
|
||||
if (incident.status === 'closed' || incident.status === 'resolved') {
|
||||
return { success: false, error: 'Incident already resolved/closed' };
|
||||
}
|
||||
|
||||
await db.query(
|
||||
`UPDATE incidents
|
||||
SET status = 'resolved',
|
||||
resolution = $1,
|
||||
resolved_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $2`,
|
||||
[request.resolution, request.incidentId]
|
||||
);
|
||||
|
||||
await this.addHistory(
|
||||
request.incidentId,
|
||||
'resolved',
|
||||
request.actorDaisId,
|
||||
{ status: incident.status },
|
||||
{ status: 'resolved', resolution: request.resolution }
|
||||
);
|
||||
|
||||
await governanceService.logEvent(
|
||||
'incident.resolved',
|
||||
request.actorDaisId,
|
||||
request.incidentId,
|
||||
'city',
|
||||
{ resolution: request.resolution }
|
||||
);
|
||||
|
||||
logger.info(`Incident resolved: ${request.incidentId}`);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Failed to resolve incident', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close incident
|
||||
*/
|
||||
async closeIncident(
|
||||
incidentId: string,
|
||||
actorDaisId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const incident = await this.getIncident(incidentId);
|
||||
|
||||
if (!incident) {
|
||||
return { success: false, error: 'Incident not found' };
|
||||
}
|
||||
|
||||
await db.query(
|
||||
`UPDATE incidents
|
||||
SET status = 'closed',
|
||||
closed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1`,
|
||||
[incidentId]
|
||||
);
|
||||
|
||||
await this.addHistory(
|
||||
incidentId,
|
||||
'closed',
|
||||
actorDaisId,
|
||||
{ status: incident.status },
|
||||
{ status: 'closed' }
|
||||
);
|
||||
|
||||
await governanceService.logEvent(
|
||||
'incident.closed',
|
||||
actorDaisId,
|
||||
incidentId,
|
||||
'city',
|
||||
{}
|
||||
);
|
||||
|
||||
logger.info(`Incident closed: ${incidentId}`);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Failed to close incident', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add comment to incident
|
||||
*/
|
||||
async addComment(
|
||||
incidentId: string,
|
||||
actorDaisId: string,
|
||||
comment: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await this.addHistory(incidentId, 'comment', actorDaisId, null, null, comment);
|
||||
|
||||
logger.info(`Comment added to incident: ${incidentId}`);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Failed to add comment to incident', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get incident history
|
||||
*/
|
||||
async getIncidentHistory(incidentId: string): Promise<IncidentHistory[]> {
|
||||
const result = await db.query<{
|
||||
id: string;
|
||||
incident_id: string;
|
||||
action: IncidentHistory['action'];
|
||||
actor_dais_id: string;
|
||||
old_value: Record<string, unknown> | null;
|
||||
new_value: Record<string, unknown> | null;
|
||||
comment: string | null;
|
||||
created_at: Date;
|
||||
}>(
|
||||
`SELECT * FROM incident_history WHERE incident_id = $1 ORDER BY created_at ASC`,
|
||||
[incidentId]
|
||||
);
|
||||
|
||||
return result.rows.map(row => ({
|
||||
id: row.id,
|
||||
incidentId: row.incident_id,
|
||||
action: row.action,
|
||||
actorDaisId: row.actor_dais_id,
|
||||
oldValue: row.old_value || undefined,
|
||||
newValue: row.new_value || undefined,
|
||||
comment: row.comment || undefined,
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get open incidents count by escalation level
|
||||
*/
|
||||
async getOpenIncidentsCount(): Promise<{
|
||||
microdao: number;
|
||||
district: number;
|
||||
city: number;
|
||||
total: number;
|
||||
}> {
|
||||
const result = await db.query<{ escalation_level: EscalationLevel; count: string }>(
|
||||
`SELECT escalation_level, COUNT(*) as count
|
||||
FROM incidents
|
||||
WHERE status IN ('open', 'in_progress')
|
||||
GROUP BY escalation_level`
|
||||
);
|
||||
|
||||
const counts = { microdao: 0, district: 0, city: 0, total: 0 };
|
||||
|
||||
result.rows.forEach(row => {
|
||||
counts[row.escalation_level] = parseInt(row.count, 10);
|
||||
counts.total += parseInt(row.count, 10);
|
||||
});
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine initial escalation level based on target scope
|
||||
*/
|
||||
private determineInitialEscalation(targetScopeType: TargetScopeType): EscalationLevel {
|
||||
switch (targetScopeType) {
|
||||
case 'city':
|
||||
return 'city';
|
||||
case 'district':
|
||||
return 'district';
|
||||
default:
|
||||
return 'microdao';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add entry to incident history
|
||||
*/
|
||||
private async addHistory(
|
||||
incidentId: string,
|
||||
action: IncidentHistory['action'],
|
||||
actorDaisId: string,
|
||||
oldValue: Record<string, unknown> | null,
|
||||
newValue: Record<string, unknown> | null,
|
||||
comment?: string
|
||||
): Promise<void> {
|
||||
await db.query(
|
||||
`INSERT INTO incident_history (incident_id, action, actor_dais_id, old_value, new_value, comment)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[
|
||||
incidentId,
|
||||
action,
|
||||
actorDaisId,
|
||||
oldValue ? JSON.stringify(oldValue) : null,
|
||||
newValue ? JSON.stringify(newValue) : null,
|
||||
comment || null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map database row to Incident type
|
||||
*/
|
||||
private mapRowToIncident(row: IncidentRow): Incident {
|
||||
return {
|
||||
id: row.id,
|
||||
createdByDaisId: row.created_by_dais_id,
|
||||
targetScopeType: row.target_scope_type,
|
||||
targetScopeId: row.target_scope_id,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
assignedToDaisId: row.assigned_to_dais_id || undefined,
|
||||
escalationLevel: row.escalation_level,
|
||||
title: row.title,
|
||||
description: row.description || undefined,
|
||||
resolution: row.resolution || undefined,
|
||||
metadata: row.metadata,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
resolvedAt: row.resolved_at || undefined,
|
||||
closedAt: row.closed_at || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const incidentsService = new IncidentsService();
|
||||
|
||||
621
backend/services/governance/permissions.ts
Normal file
621
backend/services/governance/permissions.ts
Normal file
@@ -0,0 +1,621 @@
|
||||
/**
|
||||
* Permission Engine
|
||||
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
|
||||
*
|
||||
* Implements permission checks for all governance actions
|
||||
*/
|
||||
|
||||
import { db } from '../../infra/db/client';
|
||||
import { logger } from '../../infra/logger/logger';
|
||||
import {
|
||||
AgentGovLevel,
|
||||
AgentStatus,
|
||||
GovernancePower,
|
||||
GovernanceScope,
|
||||
GovernanceContext,
|
||||
CanDoResult,
|
||||
GOV_LEVEL_TO_NUM,
|
||||
POWER_MATRIX,
|
||||
TargetType,
|
||||
PermissionAction,
|
||||
} from '../../domain/governance/types';
|
||||
|
||||
// City governance agent IDs
|
||||
const CITY_AGENTS = ['daarwizz', 'dario', 'daria'];
|
||||
|
||||
/**
|
||||
* Get agent's governance level
|
||||
*/
|
||||
export async function getAgentLevel(agentId: string): Promise<AgentGovLevel> {
|
||||
const result = await db.query<{ gov_level: AgentGovLevel; status: AgentStatus }>(
|
||||
`SELECT gov_level, status FROM agents WHERE id = $1`,
|
||||
[agentId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return 'guest';
|
||||
}
|
||||
|
||||
if (result.rows[0].status === 'revoked') {
|
||||
return 'guest';
|
||||
}
|
||||
|
||||
return result.rows[0].gov_level || 'personal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get powers for a governance level
|
||||
*/
|
||||
export function getPowersForLevel(level: AgentGovLevel): GovernancePower[] {
|
||||
const entry = POWER_MATRIX.find(p => p.level === level);
|
||||
return entry?.powers || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build governance context for an actor
|
||||
*/
|
||||
export async function buildContext(
|
||||
actorAgentId: string,
|
||||
scope: GovernanceScope
|
||||
): Promise<GovernanceContext> {
|
||||
const level = await getAgentLevel(actorAgentId);
|
||||
const powers = getPowersForLevel(level);
|
||||
|
||||
// Get DAIS ID for actor
|
||||
const agent = await db.query<{ dais_identity_id: string }>(
|
||||
`SELECT dais_identity_id FROM agents WHERE id = $1`,
|
||||
[actorAgentId]
|
||||
);
|
||||
|
||||
const daisId = agent.rows[0]?.dais_identity_id || actorAgentId;
|
||||
|
||||
// Parse scope
|
||||
let microdaoId: string | undefined;
|
||||
let districtId: string | undefined;
|
||||
|
||||
if (scope.startsWith('microdao:')) {
|
||||
microdaoId = scope.replace('microdao:', '');
|
||||
} else if (scope.startsWith('district:')) {
|
||||
districtId = scope.replace('district:', '');
|
||||
}
|
||||
|
||||
return {
|
||||
actorDaisId: daisId,
|
||||
actorAgentId,
|
||||
actorLevel: level,
|
||||
actorPowers: powers,
|
||||
currentScope: scope,
|
||||
microdaoId,
|
||||
districtId,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PERMISSION CHECKS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if agent can create a MicroDAO
|
||||
* Only Orchestrator (Level 5+) with verified DAIS
|
||||
*/
|
||||
export async function canCreateMicrodao(
|
||||
context: GovernanceContext
|
||||
): Promise<CanDoResult> {
|
||||
const levelNum = GOV_LEVEL_TO_NUM[context.actorLevel];
|
||||
|
||||
if (levelNum < 5) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Only Orchestrators (Level 5+) can create MicroDAO',
|
||||
requiredLevel: 'orchestrator',
|
||||
};
|
||||
}
|
||||
|
||||
// Check DAIS trust level
|
||||
const dais = await db.query<{ trust_level: string }>(
|
||||
`SELECT trust_level FROM dais_identities WHERE id = $1`,
|
||||
[context.actorDaisId]
|
||||
);
|
||||
|
||||
if (dais.rows.length === 0 || !['orchestrator', 'operator'].includes(dais.rows[0].trust_level)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'DAIS must have orchestrator or operator trust level',
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if agent can create a District
|
||||
* Only City Governance (Level 7) or approved Orchestrator
|
||||
*/
|
||||
export async function canCreateDistrict(
|
||||
context: GovernanceContext
|
||||
): Promise<CanDoResult> {
|
||||
const levelNum = GOV_LEVEL_TO_NUM[context.actorLevel];
|
||||
|
||||
// City governance can always create
|
||||
if (levelNum === 7) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Orchestrator needs city approval
|
||||
if (levelNum >= 5) {
|
||||
// Check if there's a pending/approved district request
|
||||
const approval = await db.query(
|
||||
`SELECT id FROM event_outbox
|
||||
WHERE event_type = 'district.creation_approved'
|
||||
AND payload->>'requestedBy' = $1
|
||||
AND status = 'published'
|
||||
LIMIT 1`,
|
||||
[context.actorAgentId]
|
||||
);
|
||||
|
||||
if (approval.rows.length > 0) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Orchestrator needs city approval to create District',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Only City Governance can create Districts',
|
||||
requiredLevel: 'city_governance',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if agent can register a node
|
||||
* Orchestrator, Core-team DevOps, Node Manager, City Infrastructure
|
||||
*/
|
||||
export async function canRegisterNode(
|
||||
context: GovernanceContext
|
||||
): Promise<CanDoResult> {
|
||||
const levelNum = GOV_LEVEL_TO_NUM[context.actorLevel];
|
||||
|
||||
// Core-team and above can register
|
||||
if (levelNum >= 4) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Check for Node Manager role in assignments
|
||||
const assignment = await db.query(
|
||||
`SELECT id FROM agent_assignments
|
||||
WHERE agent_id = $1
|
||||
AND role IN ('devops', 'node-manager')
|
||||
AND end_ts IS NULL`,
|
||||
[context.actorAgentId]
|
||||
);
|
||||
|
||||
if (assignment.rows.length > 0) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Only Core-team, DevOps, or Node Managers can register nodes',
|
||||
requiredLevel: 'core_team',
|
||||
requiredPower: 'infrastructure',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if agent can create a room
|
||||
*/
|
||||
export async function canCreateRoom(
|
||||
context: GovernanceContext,
|
||||
roomType: string
|
||||
): Promise<CanDoResult> {
|
||||
const levelNum = GOV_LEVEL_TO_NUM[context.actorLevel];
|
||||
|
||||
switch (roomType) {
|
||||
case 'personal':
|
||||
// Personal agents can create personal rooms
|
||||
if (levelNum >= 1) return { allowed: true };
|
||||
break;
|
||||
|
||||
case 'project':
|
||||
// Workers can create project rooms
|
||||
if (levelNum >= 3) return { allowed: true };
|
||||
break;
|
||||
|
||||
case 'dao-room':
|
||||
case 'dao-wide':
|
||||
// Core-team can create DAO-wide rooms
|
||||
if (levelNum >= 4) return { allowed: true };
|
||||
break;
|
||||
|
||||
case 'front-room':
|
||||
case 'portal':
|
||||
// Orchestrator can create front-rooms and portals
|
||||
if (levelNum >= 5) return { allowed: true };
|
||||
break;
|
||||
|
||||
case 'city-room':
|
||||
// City agents only
|
||||
if (levelNum === 7) return { allowed: true };
|
||||
break;
|
||||
|
||||
case 'district-room':
|
||||
// District lead or higher
|
||||
if (levelNum >= 6) return { allowed: true };
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Insufficient permissions to create ${roomType} room`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if agent can create a front-portal in city
|
||||
*/
|
||||
export async function canCreateFrontPortal(
|
||||
context: GovernanceContext,
|
||||
targetMicrodaoId: string
|
||||
): Promise<CanDoResult> {
|
||||
const levelNum = GOV_LEVEL_TO_NUM[context.actorLevel];
|
||||
|
||||
// City agents can create any portal
|
||||
if (levelNum === 7) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// District lead can create portals for their district
|
||||
if (levelNum === 6) {
|
||||
// Check if target is in their district
|
||||
const district = await db.query(
|
||||
`SELECT id FROM microdaos
|
||||
WHERE id = $1
|
||||
AND parent_microdao_id = (
|
||||
SELECT id FROM microdaos WHERE primary_orchestrator_agent_id = $2 AND dao_type = 'district'
|
||||
)`,
|
||||
[targetMicrodaoId, context.actorAgentId]
|
||||
);
|
||||
|
||||
if (district.rows.length > 0) {
|
||||
return { allowed: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Orchestrator can create portal for their own MicroDAO
|
||||
if (levelNum === 5) {
|
||||
const microdao = await db.query(
|
||||
`SELECT id FROM microdaos
|
||||
WHERE id = $1 AND primary_orchestrator_agent_id = $2`,
|
||||
[targetMicrodaoId, context.actorAgentId]
|
||||
);
|
||||
|
||||
if (microdao.rows.length > 0) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Orchestrator can only create portal for their own MicroDAO',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Only Orchestrators and above can create front-portals',
|
||||
requiredLevel: 'orchestrator',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if actor can promote target agent
|
||||
*/
|
||||
export async function canPromoteAgent(
|
||||
context: GovernanceContext,
|
||||
targetId: string,
|
||||
newLevel: AgentGovLevel
|
||||
): Promise<CanDoResult> {
|
||||
const actorLevelNum = GOV_LEVEL_TO_NUM[context.actorLevel];
|
||||
const newLevelNum = GOV_LEVEL_TO_NUM[newLevel];
|
||||
const targetLevel = await getAgentLevel(targetId);
|
||||
const targetLevelNum = GOV_LEVEL_TO_NUM[targetLevel];
|
||||
|
||||
// Cannot promote to same or higher level than self
|
||||
if (newLevelNum >= actorLevelNum) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Cannot promote agent to same or higher level than yourself',
|
||||
};
|
||||
}
|
||||
|
||||
// Cannot promote agent already at higher level
|
||||
if (targetLevelNum >= newLevelNum) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Target agent is already at this level or higher',
|
||||
};
|
||||
}
|
||||
|
||||
// Only core-team and above can promote
|
||||
if (actorLevelNum < 4) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Only Core-team and above can promote agents',
|
||||
requiredLevel: 'core_team',
|
||||
};
|
||||
}
|
||||
|
||||
// Check scope - can only promote in own DAO/District
|
||||
if (context.currentScope.startsWith('microdao:')) {
|
||||
const microdaoId = context.currentScope.replace('microdao:', '');
|
||||
|
||||
// Check if actor is orchestrator of this DAO
|
||||
const isOrchestrator = await db.query(
|
||||
`SELECT id FROM microdaos
|
||||
WHERE id = $1 AND primary_orchestrator_agent_id = $2`,
|
||||
[microdaoId, context.actorAgentId]
|
||||
);
|
||||
|
||||
// Check if target is in this DAO
|
||||
const targetInDao = await db.query(
|
||||
`SELECT id FROM agent_assignments
|
||||
WHERE agent_id = $1 AND target_microdao_id = $2 AND end_ts IS NULL`,
|
||||
[targetId, microdaoId]
|
||||
);
|
||||
|
||||
if (isOrchestrator.rows.length === 0 && actorLevelNum < 6) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Only the DAO Orchestrator can promote agents in this DAO',
|
||||
};
|
||||
}
|
||||
|
||||
if (targetInDao.rows.length === 0 && actorLevelNum < 7) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Target agent is not a member of this DAO',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if actor can revoke target agent
|
||||
*/
|
||||
export async function canRevokeAgent(
|
||||
context: GovernanceContext,
|
||||
targetId: string
|
||||
): Promise<CanDoResult> {
|
||||
const actorLevelNum = GOV_LEVEL_TO_NUM[context.actorLevel];
|
||||
const targetLevel = await getAgentLevel(targetId);
|
||||
const targetLevelNum = GOV_LEVEL_TO_NUM[targetLevel];
|
||||
|
||||
// Cannot revoke same or higher level
|
||||
if (targetLevelNum >= actorLevelNum) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Cannot revoke agent at same or higher level',
|
||||
};
|
||||
}
|
||||
|
||||
// Must have identity power
|
||||
if (!context.actorPowers.includes('identity')) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Requires identity power to revoke agents',
|
||||
requiredPower: 'identity',
|
||||
};
|
||||
}
|
||||
|
||||
// City governance can revoke anyone
|
||||
if (actorLevelNum === 7) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// District lead can revoke in their district
|
||||
if (actorLevelNum === 6) {
|
||||
// Check if target is in actor's district
|
||||
const inDistrict = await db.query(
|
||||
`SELECT a.id FROM agents a
|
||||
JOIN agent_assignments aa ON a.id = aa.agent_id
|
||||
JOIN microdaos m ON aa.target_microdao_id = m.id
|
||||
WHERE a.id = $1
|
||||
AND m.parent_microdao_id = (
|
||||
SELECT id FROM microdaos WHERE primary_orchestrator_agent_id = $2 AND dao_type = 'district'
|
||||
)`,
|
||||
[targetId, context.actorAgentId]
|
||||
);
|
||||
|
||||
if (inDistrict.rows.length > 0) {
|
||||
return { allowed: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Orchestrator can revoke in their DAO
|
||||
if (actorLevelNum === 5 && context.currentScope.startsWith('microdao:')) {
|
||||
const microdaoId = context.currentScope.replace('microdao:', '');
|
||||
|
||||
const isOrchestrator = await db.query(
|
||||
`SELECT id FROM microdaos
|
||||
WHERE id = $1 AND primary_orchestrator_agent_id = $2`,
|
||||
[microdaoId, context.actorAgentId]
|
||||
);
|
||||
|
||||
const targetInDao = await db.query(
|
||||
`SELECT id FROM agent_assignments
|
||||
WHERE agent_id = $1 AND target_microdao_id = $2 AND end_ts IS NULL`,
|
||||
[targetId, microdaoId]
|
||||
);
|
||||
|
||||
if (isOrchestrator.rows.length > 0 && targetInDao.rows.length > 0) {
|
||||
return { allowed: true };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Insufficient permissions to revoke this agent',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if actor can moderate a room
|
||||
*/
|
||||
export async function canModerateRoom(
|
||||
context: GovernanceContext,
|
||||
roomId: string
|
||||
): Promise<CanDoResult> {
|
||||
const levelNum = GOV_LEVEL_TO_NUM[context.actorLevel];
|
||||
|
||||
// Must have moderation power
|
||||
if (!context.actorPowers.includes('moderation')) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Requires moderation power',
|
||||
requiredPower: 'moderation',
|
||||
};
|
||||
}
|
||||
|
||||
// Get room info
|
||||
const room = await db.query<{
|
||||
owner_type: string;
|
||||
owner_id: string;
|
||||
type: string;
|
||||
space_scope: string;
|
||||
}>(
|
||||
`SELECT owner_type, owner_id, type, space_scope FROM rooms WHERE id = $1`,
|
||||
[roomId]
|
||||
);
|
||||
|
||||
if (room.rows.length === 0) {
|
||||
return { allowed: false, reason: 'Room not found' };
|
||||
}
|
||||
|
||||
const roomData = room.rows[0];
|
||||
|
||||
// City governance can moderate any room
|
||||
if (levelNum === 7) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// City rooms - only city agents
|
||||
if (roomData.type === 'city-room') {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Only City Governance can moderate city rooms',
|
||||
requiredLevel: 'city_governance',
|
||||
};
|
||||
}
|
||||
|
||||
// District rooms - district lead or higher
|
||||
if (roomData.type === 'district-room') {
|
||||
if (levelNum >= 6) {
|
||||
// Check if actor is lead of this district
|
||||
const isLead = await db.query(
|
||||
`SELECT id FROM microdaos
|
||||
WHERE id = $1 AND primary_orchestrator_agent_id = $2 AND dao_type = 'district'`,
|
||||
[roomData.owner_id, context.actorAgentId]
|
||||
);
|
||||
|
||||
if (isLead.rows.length > 0) {
|
||||
return { allowed: true };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Only District Lead can moderate this room',
|
||||
requiredLevel: 'district_lead',
|
||||
};
|
||||
}
|
||||
|
||||
// DAO rooms - check if actor has role in this DAO
|
||||
if (roomData.space_scope === 'microdao') {
|
||||
const assignment = await db.query(
|
||||
`SELECT role FROM agent_assignments
|
||||
WHERE agent_id = $1 AND target_microdao_id = $2 AND end_ts IS NULL`,
|
||||
[context.actorAgentId, roomData.owner_id]
|
||||
);
|
||||
|
||||
if (assignment.rows.length > 0) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Must be a member of this DAO to moderate its rooms',
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check explicit permission in database
|
||||
*/
|
||||
export async function hasExplicitPermission(
|
||||
daisId: string,
|
||||
targetType: TargetType,
|
||||
targetId: string,
|
||||
action: PermissionAction
|
||||
): Promise<boolean> {
|
||||
const result = await db.query(
|
||||
`SELECT id FROM permissions
|
||||
WHERE dais_id = $1
|
||||
AND target_type = $2
|
||||
AND target_id = $3
|
||||
AND action = $4
|
||||
AND (expires_at IS NULL OR expires_at > now())`,
|
||||
[daisId, targetType, targetId, action]
|
||||
);
|
||||
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if agent is a city governance agent
|
||||
*/
|
||||
export function isCityAgent(agentId: string): boolean {
|
||||
return CITY_AGENTS.includes(agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log permission check for audit
|
||||
*/
|
||||
export async function logPermissionCheck(
|
||||
actorId: string,
|
||||
action: string,
|
||||
targetId: string,
|
||||
result: CanDoResult
|
||||
): Promise<void> {
|
||||
logger.info(`Permission check: ${actorId} → ${action} → ${targetId}: ${result.allowed ? 'ALLOWED' : 'DENIED'}`, {
|
||||
actorId,
|
||||
action,
|
||||
targetId,
|
||||
allowed: result.allowed,
|
||||
reason: result.reason,
|
||||
});
|
||||
}
|
||||
|
||||
export const permissionEngine = {
|
||||
getAgentLevel,
|
||||
getPowersForLevel,
|
||||
buildContext,
|
||||
canCreateMicrodao,
|
||||
canCreateDistrict,
|
||||
canRegisterNode,
|
||||
canCreateRoom,
|
||||
canCreateFrontPortal,
|
||||
canPromoteAgent,
|
||||
canRevokeAgent,
|
||||
canModerateRoom,
|
||||
hasExplicitPermission,
|
||||
isCityAgent,
|
||||
logPermissionCheck,
|
||||
};
|
||||
|
||||
350
backend/services/governance/revocation.service.ts
Normal file
350
backend/services/governance/revocation.service.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* 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();
|
||||
|
||||
Reference in New Issue
Block a user