mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 07:39:03 +00:00
1ef16984eb
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>
130 lines
3.9 KiB
TypeScript
130 lines
3.9 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|