mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:41:41 +00:00
feat: M11a — certificate profiles, crypto policy enforcement, short-lived cert expiry
Add certificate profiles as named enrollment templates that control allowed key algorithms, max TTL, permitted EKUs, required SAN patterns, and optional SPIFFE URI SANs. CSR submissions are validated against profile rules at signing time (key type + minimum size). Short-lived certs (TTL < 1 hour) auto-expire via a new scheduler loop — expiry acts as revocation, no CRL/OCSP needed. New files: - Migration 000003: certificate_profiles table, FK columns on managed_certificates/renewal_policies, key metadata on certificate_versions - domain/profile.go: CertificateProfile + KeyAlgorithmRule structs - repository/postgres/profile.go: full CRUD with JSONB marshaling - service/profile.go: ProfileService with validation + audit logging - service/crypto_validation.go: CSR-against-profile validation (RSA/ECDSA/Ed25519) - handler/profiles.go: 5 HTTP endpoints under /api/v1/profiles - web/src/pages/ProfilesPage.tsx: profiles management page Modified: - renewal.go: CSR validation in CompleteAgentCSRRenewal, ExpireShortLivedCertificates - scheduler.go: 30s short-lived expiry check loop - certificate.go (repo): nullable profile FK, key metadata on versions - main.go: profile repo/service/handler wiring, 8-param NewRenewalService - router.go: 12-param RegisterHandlers with profile routes - seed_demo.sql: 4 demo profiles (standard, mtls, short-lived, high-security) - Frontend: types, API client, routing, sidebar nav Tests: 40 new tests across handler (15), service (13), crypto validation (12) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+19
-1
@@ -1,4 +1,4 @@
|
||||
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, Issuer, Target, PaginatedResponse } from './types';
|
||||
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, Issuer, Target, CertificateProfile, PaginatedResponse } from './types';
|
||||
|
||||
const BASE = '/api/v1';
|
||||
|
||||
@@ -169,5 +169,23 @@ export const createTarget = (data: Partial<Target>) =>
|
||||
export const deleteTarget = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/targets/${id}`, { method: 'DELETE' });
|
||||
|
||||
// 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' });
|
||||
|
||||
// Health
|
||||
export const getHealth = () => fetchJSON<{ status: string }>('/health');
|
||||
|
||||
@@ -129,6 +129,26 @@ export interface Target {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface KeyAlgorithmRule {
|
||||
algorithm: string;
|
||||
min_size: number;
|
||||
}
|
||||
|
||||
export interface CertificateProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
allowed_key_algorithms: KeyAlgorithmRule[];
|
||||
max_ttl_seconds: number;
|
||||
allowed_ekus: string[];
|
||||
required_san_patterns: string[];
|
||||
spiffe_uri_pattern: string;
|
||||
allow_short_lived: boolean;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
|
||||
@@ -8,6 +8,7 @@ const nav = [
|
||||
{ to: '/jobs', label: 'Jobs', icon: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15' },
|
||||
{ to: '/notifications', label: 'Notifications', icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' },
|
||||
{ to: '/policies', label: 'Policies', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' },
|
||||
{ to: '/profiles', label: 'Profiles', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
|
||||
{ to: '/issuers', label: 'Issuers', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' },
|
||||
{ to: '/targets', label: 'Targets', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
|
||||
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
|
||||
@@ -16,6 +16,7 @@ import NotificationsPage from './pages/NotificationsPage';
|
||||
import PoliciesPage from './pages/PoliciesPage';
|
||||
import IssuersPage from './pages/IssuersPage';
|
||||
import TargetsPage from './pages/TargetsPage';
|
||||
import ProfilesPage from './pages/ProfilesPage';
|
||||
import AuditPage from './pages/AuditPage';
|
||||
import './index.css';
|
||||
|
||||
@@ -46,6 +47,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="jobs" element={<JobsPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="policies" element={<PoliciesPage />} />
|
||||
<Route path="profiles" element={<ProfilesPage />} />
|
||||
<Route path="issuers" element={<IssuersPage />} />
|
||||
<Route path="targets" element={<TargetsPage />} />
|
||||
<Route path="audit" element={<AuditPage />} />
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getProfiles, deleteProfile } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { CertificateProfile } from '../api/types';
|
||||
|
||||
function formatTTL(seconds: number): string {
|
||||
if (seconds === 0) return 'No limit';
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
||||
return `${Math.floor(seconds / 86400)}d`;
|
||||
}
|
||||
|
||||
export default function ProfilesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['profiles'],
|
||||
queryFn: () => getProfiles(),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteProfile,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['profiles'] }),
|
||||
});
|
||||
|
||||
const columns: Column<CertificateProfile>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Profile',
|
||||
render: (p) => (
|
||||
<div>
|
||||
<div className="font-medium text-slate-200">{p.name}</div>
|
||||
<div className="text-xs text-slate-500 font-mono">{p.id}</div>
|
||||
{p.description && (
|
||||
<div className="text-xs text-slate-400 mt-0.5 max-w-xs truncate">{p.description}</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'algorithms',
|
||||
label: 'Key Algorithms',
|
||||
render: (p) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(p.allowed_key_algorithms || []).map((alg, i) => (
|
||||
<span key={i} className="badge badge-neutral text-xs">
|
||||
{alg.algorithm} {alg.min_size}+
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ttl',
|
||||
label: 'Max TTL',
|
||||
render: (p) => (
|
||||
<div>
|
||||
<span className="text-slate-200">{formatTTL(p.max_ttl_seconds)}</span>
|
||||
{p.allow_short_lived && (
|
||||
<span className="ml-2 text-xs text-amber-400 bg-amber-400/10 px-1.5 py-0.5 rounded">
|
||||
short-lived
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ekus',
|
||||
label: 'EKUs',
|
||||
render: (p) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(p.allowed_ekus || []).map((eku, i) => (
|
||||
<span key={i} className="text-xs text-slate-400">{eku}</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'spiffe',
|
||||
label: 'SPIFFE',
|
||||
render: (p) => (
|
||||
p.spiffe_uri_pattern
|
||||
? <span className="text-xs text-blue-400 font-mono">{p.spiffe_uri_pattern}</span>
|
||||
: <span className="text-slate-500">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
label: 'Status',
|
||||
render: (p) => <StatusBadge status={p.enabled ? 'active' : 'disabled'} />,
|
||||
},
|
||||
{
|
||||
key: 'created',
|
||||
label: 'Created',
|
||||
render: (p) => <span className="text-xs text-slate-400">{formatDateTime(p.created_at)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
render: (p) => (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete profile ${p.name}?`)) deleteMutation.mutate(p.id); }}
|
||||
className="text-xs text-red-400 hover:text-red-300 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Certificate Profiles" subtitle={data ? `${data.total} profiles` : undefined} />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
) : (
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No profiles configured" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user