mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:31:29 +00:00
0725713e19
Operator decision answered as full soft-delete with optional forced
cascade — hard-delete is not reachable from any public surface. Prior
to this commit, DELETE /agents/{id} ran a plain `DELETE FROM agents`
whose schema-level `ON DELETE CASCADE` on deployment_targets.agent_id
silently wiped every target, orphaning certs and aborting in-flight
jobs. The finding closure reshapes the agent-removal contract around
soft retirement with explicit preflight counts, an opt-in cascade
gated by a mandatory reason, and unconditional protection for the
four reserved sentinel agents used by discovery sources.
Schema — migration 000015:
migrations/000015_agent_retire.up.sql flips
deployment_targets_agent_id_fkey from ON DELETE CASCADE to ON DELETE
RESTRICT, so a stray `DELETE FROM agents` now errors at the DB
boundary instead of quietly destroying targets. Both `agents` and
`deployment_targets` grow a retired_at TIMESTAMPTZ + retired_reason
TEXT pair (TEXT not VARCHAR so operator comments are never
truncated), indexed via partial indexes WHERE retired_at IS NOT
NULL. The migration is self-healing (ADD COLUMN IF NOT EXISTS, DROP
CONSTRAINT IF EXISTS then ADD CONSTRAINT, CREATE INDEX IF NOT
EXISTS) so repeated runs against partially-migrated databases
converge. migrations/000015_agent_retire.down.sql restores CASCADE
and drops the new columns for clean rollback. A dedicated
repository-layer testcontainers test
(internal/repository/postgres/migration_000015_test.go) asserts the
before/after FK action, column presence, index presence, and
round-trip idempotency under up→down→up.
Domain — sentinel guard + dependency counts:
internal/domain/connector.go gains IsRetired() on Agent, the
exported SentinelAgentIDs slice listing server-scanner,
cloud-aws-sm, cloud-azure-kv, cloud-gcp-sm verbatim (matching the
four reserved IDs documented in CLAUDE.md and created at startup in
cmd/server/main.go), IsSentinelAgent(id string) predicate,
AgentDependencyCounts{ActiveTargets, ActiveCertificates,
PendingJobs} with a HasDependencies() method, and ActorTypeAgent /
ActorTypeSystem enum values used by audit emission downstream.
Coverage locked down by internal/domain/connector_test.go.
Service — 8-step ordered contract:
internal/service/agent_retire.go:RetireAgent(ctx, id, actor,
opts{Force, Reason}) enforces a fixed execution order:
(1) sentinel guard — IsSentinelAgent(id) returns ErrAgentIsSentinel
unconditionally; force=true does NOT bypass it.
(2) fetch — ErrAgentNotFound on miss.
(3) idempotency — if IsRetired() already, return
AgentRetirementResult{AlreadyRetired: true} with no new audit
event and no state change (safe to replay from flaky clients).
(4) preflight counts — collectAgentDependencyCounts runs
ActiveTargets, ActiveCertificates, PendingJobs sequentially
(not in parallel; keeps the per-query timeout predictable and
matches the repo's existing call-chain shape).
(5) force-reason guard — opts.Force=true with empty Reason returns
ErrForceReasonRequired (wired into the 400 status surface).
(6) dependency guard — HasDependencies() with opts.Force=false
returns BlockedByDependenciesError{Counts} (wired into the 409
body with per-bucket counts).
(7) mutation — single pinned retiredAt := time.Now(); agent
retirement first, then cascade target retirement if opts.Force,
all under the repo's single transaction so the two retired_at
stamps match to the second.
(8) best-effort audit — agent_retired always; agent_retirement_
cascaded additionally on the force path. Actor is whatever the
handler resolves from the request; actor type is mapped by
resolveActorType (system/agent-prefix→Agent/else→User). Audit
emission failures are logged via slog.Error but do not abort
the retirement (matches the house convention used by every
other scheduler-emitted event).
BlockedByDependenciesError implements Error() as
"active_targets=%d, active_certificates=%d, pending_jobs=%d" and
Unwrap() → ErrBlockedByDependencies. The single struct satisfies
errors.Is via Unwrap (used by scheduler-level tests) and errors.As
via the concrete type (used by the handler to fish out Counts for
the 409 body). ListRetiredAgents(page, perPage) adds a separate
paginated accessor with page<1→1 and perPage<1→50 normalization so
retired rows are queryable without polluting the default agent
listing.
Sentinel guard coverage is asymmetric by design: all four reserved
IDs are protected, and force=true cannot override. Regression tests
in internal/service/agent_retire_test.go assert each of the eight
steps in order, plus sentinel bypass attempts and idempotency
replay.
Handler + router — status-code surface:
internal/api/handler/agents.go:RetireAgent exposes seven status
codes on DELETE /agents/{id}:
200 on a fresh retirement (body echoes AgentRetirementResult).
204 on idempotent replay (AlreadyRetired=true; no new audit).
400 on ErrForceReasonRequired.
403 on ErrAgentIsSentinel.
404 on ErrAgentNotFound.
409 on BlockedByDependenciesError, with a custom body shape
{error, counts{active_targets, active_certificates,
pending_jobs}} that bypasses the default ErrorWithRequestID
envelope so callers get the per-bucket numbers directly.
500 on any other error.
Heartbeat HandleHeartbeat returns 410 Gone when the agent is
retired (ErrAgentRetired), signalling the agent to shut down.
Query params `force=true` and `reason=<text>` drive the cascade
path; both are forwarded as url.Values through the new MCP
transport.
internal/api/router/router.go registers GET /api/v1/agents/retired
literal-path BEFORE /api/v1/agents/{id} — Go 1.22 ServeMux's
literal-beats-pattern-var precedence routes "retired" to the
paginated retired-agents listing instead of fetching a hypothetical
agent named "retired".
Agent binary — clean shutdown on 410:
cmd/agent/main.go gains the ErrAgentRetired sentinel, a
retiredOnce sync.Once, and a retiredSignal chan struct{}. A
markRetired(source, statusCode, body) helper closes the channel
exactly once; the Run() select loop observes the close and returns
ErrAgentRetired; main() matches via errors.Is(err, ErrAgentRetired)
and exits cleanly instead of spinning in the heartbeat retry loop.
The 410 Gone surface is therefore terminal for the agent process.
MCP transport:
internal/mcp/client.go adds Client.DeleteWithQuery(path, query),
a new additive transport method. Client.Delete is path-only; without
this method the retire tool would silently drop `force` and `reason`,
turning every cascade retire into a default soft-retire. The new
method shares do()'s 204 normalization and 4xx/5xx error
propagation so tool authors get one contract.
internal/mcp/tools.go + internal/mcp/types.go expose the
retire_agent tool with Force+Reason inputs wired through
DeleteWithQuery.
CLI:
cmd/cli/main.go + internal/cli/client.go add two CLI surfaces:
`agents list --retired` (client-side strip of --retired then
delegation to ListRetiredAgents, sharing --page/--per-page parsing
with the default listing) and `agents retire <id> [--force --reason
"…"]` (mirrors ErrForceReasonRequired — force without reason is
rejected client-side before the request is sent). JSON + table
output modes both honor the new columns.
Frontend:
web/src/pages/AgentsPage.tsx surfaces retired/retire affordances.
web/src/api/client.ts + web/src/api/types.ts expose the retire
endpoint and the retired-listing. 4 new Vitest regression cases.
OpenAPI:
api/openapi.yaml documents DELETE /agents/{id} with all seven
status codes, 410 on heartbeat, and the 409 per-bucket body shape.
Regression coverage (six new test files, all green):
internal/service/agent_retire_test.go — 8-step contract + sentinel guards
internal/api/handler/agent_retire_handler_test.go — 7-status-code surface + 410 heartbeat
internal/mcp/retire_agent_test.go — DeleteWithQuery wire-through
internal/cli/agent_retire_test.go — --retired listing + --force/--reason pairing
internal/repository/postgres/migration_000015_test.go — FK flip + columns + indexes + up↔down
internal/domain/connector_test.go — IsRetired, IsSentinelAgent, SentinelAgentIDs, HasDependencies
Files:
api/openapi.yaml — DELETE + 410 + 409 body shape
cmd/agent/main.go — ErrAgentRetired, markRetired, retiredSignal
cmd/cli/main.go — handleAgents list/get/retire dispatch
docs/architecture.md, docs/concepts.md,
docs/testing-guide.md — retirement contract narrative
internal/api/handler/agents.go — RetireAgent, status surface, 410 on heartbeat
internal/api/handler/agent_handler_test.go — extended coverage
internal/api/handler/agent_retire_handler_test.go — new
internal/api/router/router.go — /agents/retired before /agents/{id}
internal/cli/agent_retire_test.go — new
internal/cli/client.go — ListRetiredAgents + RetireAgent
internal/domain/connector.go — IsRetired, SentinelAgentIDs,
IsSentinelAgent, AgentDependencyCounts,
ActorTypeAgent/System
internal/domain/connector_test.go — new
internal/integration/lifecycle_test.go — retirement fixture
internal/mcp/client.go — DeleteWithQuery additive transport
internal/mcp/retire_agent_test.go — new
internal/mcp/tools.go, internal/mcp/types.go — retire_agent tool + Force/Reason inputs
internal/repository/interfaces.go — AgentRepository retirement methods
internal/repository/postgres/agent.go — retire + cascade target retire + counts
internal/repository/postgres/migration_000015_test.go — new
internal/service/agent.go — wire into AgentService surface
internal/service/agent_retire.go — new 8-step contract
internal/service/agent_retire_test.go — new
internal/service/deployment.go — skip retired agents
internal/service/target.go — skip retired agents
internal/service/testutil_test.go — shared mocks extended
migrations/000015_agent_retire.up.sql — new
migrations/000015_agent_retire.down.sql — new
web/src/api/client.ts, types.ts + tests — retire endpoint wiring
web/src/pages/AgentsPage.tsx — retire UI
596 lines
24 KiB
TypeScript
596 lines
24 KiB
TypeScript
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary, AgentDependencyCounts, RetireAgentResponse, BlockedByDependenciesResponse } from './types';
|
|
|
|
const BASE = '/api/v1';
|
|
|
|
// API key stored in memory (not localStorage for security)
|
|
let apiKey: string | null = null;
|
|
|
|
export function setApiKey(key: string | null) {
|
|
apiKey = key;
|
|
}
|
|
|
|
export function getApiKey(): string | null {
|
|
return apiKey;
|
|
}
|
|
|
|
function authHeaders(): Record<string, string> {
|
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
if (apiKey) {
|
|
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
|
const res = await fetch(url, {
|
|
headers: { ...authHeaders(), ...init?.headers },
|
|
...init,
|
|
});
|
|
if (res.status === 401) {
|
|
// Trigger re-auth
|
|
const event = new CustomEvent('certctl:auth-required');
|
|
window.dispatchEvent(event);
|
|
throw new Error('Authentication required');
|
|
}
|
|
if (!res.ok) {
|
|
let errorMsg = res.statusText;
|
|
try {
|
|
const body = await res.json();
|
|
errorMsg = body.message || body.error || errorMsg;
|
|
} catch {
|
|
// Response body is not JSON, use status text
|
|
}
|
|
throw new Error(errorMsg || `HTTP ${res.status}`);
|
|
}
|
|
if (res.status === 204) return {} as T;
|
|
return res.json();
|
|
}
|
|
|
|
// Auth
|
|
export const getAuthInfo = () =>
|
|
fetch(`${BASE}/auth/info`, { headers: { 'Content-Type': 'application/json' } })
|
|
.then(r => r.json() as Promise<{ auth_type: string; required: boolean }>);
|
|
|
|
// AuthCheckResponse mirrors the /auth/check handler payload. Post-M-003 it
|
|
// surfaces `user` (named-key identity) and `admin` (named-key admin flag) so
|
|
// the GUI can gate admin-only affordances. When CERTCTL_AUTH_TYPE=none the
|
|
// backend returns {user: "", admin: false}.
|
|
export interface AuthCheckResponse {
|
|
status: string;
|
|
user: string;
|
|
admin: boolean;
|
|
}
|
|
|
|
export const checkAuth = (key: string) =>
|
|
fetch(`${BASE}/auth/check`, {
|
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${key}` },
|
|
}).then(r => {
|
|
if (!r.ok) throw new Error('Invalid API key');
|
|
return r.json() as Promise<AuthCheckResponse>;
|
|
});
|
|
|
|
// Certificates
|
|
export const getCertificates = (params: Record<string, string> = {}) => {
|
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
|
return fetchJSON<PaginatedResponse<Certificate>>(`${BASE}/certificates?${qs}`);
|
|
};
|
|
|
|
export const getCertificate = (id: string) =>
|
|
fetchJSON<Certificate>(`${BASE}/certificates/${id}`);
|
|
|
|
export const getCertificateVersions = (id: string) =>
|
|
fetchJSON<PaginatedResponse<CertificateVersion>>(`${BASE}/certificates/${id}/versions`);
|
|
|
|
export const createCertificate = (data: Partial<Certificate>) =>
|
|
fetchJSON<Certificate>(`${BASE}/certificates`, { method: 'POST', body: JSON.stringify(data) });
|
|
|
|
export const triggerRenewal = (id: string) =>
|
|
fetchJSON<{ message: string }>(`${BASE}/certificates/${id}/renew`, { method: 'POST' });
|
|
|
|
export const updateCertificate = (id: string, data: Partial<Certificate>) =>
|
|
fetchJSON<Certificate>(`${BASE}/certificates/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
|
|
|
export const archiveCertificate = (id: string) =>
|
|
fetchJSON<{ message: string }>(`${BASE}/certificates/${id}`, { method: 'DELETE' });
|
|
|
|
export const triggerDeployment = (id: string, targetId: string) =>
|
|
fetchJSON<{ message: string }>(`${BASE}/certificates/${id}/deploy`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ target_id: targetId }),
|
|
});
|
|
|
|
export const revokeCertificate = (id: string, reason: string) =>
|
|
fetchJSON<{ status: string }>(`${BASE}/certificates/${id}/revoke`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason }),
|
|
});
|
|
|
|
export interface BulkRevokeCriteria {
|
|
reason: string;
|
|
profile_id?: string;
|
|
owner_id?: string;
|
|
agent_id?: string;
|
|
issuer_id?: string;
|
|
team_id?: string;
|
|
certificate_ids?: string[];
|
|
}
|
|
|
|
export interface BulkRevokeResult {
|
|
total_matched: number;
|
|
total_revoked: number;
|
|
total_skipped: number;
|
|
total_failed: number;
|
|
errors?: { certificate_id: string; error: string }[];
|
|
}
|
|
|
|
export const bulkRevokeCertificates = (criteria: BulkRevokeCriteria) =>
|
|
fetchJSON<BulkRevokeResult>(`${BASE}/certificates/bulk-revoke`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(criteria),
|
|
});
|
|
|
|
// Certificate Export
|
|
export const exportCertificatePEM = (id: string) =>
|
|
fetchJSON<{ cert_pem: string; chain_pem: string; full_pem: string }>(`${BASE}/certificates/${id}/export/pem`);
|
|
|
|
export const downloadCertificatePEM = (id: string) => {
|
|
const headers: Record<string, string> = {};
|
|
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
|
return fetch(`${BASE}/certificates/${id}/export/pem?download=true`, { headers })
|
|
.then(r => {
|
|
if (!r.ok) throw new Error('Export failed');
|
|
return r.blob();
|
|
});
|
|
};
|
|
|
|
export const exportCertificatePKCS12 = (id: string, password: string = '') => {
|
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
|
return fetch(`${BASE}/certificates/${id}/export/pkcs12`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({ password }),
|
|
}).then(r => {
|
|
if (!r.ok) throw new Error('Export failed');
|
|
return r.blob();
|
|
});
|
|
};
|
|
|
|
// Certificate Deployments
|
|
export const getCertificateDeployments = (id: string, params: Record<string, string> = {}) => {
|
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
|
return fetchJSON<PaginatedResponse<Job>>(`${BASE}/certificates/${id}/deployments?${qs}`);
|
|
};
|
|
|
|
// OCSP (RFC 6960) — served unauthenticated under /.well-known/pki/ per RFC 8615
|
|
// (M-006 relocation). The legacy JSON CRL endpoint (`GET /api/v1/crl`) was
|
|
// removed entirely; relying parties fetch the DER-encoded CRL directly from
|
|
// `/.well-known/pki/crl/{issuer_id}` (no GUI wrapper — binary download only).
|
|
export const getOCSPStatus = (issuerId: string, serial: string) => {
|
|
// No Authorization header — the OCSP responder is intentionally unauthenticated
|
|
// so relying parties without certctl API keys can check revocation status.
|
|
return fetch(`/.well-known/pki/ocsp/${issuerId}/${serial}`)
|
|
.then(r => {
|
|
if (!r.ok) throw new Error(`OCSP request failed: ${r.status}`);
|
|
return r.arrayBuffer();
|
|
});
|
|
};
|
|
|
|
// Agents
|
|
export const getAgents = (params: Record<string, string> = {}) => {
|
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
|
return fetchJSON<PaginatedResponse<Agent>>(`${BASE}/agents?${qs}`);
|
|
};
|
|
|
|
export const getAgent = (id: string) =>
|
|
fetchJSON<Agent>(`${BASE}/agents/${id}`);
|
|
|
|
export const registerAgent = (data: Partial<Agent>) =>
|
|
fetchJSON<Agent>(`${BASE}/agents`, { method: 'POST', body: JSON.stringify(data) });
|
|
|
|
// I-004: typed error thrown by retireAgent when the server returns HTTP 409 with
|
|
// {error: "blocked_by_dependencies", ...}. Callers that want to show the
|
|
// dependency-counts dialog should `catch (e)` and check `e instanceof
|
|
// BlockedByDependenciesError` — the counts field is the same shape the
|
|
// backend handler returns from its inline struct in
|
|
// internal/api/handler/agents.go. Generic network / 5xx failures still throw
|
|
// plain Error so existing error-boundary code is unaffected.
|
|
export class BlockedByDependenciesError extends Error {
|
|
readonly counts: AgentDependencyCounts;
|
|
constructor(message: string, counts: AgentDependencyCounts) {
|
|
super(message);
|
|
this.name = 'BlockedByDependenciesError';
|
|
this.counts = counts;
|
|
}
|
|
}
|
|
|
|
// I-004: retire an agent via DELETE /api/v1/agents/{id}. Three distinct
|
|
// success paths the UI needs to distinguish:
|
|
// * 200 — fresh retire; body has retired_at, already_retired=false, cascade
|
|
// flag, counts of what was cascaded.
|
|
// * 204 — idempotent re-retire; the row was already retired. No body. We
|
|
// synthesize a RetireAgentResponse with already_retired=true and zero
|
|
// counts so the caller can keep a single return type.
|
|
// * 409 — blocked_by_dependencies; thrown as BlockedByDependenciesError so
|
|
// the caller can surface the active_targets/active_certificates/pending_jobs
|
|
// counts in a confirmation dialog and offer force=true.
|
|
// Anything else bubbles up via the standard fetchJSON error path.
|
|
export const retireAgent = async (
|
|
id: string,
|
|
opts: { force?: boolean; reason?: string } = {},
|
|
): Promise<RetireAgentResponse> => {
|
|
const qs = new URLSearchParams();
|
|
if (opts.force) qs.set('force', 'true');
|
|
if (opts.reason) qs.set('reason', opts.reason);
|
|
const url = qs.toString()
|
|
? `${BASE}/agents/${id}?${qs.toString()}`
|
|
: `${BASE}/agents/${id}`;
|
|
|
|
const res = await fetch(url, {
|
|
method: 'DELETE',
|
|
headers: authHeaders(),
|
|
});
|
|
|
|
if (res.status === 401) {
|
|
window.dispatchEvent(new CustomEvent('certctl:auth-required'));
|
|
throw new Error('Authentication required');
|
|
}
|
|
|
|
// 204 No Content — idempotent re-retire. Synthesize a response so callers
|
|
// get a uniform shape; already_retired=true tells them the agent was
|
|
// already in the retired state before this call.
|
|
if (res.status === 204) {
|
|
return {
|
|
retired_at: '',
|
|
already_retired: true,
|
|
cascade: false,
|
|
counts: { active_targets: 0, active_certificates: 0, pending_jobs: 0 },
|
|
};
|
|
}
|
|
|
|
if (res.status === 409) {
|
|
// Body is always JSON for 409 per the handler contract.
|
|
const body = (await res.json()) as BlockedByDependenciesResponse;
|
|
throw new BlockedByDependenciesError(
|
|
body.message || 'agent has active dependencies',
|
|
body.counts,
|
|
);
|
|
}
|
|
|
|
if (!res.ok) {
|
|
let errorMsg = res.statusText;
|
|
try {
|
|
const body = await res.json();
|
|
errorMsg = body.message || body.error || errorMsg;
|
|
} catch {
|
|
// not JSON
|
|
}
|
|
throw new Error(errorMsg || `HTTP ${res.status}`);
|
|
}
|
|
|
|
return (await res.json()) as RetireAgentResponse;
|
|
};
|
|
|
|
// I-004: list retired agents via GET /api/v1/agents/retired. Kept separate
|
|
// from getAgents (which hits the default active-only listing) so the retired
|
|
// tab on AgentsPage can page independently. per_page is capped server-side at
|
|
// 500 (see handler ListRetiredAgents).
|
|
export const listRetiredAgents = (params: Record<string, string> = {}) => {
|
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
|
return fetchJSON<PaginatedResponse<Agent>>(`${BASE}/agents/retired?${qs}`);
|
|
};
|
|
|
|
// Jobs
|
|
export const getJobs = (params: Record<string, string> = {}) => {
|
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
|
return fetchJSON<PaginatedResponse<Job>>(`${BASE}/jobs?${qs}`);
|
|
};
|
|
|
|
export const cancelJob = (id: string) =>
|
|
fetchJSON<{ message: string }>(`${BASE}/jobs/${id}/cancel`, { method: 'POST' });
|
|
|
|
// Notifications
|
|
export const getNotifications = (params: Record<string, string> = {}) => {
|
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
|
return fetchJSON<PaginatedResponse<Notification>>(`${BASE}/notifications?${qs}`);
|
|
};
|
|
|
|
export const getNotification = (id: string) =>
|
|
fetchJSON<Notification>(`${BASE}/notifications/${id}`);
|
|
|
|
export const markNotificationRead = (id: string) =>
|
|
fetchJSON<{ message: string }>(`${BASE}/notifications/${id}/read`, { method: 'POST' });
|
|
|
|
// Audit
|
|
export const getAuditEvents = (params: Record<string, string> = {}) => {
|
|
const qs = new URLSearchParams({ page: '1', per_page: '200', ...params }).toString();
|
|
return fetchJSON<PaginatedResponse<AuditEvent>>(`${BASE}/audit?${qs}`);
|
|
};
|
|
|
|
export const getAuditEvent = (id: string) =>
|
|
fetchJSON<AuditEvent>(`${BASE}/audit/${id}`);
|
|
|
|
// Policies
|
|
export const getPolicies = (params: Record<string, string> = {}) => {
|
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
|
return fetchJSON<PaginatedResponse<PolicyRule>>(`${BASE}/policies?${qs}`);
|
|
};
|
|
|
|
export const createPolicy = (data: Partial<PolicyRule>) =>
|
|
fetchJSON<PolicyRule>(`${BASE}/policies`, { method: 'POST', body: JSON.stringify(data) });
|
|
|
|
export const updatePolicy = (id: string, data: Partial<PolicyRule>) =>
|
|
fetchJSON<PolicyRule>(`${BASE}/policies/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
|
|
|
export const getPolicy = (id: string) =>
|
|
fetchJSON<PolicyRule>(`${BASE}/policies/${id}`);
|
|
|
|
export const deletePolicy = (id: string) =>
|
|
fetchJSON<{ message: string }>(`${BASE}/policies/${id}`, { method: 'DELETE' });
|
|
|
|
export const getPolicyViolations = (id: string) =>
|
|
fetchJSON<PaginatedResponse<PolicyViolation>>(`${BASE}/policies/${id}/violations`);
|
|
|
|
// Issuers
|
|
export const getIssuers = (params: Record<string, string> = {}) => {
|
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
|
return fetchJSON<PaginatedResponse<Issuer>>(`${BASE}/issuers?${qs}`);
|
|
};
|
|
|
|
export const createIssuer = (data: Partial<Issuer>) =>
|
|
fetchJSON<Issuer>(`${BASE}/issuers`, { method: 'POST', body: JSON.stringify(data) });
|
|
|
|
export const testIssuerConnection = (id: string) =>
|
|
fetchJSON<{ message: string }>(`${BASE}/issuers/${id}/test`, { method: 'POST' });
|
|
|
|
export const updateIssuer = (id: string, data: Partial<Issuer>) =>
|
|
fetchJSON<Issuer>(`${BASE}/issuers/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
|
|
|
export const deleteIssuer = (id: string) =>
|
|
fetchJSON<{ message: string }>(`${BASE}/issuers/${id}`, { method: 'DELETE' });
|
|
|
|
// Targets
|
|
export const getTargets = (params: Record<string, string> = {}) => {
|
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
|
return fetchJSON<PaginatedResponse<Target>>(`${BASE}/targets?${qs}`);
|
|
};
|
|
|
|
export const createTarget = (data: Partial<Target>) =>
|
|
fetchJSON<Target>(`${BASE}/targets`, { method: 'POST', body: JSON.stringify(data) });
|
|
|
|
export const updateTarget = (id: string, data: Partial<Target>) =>
|
|
fetchJSON<Target>(`${BASE}/targets/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
|
|
|
export const deleteTarget = (id: string) =>
|
|
fetchJSON<{ message: string }>(`${BASE}/targets/${id}`, { method: 'DELETE' });
|
|
|
|
export const testTargetConnection = (id: string) =>
|
|
fetchJSON<{ status: string; message: string }>(`${BASE}/targets/${id}/test`, { method: 'POST' });
|
|
|
|
// Profiles
|
|
export const getProfiles = (params: Record<string, string> = {}) => {
|
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
|
return fetchJSON<PaginatedResponse<CertificateProfile>>(`${BASE}/profiles?${qs}`);
|
|
};
|
|
|
|
export const getProfile = (id: string) =>
|
|
fetchJSON<CertificateProfile>(`${BASE}/profiles/${id}`);
|
|
|
|
export const createProfile = (data: Partial<CertificateProfile>) =>
|
|
fetchJSON<CertificateProfile>(`${BASE}/profiles`, { method: 'POST', body: JSON.stringify(data) });
|
|
|
|
export const updateProfile = (id: string, data: Partial<CertificateProfile>) =>
|
|
fetchJSON<CertificateProfile>(`${BASE}/profiles/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
|
|
|
export const deleteProfile = (id: string) =>
|
|
fetchJSON<{ message: string }>(`${BASE}/profiles/${id}`, { method: 'DELETE' });
|
|
|
|
// Owners
|
|
export const getOwners = (params: Record<string, string> = {}) => {
|
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
|
return fetchJSON<PaginatedResponse<Owner>>(`${BASE}/owners?${qs}`);
|
|
};
|
|
|
|
export const getOwner = (id: string) =>
|
|
fetchJSON<Owner>(`${BASE}/owners/${id}`);
|
|
|
|
export const createOwner = (data: Partial<Owner>) =>
|
|
fetchJSON<Owner>(`${BASE}/owners`, { method: 'POST', body: JSON.stringify(data) });
|
|
|
|
export const updateOwner = (id: string, data: Partial<Owner>) =>
|
|
fetchJSON<Owner>(`${BASE}/owners/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
|
|
|
export const deleteOwner = (id: string) =>
|
|
fetchJSON<{ message: string }>(`${BASE}/owners/${id}`, { method: 'DELETE' });
|
|
|
|
// Teams
|
|
export const getTeams = (params: Record<string, string> = {}) => {
|
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
|
return fetchJSON<PaginatedResponse<Team>>(`${BASE}/teams?${qs}`);
|
|
};
|
|
|
|
export const getTeam = (id: string) =>
|
|
fetchJSON<Team>(`${BASE}/teams/${id}`);
|
|
|
|
export const createTeam = (data: Partial<Team>) =>
|
|
fetchJSON<Team>(`${BASE}/teams`, { method: 'POST', body: JSON.stringify(data) });
|
|
|
|
export const updateTeam = (id: string, data: Partial<Team>) =>
|
|
fetchJSON<Team>(`${BASE}/teams/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
|
|
|
export const deleteTeam = (id: string) =>
|
|
fetchJSON<{ message: string }>(`${BASE}/teams/${id}`, { method: 'DELETE' });
|
|
|
|
// Agent Groups
|
|
export const getAgentGroups = (params: Record<string, string> = {}) => {
|
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
|
return fetchJSON<PaginatedResponse<AgentGroup>>(`${BASE}/agent-groups?${qs}`);
|
|
};
|
|
|
|
export const getAgentGroup = (id: string) =>
|
|
fetchJSON<AgentGroup>(`${BASE}/agent-groups/${id}`);
|
|
|
|
export const createAgentGroup = (data: Partial<AgentGroup>) =>
|
|
fetchJSON<AgentGroup>(`${BASE}/agent-groups`, { method: 'POST', body: JSON.stringify(data) });
|
|
|
|
export const updateAgentGroup = (id: string, data: Partial<AgentGroup>) =>
|
|
fetchJSON<AgentGroup>(`${BASE}/agent-groups/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
|
|
|
export const deleteAgentGroup = (id: string) =>
|
|
fetchJSON<{ message: string }>(`${BASE}/agent-groups/${id}`, { method: 'DELETE' });
|
|
|
|
export const getAgentGroupMembers = (id: string) =>
|
|
fetchJSON<PaginatedResponse<Agent>>(`${BASE}/agent-groups/${id}/members`);
|
|
|
|
// Renewal Approvals
|
|
export const approveRenewal = (jobId: string) =>
|
|
fetchJSON<{ message: string }>(`${BASE}/jobs/${jobId}/approve`, { method: 'POST' });
|
|
|
|
export const rejectRenewal = (jobId: string, reason: string) =>
|
|
fetchJSON<{ message: string }>(`${BASE}/jobs/${jobId}/reject`, { method: 'POST', body: JSON.stringify({ reason }) });
|
|
|
|
// Discovery
|
|
export const getDiscoveredCertificates = (params: Record<string, string> = {}) => {
|
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
|
return fetchJSON<PaginatedResponse<DiscoveredCertificate>>(`${BASE}/discovered-certificates?${qs}`);
|
|
};
|
|
|
|
export const getDiscoveredCertificate = (id: string) =>
|
|
fetchJSON<DiscoveredCertificate>(`${BASE}/discovered-certificates/${id}`);
|
|
|
|
export const claimDiscoveredCertificate = (id: string, managedCertificateId: string) =>
|
|
fetchJSON<{ message: string }>(`${BASE}/discovered-certificates/${id}/claim`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ managed_certificate_id: managedCertificateId }),
|
|
});
|
|
|
|
export const dismissDiscoveredCertificate = (id: string) =>
|
|
fetchJSON<{ message: string }>(`${BASE}/discovered-certificates/${id}/dismiss`, { method: 'POST' });
|
|
|
|
export const getDiscoveryScans = (params: Record<string, string> = {}) => {
|
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
|
return fetchJSON<PaginatedResponse<DiscoveryScan>>(`${BASE}/discovery-scans?${qs}`);
|
|
};
|
|
|
|
export const getDiscoverySummary = () =>
|
|
fetchJSON<DiscoverySummary>(`${BASE}/discovery-summary`);
|
|
|
|
// Network Scan Targets
|
|
export const getNetworkScanTargets = (params: Record<string, string> = {}) => {
|
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
|
return fetchJSON<PaginatedResponse<NetworkScanTarget>>(`${BASE}/network-scan-targets?${qs}`);
|
|
};
|
|
|
|
export const getNetworkScanTarget = (id: string) =>
|
|
fetchJSON<NetworkScanTarget>(`${BASE}/network-scan-targets/${id}`);
|
|
|
|
export const createNetworkScanTarget = (data: Partial<NetworkScanTarget>) =>
|
|
fetchJSON<NetworkScanTarget>(`${BASE}/network-scan-targets`, { method: 'POST', body: JSON.stringify(data) });
|
|
|
|
export const updateNetworkScanTarget = (id: string, data: Partial<NetworkScanTarget>) =>
|
|
fetchJSON<NetworkScanTarget>(`${BASE}/network-scan-targets/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
|
|
|
export const deleteNetworkScanTarget = (id: string) =>
|
|
fetchJSON<{ message: string }>(`${BASE}/network-scan-targets/${id}`, { method: 'DELETE' });
|
|
|
|
export const triggerNetworkScan = (id: string) =>
|
|
fetchJSON<{ message: string }>(`${BASE}/network-scan-targets/${id}/scan`, { method: 'POST' });
|
|
|
|
// Stats
|
|
export const getDashboardSummary = () =>
|
|
fetchJSON<DashboardSummary>(`${BASE}/stats/summary`);
|
|
|
|
export const getCertificatesByStatus = () =>
|
|
fetchJSON<CertificateStatusCount[]>(`${BASE}/stats/certificates-by-status`);
|
|
|
|
export const getExpirationTimeline = (days = 30) =>
|
|
fetchJSON<ExpirationBucket[]>(`${BASE}/stats/expiration-timeline?days=${days}`);
|
|
|
|
export const getJobTrends = (days = 30) =>
|
|
fetchJSON<JobTrendDataPoint[]>(`${BASE}/stats/job-trends?days=${days}`);
|
|
|
|
export const getIssuanceRate = (days = 30) =>
|
|
fetchJSON<IssuanceRateDataPoint[]>(`${BASE}/stats/issuance-rate?days=${days}`);
|
|
|
|
export const getMetrics = () =>
|
|
fetchJSON<MetricsResponse>(`${BASE}/metrics`);
|
|
|
|
// Digest
|
|
export const previewDigest = () => {
|
|
const headers: Record<string, string> = {};
|
|
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
|
return fetch(`${BASE}/digest/preview`, { headers })
|
|
.then(r => {
|
|
if (!r.ok) throw new Error(`Digest preview failed: ${r.status}`);
|
|
return r.text();
|
|
});
|
|
};
|
|
|
|
export const sendDigest = () =>
|
|
fetchJSON<{ message: string }>(`${BASE}/digest/send`, { method: 'POST' });
|
|
|
|
// Jobs (single)
|
|
export const getJob = (id: string) =>
|
|
fetchJSON<Job>(`${BASE}/jobs/${id}`);
|
|
|
|
// Job Verification
|
|
export const getJobVerification = (id: string) =>
|
|
fetchJSON<{ job_id: string; target_id: string; verified: boolean; actual_fingerprint: string; expected_fingerprint: string; verified_at: string; error?: string }>(`${BASE}/jobs/${id}/verification`);
|
|
|
|
// Issuers (single)
|
|
export const getIssuer = (id: string) =>
|
|
fetchJSON<Issuer>(`${BASE}/issuers/${id}`);
|
|
|
|
// Targets (single)
|
|
export const getTarget = (id: string) =>
|
|
fetchJSON<Target>(`${BASE}/targets/${id}`);
|
|
|
|
// Prometheus metrics (text format)
|
|
export const getPrometheusMetrics = () => {
|
|
const headers: Record<string, string> = {};
|
|
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
|
return fetch(`${BASE}/metrics/prometheus`, { headers })
|
|
.then(r => {
|
|
if (!r.ok) throw new Error(`Prometheus metrics failed: ${r.status}`);
|
|
return r.text();
|
|
});
|
|
};
|
|
|
|
// Health
|
|
export const getHealth = () => fetchJSON<{ status: string }>('/health');
|
|
|
|
// Health checks (M48)
|
|
export const listHealthChecks = (params?: { status?: string; certificate_id?: string; enabled?: string; page?: number; per_page?: number }): Promise<PaginatedResponse<EndpointHealthCheck>> => {
|
|
const query = new URLSearchParams();
|
|
if (params?.status) query.set('status', params.status);
|
|
if (params?.certificate_id) query.set('certificate_id', params.certificate_id);
|
|
if (params?.enabled) query.set('enabled', params.enabled);
|
|
if (params?.page) query.set('page', String(params.page));
|
|
if (params?.per_page) query.set('per_page', String(params.per_page));
|
|
const qs = query.toString();
|
|
return fetchJSON<PaginatedResponse<EndpointHealthCheck>>(`${BASE}/health-checks${qs ? '?' + qs : ''}`);
|
|
};
|
|
|
|
export const getHealthCheck = (id: string) =>
|
|
fetchJSON<EndpointHealthCheck>(`${BASE}/health-checks/${id}`);
|
|
|
|
export const createHealthCheck = (data: Partial<EndpointHealthCheck>) =>
|
|
fetchJSON<EndpointHealthCheck>(`${BASE}/health-checks`, { method: 'POST', body: JSON.stringify(data) });
|
|
|
|
export const updateHealthCheck = (id: string, data: Partial<EndpointHealthCheck>) =>
|
|
fetchJSON<EndpointHealthCheck>(`${BASE}/health-checks/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
|
|
|
export const deleteHealthCheck = (id: string) =>
|
|
fetchJSON<void>(`${BASE}/health-checks/${id}`, { method: 'DELETE' });
|
|
|
|
export const getHealthCheckHistory = (id: string, limit?: number) => {
|
|
const query = limit ? `?limit=${limit}` : '';
|
|
return fetchJSON<HealthHistoryEntry[]>(`${BASE}/health-checks/${id}/history${query}`);
|
|
};
|
|
|
|
export const acknowledgeHealthCheck = (id: string) =>
|
|
fetchJSON<void>(`${BASE}/health-checks/${id}/acknowledge`, { method: 'POST', body: JSON.stringify({}) });
|
|
|
|
export const getHealthCheckSummary = () =>
|
|
fetchJSON<HealthCheckSummary>(`${BASE}/health-checks/summary`);
|