Add server template apply modes, bulk revocation, explicit DN order and detail tabs

This commit is contained in:
2026-10-23 05:33:43 +00:00
parent 5eb7866f5b
commit 763268bd40
13 changed files with 1477 additions and 633 deletions
+18 -11
View File
@@ -78,8 +78,10 @@ explicit/mandatory DN entries, default key type/size, default validity).
- Issue self-signed certificates or certificates signed by a CA in the same - Issue self-signed certificates or certificates signed by a CA in the same
vault, from scratch or from a request. Imported CSRs are marked **signed** vault, from scratch or from a request. Imported CSRs are marked **signed**
once a certificate is issued from them. once a certificate is issued from them.
- The certificate wizard covers subject entries, validity, basic constraints, - The certificate wizard covers subject entries in the vault's explicit DN
key usage, extended key usage, SAN/IAN, subject/authority key identifiers, order, additional subject attributes, mandatory DN enforcement, validity,
basic constraints, key usage, extended key usage, SAN/IAN,
subject/authority key identifiers,
CRL distribution points, authority information access, name constraints, CRL distribution points, authority information access, name constraints,
certificate policies, policy constraints, inhibit-any-policy, OCSP no-check, certificate policies, policy constraints, inhibit-any-policy, OCSP no-check,
TLS feature (OCSP stapling), Netscape legacy extensions and raw TLS feature (OCSP stapling), Netscape legacy extensions and raw
@@ -87,14 +89,18 @@ explicit/mandatory DN entries, default key type/size, default validity).
advanced extensions plus an advanced JSON editor for raw/less common advanced extensions plus an advanced JSON editor for raw/less common
extensions, and a server-side **Validate extensions** action. OpenSSL config extensions, and a server-side **Validate extensions** action. OpenSSL config
text is available on export. text is available on export.
- Certificate details show subject/issuer, serial, validity, signatures, - Certificate details are split into Overview, Extensions, Validation, Export,
MD5/SHA-1/SHA-256 fingerprints, parsed extensions, OpenSSL validation Metadata, Revoke, Transform and CA tabs, with hash deep links. They show
errors, purposes and the issuer chain. subject/issuer, serial, validity, signatures, MD5/SHA-1/SHA-256
fingerprints, parsed extensions, OpenSSL validation errors, purposes and the
issuer chain.
- Renew with a new or kept serial, optionally revoke/replace the old - Renew with a new or kept serial, optionally revoke/replace the old
certificate. Revoke with a reason; manage the CA's revocation list manually, certificate. Revoke with a reason; manage the CA's revocation list manually,
including revocation dates and invalidity dates. CA properties (CRL days and including revocation dates and invalidity dates. Certificate lists support
default signing template, including built-ins) are editable from the CA bulk revocation with a shared reason and invalidity date. CA properties
detail page. (CRL days and default signing template, including built-ins) are editable
from the CA detail page, and the wizard offers the CA default template
explicitly without overwriting edits.
- Absolute validity dates and XCA's "no well-defined expiration" mode are - Absolute validity dates and XCA's "no well-defined expiration" mode are
supported alongside duration-based validity. supported alongside duration-based validity.
- Transform certificates to a public key, a CSR or a template. - Transform certificates to a public key, a CSR or a template.
@@ -114,8 +120,9 @@ DER or vCalendar.
Built-in **CA**, **TLS server** and **TLS client** templates mirror XCA's Built-in **CA**, **TLS server** and **TLS client** templates mirror XCA's
presets. User templates store subject defaults, extension defaults and a presets. User templates store subject defaults, extension defaults and a
relative validity range. XCA's binary template format is supported for import relative validity range. XCA's binary template format is supported for import
and export, and templates can be applied to a certificate/request spec and export, and templates are applied to a certificate/request spec
server-side. server-side with **Apply all**, **Subject only**, and **Extensions + validity**
modes.
### Import and migration ### Import and migration
@@ -148,7 +155,7 @@ OpenAPI JSON is at `/api/openapi.json`; Swagger UI at `/api/docs`.
| Vaults | `GET/POST /api/vaults`, `GET/PATCH/DELETE /api/vaults/{id}`, `GET/PATCH /api/vaults/{id}/settings`, `GET /api/vaults/{id}/summary`, `GET /api/vaults/{id}/options`, `GET /api/vaults/{id}/search`, `POST /api/vaults/{id}/export` | | Vaults | `GET/POST /api/vaults`, `GET/PATCH/DELETE /api/vaults/{id}`, `GET/PATCH /api/vaults/{id}/settings`, `GET /api/vaults/{id}/summary`, `GET /api/vaults/{id}/options`, `GET /api/vaults/{id}/search`, `POST /api/vaults/{id}/export` |
| Keys | `GET/POST /api/vaults/{id}/keys`, `GET/PATCH/DELETE .../keys/{key_id}`, `POST .../keys/{key_id}/export` | | Keys | `GET/POST /api/vaults/{id}/keys`, `GET/PATCH/DELETE .../keys/{key_id}`, `POST .../keys/{key_id}/export` |
| Requests | `GET/POST /api/vaults/{id}/requests`, `GET/PATCH/DELETE .../{id}`, `POST .../{id}/export`, `POST .../{id}/transform/open-key`, `POST .../{id}/transform/template` | | Requests | `GET/POST /api/vaults/{id}/requests`, `GET/PATCH/DELETE .../{id}`, `POST .../{id}/export`, `POST .../{id}/transform/open-key`, `POST .../{id}/transform/template` |
| Certificates | `GET/POST /api/vaults/{id}/certificates`, `POST /api/vaults/{id}/certificates/preview`, `GET/PATCH/DELETE .../{id}`, `POST .../{id}/export`, `POST .../{id}/renew`, `POST .../{id}/revoke`, `GET/PATCH .../{id}/ca`, `GET/POST .../{id}/revocations`, `PATCH/DELETE .../revocations/{rev_id}`, `POST .../{id}/crl`, `POST .../{id}/tls-auth`, transforms | | Certificates | `GET/POST /api/vaults/{id}/certificates`, `POST /api/vaults/{id}/certificates/preview`, `GET/PATCH/DELETE .../{id}`, `POST .../{id}/export`, `POST .../{id}/renew`, `POST .../{id}/revoke`, `POST /api/vaults/{id}/certificates/bulk-revoke`, `GET/PATCH .../{id}/ca`, `GET/POST .../{id}/revocations`, `PATCH/DELETE .../revocations/{rev_id}`, `POST .../{id}/crl`, `POST .../{id}/tls-auth`, transforms |
| CRLs | `GET /api/vaults/{id}/crls`, `GET/DELETE .../{crl_id}`, `POST .../{crl_id}/export` | | CRLs | `GET /api/vaults/{id}/crls`, `GET/DELETE .../{crl_id}`, `POST .../{crl_id}/export` |
| Templates | `GET/POST /api/vaults/{id}/templates`, `GET/PATCH/DELETE .../{template_id}`, `POST .../{template_id}/apply`, `POST .../{template_id}/export` | | Templates | `GET/POST /api/vaults/{id}/templates`, `GET/PATCH/DELETE .../{template_id}`, `POST .../{template_id}/apply`, `POST .../{template_id}/export` |
| Import | `POST /api/vaults/{id}/import` (`dry_run`, `selected`, `xca_db`) | | Import | `POST /api/vaults/{id}/import` (`dry_run`, `selected`, `xca_db`) |
+23
View File
@@ -49,6 +49,9 @@ test("advanced extensions, validation and vault export", async ({ page }) => {
await page.getByRole("button", { name: "Create certificate" }).click(); await page.getByRole("button", { name: "Create certificate" }).click();
await expect(page.getByRole("heading", { name: certName })).toBeVisible(); await expect(page.getByRole("heading", { name: certName })).toBeVisible();
const certUrl = page.url();
const certApiUrl = certUrl.replace(/\/vaults\//, "/api/vaults/");
await page.locator(".nav-tabs").getByRole("button", { name: "Extensions", exact: true }).click();
await expect(page.getByText("CRL Distribution Points", { exact: false })).toBeVisible(); await expect(page.getByText("CRL Distribution Points", { exact: false })).toBeVisible();
await expect(page.getByText("1.2.3.4", { exact: false })).toBeVisible(); await expect(page.getByText("1.2.3.4", { exact: false })).toBeVisible();
@@ -59,4 +62,24 @@ test("advanced extensions, validation and vault export", async ({ page }) => {
await page.getByRole("button", { name: "Export ZIP" }).click(); await page.getByRole("button", { name: "Export ZIP" }).click();
const download = await downloadPromise; const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.zip$/); expect(download.suggestedFilename()).toMatch(/\.zip$/);
// Import preview selection: a duplicate is deselected by default, but can
// be selected explicitly and then reported as skipped by the commit.
await page.goto(`${vaultUrl}/import`);
await expect(page.getByRole("heading", { name: "Import" })).toBeVisible();
const pem = await page.evaluate(async (url) => {
const response = await fetch(`${url}/export`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ format: "pem" }),
});
const payload = await response.json();
return atob(payload.content_base64);
}, certApiUrl);
await page.locator("textarea[placeholder='-----BEGIN CERTIFICATE-----']").fill(pem);
await page.getByRole("button", { name: "Preview", exact: true }).click();
await expect(page.getByText("duplicate", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "All", exact: true }).click();
await page.getByRole("button", { name: "Import", exact: true }).click();
await expect(page.getByText(/skipped 1/)).toBeVisible();
}); });
+115
View File
@@ -0,0 +1,115 @@
// Bulk revocation through the certificate list.
const { test, expect } = require("@playwright/test");
function unique(prefix) {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
async function login(page, username) {
await page.goto("/login");
await page.getByRole("link", { name: "Sign in with default" }).click();
await page.getByPlaceholder("Enter any user/subject").fill(username);
await page.getByRole("button", { name: "Sign-in" }).click();
await expect(page.getByRole("heading", { name: "Vaults" })).toBeVisible();
}
async function api(page, method, url, body) {
return page.evaluate(
async ({ method, url, body }) => {
const response = await fetch(url, {
method,
headers: { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
return { status: response.status, json: text ? JSON.parse(text) : null };
},
{ method, url, body }
);
}
test("bulk revocation from the certificate list", async ({ page }) => {
const user = unique("bulk");
const vaultName = unique("Bulk Vault");
await login(page, user);
const vault = await api(page, "POST", "/api/vaults", { name: vaultName });
expect(vault.status).toBe(201);
const vaultId = vault.json.id;
const caKey = await api(page, "POST", `/api/vaults/${vaultId}/keys`, {
name: "Bulk CA key",
key_type: "rsa",
size: 2048,
});
expect(caKey.status).toBe(201);
const now = new Date();
const later = new Date(now.getTime() + 365 * 24 * 3600 * 1000);
const ca = await api(page, "POST", `/api/vaults/${vaultId}/certificates`, {
name: "Bulk CA",
key_id: caKey.json.id,
subject: [
{ name: "C", value: "DE" },
{ name: "O", value: "KCA E2E" },
{ name: "CN", value: "Bulk CA" },
],
not_before: now.toISOString(),
not_after: later.toISOString(),
extensions: {
basic_constraints: { critical: true, ca: true, path_length: null },
key_usage: { critical: true, usages: ["key_cert_sign", "crl_sign"] },
subject_key_identifier: { critical: false },
authority_key_identifier: { critical: false },
},
});
expect(ca.status).toBe(201);
const caId = ca.json.id;
async function leaf(name) {
const key = await api(page, "POST", `/api/vaults/${vaultId}/keys`, {
name: `${name} key`,
key_type: "rsa",
size: 2048,
});
expect(key.status).toBe(201);
const cert = await api(page, "POST", `/api/vaults/${vaultId}/certificates`, {
name,
key_id: key.json.id,
signer_id: caId,
subject: [
{ name: "C", value: "DE" },
{ name: "O", value: "KCA E2E" },
{ name: "CN", value: name },
],
not_before: now.toISOString(),
not_after: later.toISOString(),
extensions: {
basic_constraints: { critical: true, ca: false, path_length: null },
key_usage: { critical: true, usages: ["digital_signature"] },
subject_alt_name: { critical: false, entries: [{ type: "dns", value: name }] },
subject_key_identifier: { critical: false },
authority_key_identifier: { critical: false },
},
});
expect(cert.status).toBe(201);
return cert.json;
}
await leaf("bulk-one.example");
await leaf("bulk-two.example");
await page.goto(`/vaults/${vaultId}/certificates`);
await expect(page.getByRole("heading", { name: "Certificates" })).toBeVisible();
await page.evaluate(() => {
window.confirm = () => true;
});
await page.getByRole("button", { name: "Select all revocable" }).click();
await expect(page.getByText("2 selected")).toBeVisible();
await page.locator("select").first().selectOption("keyCompromise");
await page.getByRole("button", { name: "Revoke selected" }).click();
await expect(page.getByText(/Revoked 2 certificate\(s\); 0 already revoked; 0 error\(s\)/)).toBeVisible();
const revocations = await api(page, "GET", `/api/vaults/${vaultId}/certificates`);
const revoked = revocations.json.results.filter((cert) => cert.revoked);
expect(revoked).toHaveLength(2);
});
+10 -1
View File
@@ -54,6 +54,10 @@ test("core PKI workflow", async ({ page }) => {
await page.getByRole("button", { name: "Create certificate" }).click(); await page.getByRole("button", { name: "Create certificate" }).click();
await expect(page.getByText("CA certificate", { exact: false })).toBeVisible(); await expect(page.getByText("CA certificate", { exact: false })).toBeVisible();
const caUrl = page.url(); const caUrl = page.url();
await page.locator(".nav-tabs").getByRole("button", { name: "CA", exact: true }).click();
await (await field(page, "CA · default signing template")).selectOption("builtin:tls-server");
await page.getByRole("button", { name: "Save CA" }).click();
await expect(page.getByText("CA properties saved.")).toBeVisible();
// Leaf signed by the CA // Leaf signed by the CA
await page.goto(`${vaultUrl}/certificates/new`); await page.goto(`${vaultUrl}/certificates/new`);
@@ -63,10 +67,13 @@ test("core PKI workflow", async ({ page }) => {
await (await field(page, "Organization (O)")).fill("KCA E2E"); await (await field(page, "Organization (O)")).fill("KCA E2E");
await (await field(page, "Country (C)")).fill("DE"); await (await field(page, "Country (C)")).fill("DE");
await page.locator("#new-key").check(); await page.locator("#new-key").check();
await expect(page.getByText("CA default template: TLS server")).toBeVisible();
await page.getByRole("button", { name: "Apply CA default" }).click();
await expect(page.locator("label").filter({ hasText: "TLS server" }).locator("..").locator("input[type=checkbox]")).toBeChecked();
await page.locator("input[placeholder*='DNS:example.com']").fill(`DNS:${leafName}`); await page.locator("input[placeholder*='DNS:example.com']").fill(`DNS:${leafName}`);
await page.locator("label").filter({ hasText: "TLS server" }).locator("..").locator("input[type=checkbox]").check();
await page.getByRole("button", { name: "Create certificate" }).click(); await page.getByRole("button", { name: "Create certificate" }).click();
await expect(page.getByRole("heading", { name: leafName })).toBeVisible(); await expect(page.getByRole("heading", { name: leafName })).toBeVisible();
await page.locator(".nav-tabs").getByRole("button", { name: "Extensions", exact: true }).click();
await expect(page.getByText("serverAuth", { exact: false })).toBeVisible(); await expect(page.getByText("serverAuth", { exact: false })).toBeVisible();
// Search // Search
@@ -77,12 +84,14 @@ test("core PKI workflow", async ({ page }) => {
// Revoke // Revoke
await page.getByRole("link", { name: leafName, exact: true }).click(); await page.getByRole("link", { name: leafName, exact: true }).click();
await page.locator(".nav-tabs").getByRole("button", { name: "Revoke", exact: true }).click();
await page.getByRole("button", { name: "Revoke certificate" }).click(); await page.getByRole("button", { name: "Revoke certificate" }).click();
await expect(page.getByText("revoked", { exact: true })).toBeVisible(); await expect(page.getByText("revoked", { exact: true })).toBeVisible();
// CRL from the CA // CRL from the CA
await page.goto(caUrl); await page.goto(caUrl);
await expect(page.getByRole("heading", { name: caName })).toBeVisible(); await expect(page.getByRole("heading", { name: caName })).toBeVisible();
await page.locator(".nav-tabs").getByRole("button", { name: "CA", exact: true }).click();
await page.getByRole("button", { name: "Generate CRL" }).click(); await page.getByRole("button", { name: "Generate CRL" }).click();
await expect(page).toHaveURL(/\/crls\//); await expect(page).toHaveURL(/\/crls\//);
await expect(page.getByText(/CRL number \d+ · 1 revoked/)).toBeVisible(); await expect(page.getByText(/CRL number \d+ · 1 revoked/)).toBeVisible();
+85
View File
@@ -0,0 +1,85 @@
// Vault settings: explicit DN order, mandatory DN and template application.
const { test, expect } = require("@playwright/test");
function unique(prefix) {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
async function field(page, label) {
return page.locator("label").filter({ hasText: label }).first().locator("..").locator("input, select, textarea").first();
}
async function login(page, username) {
await page.goto("/login");
await page.getByRole("link", { name: "Sign in with default" }).click();
await page.getByPlaceholder("Enter any user/subject").fill(username);
await page.getByRole("button", { name: "Sign-in" }).click();
await expect(page.getByRole("heading", { name: "Vaults" })).toBeVisible();
}
async function api(page, method, url, body) {
return page.evaluate(
async ({ method, url, body }) => {
const response = await fetch(url, {
method,
headers: { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
return { status: response.status, json: text ? JSON.parse(text) : null };
},
{ method, url, body }
);
}
test("explicit DN order, mandatory DN and template apply", async ({ page }) => {
const user = unique("settings");
const vaultName = unique("Settings Vault");
const certName = unique("ordered.example");
await login(page, user);
await page.getByRole("button", { name: "New vault" }).click();
await page.getByPlaceholder("Production PKI").fill(vaultName);
await page.getByRole("button", { name: "Create vault" }).click();
await page.getByRole("link", { name: vaultName }).click();
const vaultUrl = page.url();
const vaultId = vaultUrl.split("/").pop();
const settings = await api(page, "GET", `/api/vaults/${vaultId}/settings`);
const updated = await api(page, "PATCH", `/api/vaults/${vaultId}/settings`, {
version: settings.json.version,
settings: { explicit_dn: ["CN", "O"], mandatory_dn: ["CN"] },
});
expect(updated.status).toBe(200);
await page.goto(`${vaultUrl}/certificates/new`);
await expect(page.getByText("Additional subject attributes")).toBeVisible();
const labels = await page.locator("label").allTextContents();
const cnIndex = labels.findIndex((label) => label.trim() === "Common name (CN)");
const oIndex = labels.findIndex((label) => label.trim() === "Organization (O)");
expect(cnIndex).toBeGreaterThan(-1);
expect(oIndex).toBeGreaterThan(cnIndex);
await expect(page.locator("label").filter({ hasText: "Country (C)" })).toHaveCount(0);
// Applying a template uses the server merge endpoint and updates typed fields.
await page.locator("select").nth(2).selectOption("builtin:tls-server");
await page.locator("select").nth(3).selectOption("extensions");
await page.getByRole("button", { name: "Apply", exact: true }).click();
await expect(page.locator("label").filter({ hasText: "TLS server" }).locator("..").locator("input[type=checkbox]")).toBeChecked();
await (await field(page, "Internal name")).fill(certName);
await (await field(page, "Organization (O)")).fill("KCA E2E");
await page.locator("#new-key").check();
await page.getByRole("button", { name: "Create certificate" }).click();
await expect(page.getByText("missing mandatory subject entries: CN")).toBeVisible();
await (await field(page, "Common name (CN)")).fill(certName);
await page.locator("label").filter({ hasText: "No well-defined expiration" }).locator("..").locator("input[type=checkbox]").check();
await page.getByRole("button", { name: "Create certificate" }).click();
await expect(page.getByRole("heading", { name: certName })).toBeVisible();
await expect(page.getByText("No expiration", { exact: true })).toBeVisible();
const cert = await api(page, "GET", `/api/vaults/${vaultId}/certificates`);
const created = cert.json.results.find((item) => item.name === certName);
// RFC 4514 renders the most specific RDN first, so CN follows O.
expect(created.subject).toBe(`O=KCA E2E,CN=${certName}`);
});
@@ -26,6 +26,7 @@ from ..service import (
chain_for_certificate, chain_for_certificate,
delete_certificate_cascade, delete_certificate_cascade,
generate_crl, generate_crl,
bulk_revoke_certificates,
issue_certificate, issue_certificate,
load_private_key, load_private_key,
preview_certificate, preview_certificate,
@@ -97,6 +98,25 @@ def register(app: KayaApp, kca: "Kca") -> None:
return return
await send_json(ctx, 201, await serialize_certificate_detail(cert, vault)) await send_json(ctx, 201, await serialize_certificate_detail(cert, vault))
@app.POST("/api/vaults/${vault_id}/certificates/bulk-revoke")
@operation(summary="Revoke several certificates", tags=["certificates"])
@require_auth(kca.oidc)
async def bulk_revoke(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
result = await bulk_revoke_certificates(vault, body)
except (ValidationError, ValueError) as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 200, result)
@app.POST("/api/vaults/${vault_id}/certificates/preview") @app.POST("/api/vaults/${vault_id}/certificates/preview")
@operation(summary="Preview/validate a certificate payload", tags=["certificates"]) @operation(summary="Preview/validate a certificate payload", tags=["certificates"])
@require_auth(kca.oidc) @require_auth(kca.oidc)
@@ -73,6 +73,17 @@ def merge_settings(current: Mapping[str, Any], patch: Mapping[str, Any]) -> Dict
if not (32 <= bits <= 160): if not (32 <= bits <= 160):
raise ValidationError("serial_len_bits must be between 32 and 160") raise ValidationError("serial_len_bits must be between 32 and 160")
merged["serial_len_bits"] = bits merged["serial_len_bits"] = bits
for key in ("explicit_dn", "mandatory_dn"):
entries = merged.get(key) or []
if not isinstance(entries, list):
raise ValidationError(f"{key} must be a list")
for entry in entries:
if not isinstance(entry, str) or not entry.strip():
raise ValidationError(f"{key} entries must be non-empty strings")
try:
name_core.resolve_oid(entry)
except ValueError as exc:
raise ValidationError(f"unknown subject attribute {entry!r}") from exc
return merged return merged
@@ -965,6 +976,63 @@ async def revoke_certificate(vault: Vault, cert: Certificate, payload: Mapping[s
return row return row
async def bulk_revoke_certificates(vault: Vault, payload: Mapping[str, Any]) -> Dict[str, Any]:
"""Revoke several certificates in one request, reporting per-item failures."""
raw_ids = payload.get("certificate_ids") or payload.get("ids") or []
if not isinstance(raw_ids, list):
raise ValidationError("certificate_ids must be a list")
seen: set[str] = set()
ids: list[str] = []
for raw in raw_ids:
text = str(raw)
if text in seen:
continue
seen.add(text)
ids.append(text)
if not ids:
raise ValidationError("select at least one certificate")
if len(ids) > 200:
raise ValidationError("at most 200 certificates can be revoked at once")
reason = str(payload.get("reason") or "unspecified")
if reason not in {item["name"] for item in crl_core.CRL_REASONS}:
raise ValidationError("unknown revocation reason")
invalidity = payload.get("invalidity_date")
if payload.get("revoked_at"):
parse_datetime(payload["revoked_at"], "revoked_at")
if invalidity:
parse_datetime(invalidity, "invalidity_date")
revocation_payload = {
"reason": reason,
"revoked_at": payload.get("revoked_at"),
"invalidity_date": invalidity,
}
revoked: list[Dict[str, Any]] = []
skipped: list[Dict[str, Any]] = []
errors: list[Dict[str, Any]] = []
for raw_id in ids:
parsed_id = parse_uuid(raw_id)
if parsed_id is None:
errors.append({"id": raw_id, "error": "invalid certificate id"})
continue
cert = await Certificate.get_or_none(id=parsed_id, vault_id=vault.id)
if cert is None:
errors.append({"id": raw_id, "error": "certificate not found"})
continue
existing = None
if cert.issuer_id is not None:
existing = await CertificateRevocation.get_or_none(ca_id=cert.issuer_id, serial_hex=cert.serial_hex)
if existing is not None:
skipped.append({"id": str(cert.id), "serial_hex": cert.serial_hex, "reason": "already revoked"})
continue
try:
row = await revoke_certificate(vault, cert, revocation_payload)
except (ValidationError, ValueError) as exc:
errors.append({"id": str(cert.id), "serial_hex": cert.serial_hex, "error": str(exc)})
continue
revoked.append({"id": str(cert.id), "serial_hex": cert.serial_hex, "revocation_id": str(row.id)})
return {"revoked": revoked, "skipped": skipped, "errors": errors}
async def generate_crl(vault: Vault, ca: Certificate, payload: Mapping[str, Any]) -> Crl: async def generate_crl(vault: Vault, ca: Certificate, payload: Mapping[str, Any]) -> Crl:
if not ca.is_ca: if not ca.is_ca:
raise ValidationError("the selected certificate is not a CA") raise ValidationError("the selected certificate is not a CA")
@@ -1323,6 +1391,7 @@ def _reason_from_bit(bit: int) -> str:
__all__ = [ __all__ = [
"DEFAULT_VAULT_SETTINGS", "DEFAULT_VAULT_SETTINGS",
"ValidationError", "ValidationError",
"bulk_revoke_certificates",
"chain_for_certificate", "chain_for_certificate",
"extension_search_text", "extension_search_text",
"delete_certificate_cascade", "delete_certificate_cascade",
+74 -1
View File
@@ -152,6 +152,30 @@ class RouteExtraTests(unittest.TestCase):
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["extensions"]["basic_constraints"]["ca"] is True assert response.json()["extensions"]["basic_constraints"]["ca"] is True
response = await client.post(
f"/api/vaults/{vault_id}/templates/builtin:tls-server/apply",
json={
"target": {
"subject": [{"name": "CN", "value": "keep.example.com"}],
"extensions": {},
},
"apply_subject": False,
"apply_extensions": True,
},
)
assert response.status_code == 200
merged = response.json()
assert merged["subject"] == [{"name": "CN", "value": "keep.example.com"}]
assert "serverAuth" in merged["extensions"]["extended_key_usage"]["usages"]
settings_response = await client.get(f"/api/vaults/{vault_id}/settings")
response = await client.patch(
f"/api/vaults/{vault_id}/settings",
json={
"settings": {"explicit_dn": ["CN", "not-a-real-attribute"]},
"version": settings_response.json()["version"],
},
)
assert response.status_code == 400
@async_test @async_test
async def test_revocation_and_crl(self) -> None: async def test_revocation_and_crl(self) -> None:
@@ -303,6 +327,55 @@ class VaultExportTests(unittest.TestCase):
names = archive.namelist() names = archive.namelist()
assert "manifest.json" in names assert "manifest.json" in names
assert any(name.startswith("certificates/") for name in names) assert any(name.startswith("certificates/") for name in names)
assert any(name.startswith("keys/") for name in names) key_files = [name for name in names if name.startswith("keys/") and name.endswith(".key")]
assert key_files
assert b"BEGIN ENCRYPTED PRIVATE KEY" in archive.read(key_files[0])
manifest = archive.read("manifest.json") manifest = archive.read("manifest.json")
assert b"Zip Cert" in manifest assert b"Zip Cert" in manifest
class BulkRevocationTests(unittest.TestCase):
@async_test
async def test_bulk_revoke_reports_skips_and_errors(self) -> None:
async with api_client() as client:
with oidc_user("alice"):
vault_id = await _vault(client)
ca_key = await _key(client, vault_id, "bulk ca")
ca = await _cert(client, vault_id, ca_key["id"], "Bulk CA", ca=True)
leaf1_key = await _key(client, vault_id, "leaf1")
leaf1 = await _cert(client, vault_id, leaf1_key["id"], "Leaf 1", signer_id=ca["id"])
leaf2_key = await _key(client, vault_id, "leaf2")
leaf2 = await _cert(client, vault_id, leaf2_key["id"], "Leaf 2", signer_id=ca["id"])
self_key = await _key(client, vault_id, "self")
self_signed = await _cert(client, vault_id, self_key["id"], "Self")
response = await client.post(
f"/api/vaults/{vault_id}/certificates/bulk-revoke",
json={
"certificate_ids": [leaf1["id"], leaf2["id"], self_signed["id"]],
"reason": "keyCompromise",
},
)
assert response.status_code == 200, response.text
result = response.json()
assert len(result["revoked"]) == 2
assert len(result["errors"]) == 1
assert result["errors"][0]["id"] == self_signed["id"]
response = await client.get(f"/api/vaults/{vault_id}/certificates/{leaf1['id']}")
assert response.json()["revoked"] is True
response = await client.post(
f"/api/vaults/{vault_id}/certificates/bulk-revoke",
json={"certificate_ids": [leaf1["id"], leaf2["id"]], "reason": "superseded"},
)
assert response.status_code == 200, response.text
assert len(response.json()["skipped"]) == 2
response = await client.post(
f"/api/vaults/{vault_id}/certificates/bulk-revoke",
json={"certificate_ids": [], "reason": "superseded"},
)
assert response.status_code == 400
response = await client.post(
f"/api/vaults/{vault_id}/certificates/bulk-revoke",
json={"certificate_ids": ["not-a-uuid"], "reason": "superseded"},
)
assert response.status_code == 200
assert response.json()["errors"][0]["error"] == "invalid certificate id"
+5
View File
@@ -297,6 +297,11 @@ pub async fn certificate(vault_id: &str, cert_id: &str) -> Result<CertificateDet
check(response).await check(response).await
} }
pub async fn bulk_revoke_certificates(vault_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/bulk-revoke"), Some(payload)).await?;
check(response).await
}
pub async fn preview_certificate(vault_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> { pub async fn preview_certificate(vault_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/preview"), Some(payload)).await?; let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/preview"), Some(payload)).await?;
check(response).await check(response).await
+2 -2
View File
@@ -1,6 +1,6 @@
//! JSON models mirroring the KCA API and small display helpers. //! JSON models mirroring the KCA API and small display helpers.
use serde::Deserialize; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
@@ -265,7 +265,7 @@ pub struct CertificateDetail {
pub cert_spec: Value, pub cert_spec: Value,
} }
#[derive(Debug, Clone, PartialEq, Deserialize, Default)] #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)]
pub struct DnEntry { pub struct DnEntry {
#[serde(default)] #[serde(default)]
pub oid: String, pub oid: String,
+93 -7
View File
@@ -37,6 +37,9 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
let tls_auth_text = create_signal(String::new()); let tls_auth_text = create_signal(String::new());
let new_name = create_signal(String::new()); let new_name = create_signal(String::new());
let new_comment = create_signal(String::new()); let new_comment = create_signal(String::new());
let active_tab = create_signal(initial_tab());
let info = create_signal(None::<String>);
let openssl_config = create_signal(String::new());
let load = move || { let load = move || {
spawn_local(async move { spawn_local(async move {
@@ -62,6 +65,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
validation_errors.set(value.validation_errors.clone()); validation_errors.set(value.validation_errors.clone());
purposes.set(value.purposes.clone()); purposes.set(value.purposes.clone());
chain.set(value.chain.clone()); chain.set(value.chain.clone());
openssl_config.set(value.openssl_config.clone());
cert.set(Some(value)); cert.set(Some(value));
if is_ca { if is_ca {
if let Ok(page) = api::templates(id).await { if let Ok(page) = api::templates(id).await {
@@ -111,7 +115,10 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
let payload = json!({"reason": rev_reason.get_clone()}); let payload = json!({"reason": rev_reason.get_clone()});
spawn_local(async move { spawn_local(async move {
match api::revoke_certificate(id, cert_id_value, &payload).await { match api::revoke_certificate(id, cert_id_value, &payload).await {
Ok(_) => load(), Ok(_) => {
info.set(Some("Certificate revoked.".to_string()));
load();
}
Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)), Err(message) => error.set(Some(message)),
} }
@@ -137,7 +144,10 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
let payload = json!({"name": new_name.get_clone(), "comment": new_comment.get_clone(), "version": value.summary.version}); let payload = json!({"name": new_name.get_clone(), "comment": new_comment.get_clone(), "version": value.summary.version});
spawn_local(async move { spawn_local(async move {
match api::update_certificate(id, cert_id_value, &payload).await { match api::update_certificate(id, cert_id_value, &payload).await {
Ok(updated) => cert.set(Some(updated)), Ok(updated) => {
cert.set(Some(updated));
info.set(Some("Certificate metadata saved.".to_string()));
}
Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)), Err(message) => error.set(Some(message)),
} }
@@ -171,7 +181,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
}); });
spawn_local(async move { spawn_local(async move {
match api::update_ca(id, cert_id_value, &payload).await { match api::update_ca(id, cert_id_value, &payload).await {
Ok(_) => {} Ok(_) => info.set(Some("CA properties saved.".to_string())),
Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)), Err(message) => error.set(Some(message)),
} }
@@ -217,6 +227,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
rev_serial.set(String::new()); rev_serial.set(String::new());
rev_invalidity.set(String::new()); rev_invalidity.set(String::new());
editing_rev.set(String::new()); editing_rev.set(String::new());
info.set(Some("Revocation list saved.".to_string()));
} }
Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)), Err(message) => error.set(Some(message)),
@@ -264,6 +275,20 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
}); });
}; };
let tab = move |key: &'static str, label: &'static str| -> View {
view! {
li(class="nav-item") {
button(class=if active_tab.get_clone() == key { "nav-link active" } else { "nav-link" },
on:click=move |_| {
active_tab.set(key.to_string());
if let Some(window) = web_sys::window() {
let _ = window.location().set_hash(key);
}
}) { (label) }
}
}
};
let content = move || { let content = move || {
if loading.get() { if loading.get() {
return spinner(); return spinner();
@@ -295,12 +320,24 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
div(class="col-md-3") { div(class="small text-secondary") { "Serial" } div(class="mono") { (summary.serial_hex.clone()) } } div(class="col-md-3") { div(class="small text-secondary") { "Serial" } div(class="mono") { (summary.serial_hex.clone()) } }
div(class="col-md-3") { div(class="small text-secondary") { "Signature" } div() { (value.signature_algorithm.clone()) } } div(class="col-md-3") { div(class="small text-secondary") { "Signature" } div() { (value.signature_algorithm.clone()) } }
div(class="col-md-3") { div(class="small text-secondary") { "Not before" } div() { (fmt_date(&summary.not_before)) } } div(class="col-md-3") { div(class="small text-secondary") { "Not before" } div() { (fmt_date(&summary.not_before)) } }
div(class="col-md-3") { div(class="small text-secondary") { "Not after" } div() { (fmt_date(&summary.not_after)) } } div(class="col-md-3") { div(class="small text-secondary") { "Not after" } div() { (if summary.no_expiration { "No expiration".to_string() } else { fmt_date(&summary.not_after) }) } }
div(class="col-md-3") { div(class="small text-secondary") { "Key" } div() { (value.key_name.clone().unwrap_or_else(|| "not in vault".to_string())) } } div(class="col-md-3") { div(class="small text-secondary") { "Key" } div() { (value.key_name.clone().unwrap_or_else(|| "not in vault".to_string())) } }
div(class="col-md-3") { div(class="small text-secondary") { "Status" } (if summary.revoked { view! { span(class="badge badge-danger") { "revoked" } } } else { view! { span(class="badge badge-soft") { "valid" } } }) } div(class="col-md-3") { div(class="small text-secondary") { "Status" } (if summary.revoked { view! { span(class="badge badge-danger") { "revoked" } } } else { view! { span(class="badge badge-soft") { "valid" } } }) }
} }
} }
} }
ul(class="nav nav-tabs mb-3") {
(tab("overview", "Overview"))
(tab("extensions", "Extensions"))
(tab("validation", "Validation"))
(tab("export", "Export"))
(tab("metadata", "Metadata"))
(tab("revoke", "Revoke"))
(tab("transform", "Transform"))
(if is_ca { tab("ca", "CA") } else { view! {} })
}
(if active_tab.get_clone() == "overview" {
view! {
div(class="row g-3 mb-3") { div(class="row g-3 mb-3") {
div(class="col-12 col-lg-6") { div(class="col-12 col-lg-6") {
div(class="card") { div(class="card") {
@@ -319,10 +356,14 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
} }
} }
} }
}
} else { view! {} }) (if active_tab.get_clone() == "extensions" {
view! {
div(class="card mb-3") { div(class="card mb-3") {
div(class="card-header d-flex justify-content-between align-items-center") { div(class="card-header d-flex justify-content-between align-items-center") {
span() { "Extensions" } span() { "Extensions" }
(copy_button(value.openssl_config.clone(), "Copy OpenSSL config")) (copy_button(openssl_config.get_clone(), "Copy OpenSSL config"))
} }
div(class="table-responsive") { div(class="table-responsive") {
table(class="table table-sm ext-table mb-0") { table(class="table table-sm ext-table mb-0") {
@@ -331,6 +372,10 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
} }
} }
} }
}
} else { view! {} }) (if active_tab.get_clone() == "validation" {
view! {
div(class="card mb-3") { div(class="card mb-3") {
div(class="card-header") { "Validation" } div(class="card-header") { "Validation" }
div(class="card-body") { div(class="card-body") {
@@ -342,6 +387,10 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
div(class="mt-2") { Indexed(list=purposes, view=|p| view! { span(class="badge badge-soft me-1") { (p) } }) } div(class="mt-2") { Indexed(list=purposes, view=|p| view! { span(class="badge badge-soft me-1") { (p) } }) }
} }
} }
}
} else { view! {} }) (if active_tab.get_clone() == "export" {
view! {
div(class="row g-3") { div(class="row g-3") {
div(class="col-12 col-lg-6") { div(class="col-12 col-lg-6") {
div(class="card mb-3") { div(class="card mb-3") {
@@ -372,6 +421,14 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
button(class="btn btn-primary", on:click=do_export) { "Download" } button(class="btn btn-primary", on:click=do_export) { "Download" }
} }
} }
}
}
}
} else { view! {} })
(if active_tab.get_clone() == "metadata" {
view! {
div(class="row g-3") {
div(class="col-12 col-lg-6") {
div(class="card") { div(class="card") {
div(class="card-header") { "Metadata" } div(class="card-header") { "Metadata" }
div(class="card-body") { div(class="card-body") {
@@ -387,14 +444,28 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
} }
} }
} }
}
}
} else { view! {} })
(if active_tab.get_clone() == "revoke" {
view! {
div(class="row g-3") {
div(class="col-12 col-lg-6") { div(class="col-12 col-lg-6") {
div(class="card mb-3") { div(class="card") {
div(class="card-header") { "Revoke" } div(class="card-header") { "Revoke" }
div(class="card-body") { div(class="card-body") {
select(class="form-select mb-2", bind:value=rev_reason) { (reason_options()) } select(class="form-select mb-2", bind:value=rev_reason) { (reason_options()) }
button(class="btn btn-warning", disabled=summary.revoked, on:click=revoke) { "Revoke certificate" } button(class="btn btn-warning", disabled=summary.revoked, on:click=revoke) { "Revoke certificate" }
} }
} }
}
}
}
} else { view! {} })
(if active_tab.get_clone() == "transform" {
view! {
div(class="row g-3") {
div(class="col-12 col-lg-6") {
div(class="card") { div(class="card") {
div(class="card-header") { "Transform" } div(class="card-header") { "Transform" }
div(class="card-body d-grid gap-2") { div(class="card-body d-grid gap-2") {
@@ -406,7 +477,9 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
} }
} }
} }
(if is_ca { }
} else { view! {} })
(if is_ca && active_tab.get_clone() == "ca" {
view! { view! {
div(class="card mt-3") { div(class="card mt-3") {
div(class="card-header") { "CA operations" } div(class="card-header") { "CA operations" }
@@ -513,11 +586,24 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
(app_nav("/", Some(id), nav)) (app_nav("/", Some(id), nav))
main(class="container page-shell py-4") { main(class="container page-shell py-4") {
(toast_error(error)) (toast_error(error))
(crate::components::toast::toast(info, true))
(content) (content)
} }
} }
} }
fn initial_tab() -> String {
let hash = web_sys::window()
.and_then(|window| window.location().hash().ok())
.unwrap_or_default();
let hash = hash.trim_start_matches('#');
match hash {
"overview" | "extensions" | "validation" | "export" | "metadata" | "revoke"
| "transform" | "ca" => hash.to_string(),
_ => "overview".to_string(),
}
}
fn reason_options() -> Vec<View> { fn reason_options() -> Vec<View> {
[ [
"unspecified", "unspecified",
File diff suppressed because it is too large Load Diff
+172 -11
View File
@@ -1,12 +1,16 @@
//! Certificate list, flat or as an issuer tree. //! Certificate list, flat or as an issuer tree.
use std::collections::HashMap; use std::collections::HashMap;
use serde_json::json;
use sycamore::prelude::*; use sycamore::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use crate::api; use crate::api;
use crate::components::nav::{app_nav, spinner, whoami}; use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{page_header, toast_error}; use crate::components::ui::{page_header, toast_error};
use crate::dom;
use crate::model::{fmt_date, fmt_fp, status_badge, CertificateSummary, Me}; use crate::model::{fmt_date, fmt_fp, status_badge, CertificateSummary, Me};
pub fn CertificatesPage(vault_id: String) -> View { pub fn CertificatesPage(vault_id: String) -> View {
@@ -17,6 +21,11 @@ pub fn CertificatesPage(vault_id: String) -> View {
let loading = create_signal(true); let loading = create_signal(true);
let filter = create_signal(String::new()); let filter = create_signal(String::new());
let plain = create_signal(false); let plain = create_signal(false);
let selected = create_signal(Vec::<String>::new());
let bulk_reason = create_signal("unspecified".to_string());
let bulk_invalidity = create_signal(String::new());
let bulk_busy = create_signal(false);
let info = create_signal(None::<String>);
spawn_local(async move { spawn_local(async move {
match api::me().await { match api::me().await {
@@ -50,6 +59,97 @@ pub fn CertificatesPage(vault_id: String) -> View {
} }
}); });
let refresh = move || {
spawn_local(async move {
if let Ok(page) = api::certificates(id).await {
if certificates.is_alive() {
certificates.set(page.results);
}
}
});
};
let toggle = move |event: web_sys::Event| {
if let Some(target) = event
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
if let Some(cert_id) = target.get_attribute("data-cert-id") {
let mut current = selected.get_clone();
if target.checked() {
if !current.contains(&cert_id) {
current.push(cert_id);
}
} else {
current.retain(|value| value != &cert_id);
}
selected.set(current);
}
}
};
let select_all = move |_| {
let all = certificates
.get_clone()
.into_iter()
.filter(|cert| cert.issuer_id.is_some())
.map(|cert| cert.id)
.collect::<Vec<_>>();
selected.set(all);
};
let clear_selection = move |_| selected.set(Vec::new());
let bulk_revoke = move |_| {
let ids = selected.get_clone();
if ids.is_empty() {
error.set(Some("select at least one certificate".to_string()));
return;
}
if !dom::confirm(&format!("Revoke {} selected certificate(s)?", ids.len())) {
return;
}
let payload = json!({
"certificate_ids": ids,
"reason": bulk_reason.get_clone(),
"invalidity_date": if bulk_invalidity.get_clone().is_empty() {
serde_json::Value::Null
} else {
json!(bulk_invalidity.get_clone())
},
});
bulk_busy.set(true);
spawn_local(async move {
match api::bulk_revoke_certificates(id, &payload).await {
Ok(value) => {
let revoked = value
.get("revoked")
.and_then(|v| v.as_array())
.map(|v| v.len())
.unwrap_or(0);
let skipped = value
.get("skipped")
.and_then(|v| v.as_array())
.map(|v| v.len())
.unwrap_or(0);
let errors = value
.get("errors")
.and_then(|v| v.as_array())
.map(|v| v.len())
.unwrap_or(0);
info.set(Some(format!(
"Revoked {revoked} certificate(s); {skipped} already revoked; {errors} error(s)."
)));
selected.set(Vec::new());
bulk_invalidity.set(String::new());
refresh();
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
if bulk_busy.is_alive() {
bulk_busy.set(false);
}
});
};
let content = move || { let content = move || {
if loading.get() { if loading.get() {
return spinner(); return spinner();
@@ -67,10 +167,11 @@ pub fn CertificatesPage(vault_id: String) -> View {
|| cert.fingerprint_sha256.to_lowercase().contains(&query) || cert.fingerprint_sha256.to_lowercase().contains(&query)
}) })
.collect(); .collect();
let chosen = selected.get_clone();
let html = if plain.get() { let html = if plain.get() {
flat_html(&visible, id) flat_html(&visible, id, &chosen)
} else { } else {
tree_html(&visible, id) tree_html(&visible, id, &chosen)
}; };
view! { view! {
(page_header( (page_header(
@@ -90,7 +191,31 @@ pub fn CertificatesPage(vault_id: String) -> View {
bind:value=filter) bind:value=filter)
} }
} }
div(class="card", dangerously_set_inner_html=html) {} div(class="card mb-3") {
div(class="card-body") {
div(class="d-flex flex-wrap gap-2 align-items-end") {
div(class="me-auto") {
div(class="small text-secondary") { (format!("{} selected", selected.get_clone().len())) }
div(class="d-flex gap-2 mt-1") {
button(class="btn btn-sm btn-outline-light", on:click=select_all) { "Select all revocable" }
button(class="btn btn-sm btn-outline-light", on:click=clear_selection) { "Clear" }
}
}
div() {
label(class="form-label small mb-1") { "Reason" }
select(class="form-select form-select-sm", bind:value=bulk_reason) { (reason_options()) }
}
div() {
label(class="form-label small mb-1") { "Invalid since (optional)" }
input(class="form-control form-control-sm", r#type="datetime-local", bind:value=bulk_invalidity)
}
button(class="btn btn-warning", disabled=bulk_busy.get() || selected.get_clone().is_empty(), on:click=bulk_revoke) {
(if bulk_busy.get() { "Revoking…" } else { "Revoke selected" })
}
}
}
}
div(class="card", on:change=toggle, dangerously_set_inner_html=html) {}
} }
}; };
@@ -103,12 +228,13 @@ pub fn CertificatesPage(vault_id: String) -> View {
(app_nav("/", Some(id), nav)) (app_nav("/", Some(id), nav))
main(class="container page-shell py-4") { main(class="container page-shell py-4") {
(toast_error(error)) (toast_error(error))
(crate::components::toast::toast(info, true))
(content) (content)
} }
} }
} }
fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String { fn flat_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) -> String {
if rows.is_empty() { if rows.is_empty() {
return "<p class=\"empty-state\">No certificates match.</p>".to_string(); return "<p class=\"empty-state\">No certificates match.</p>".to_string();
} }
@@ -116,7 +242,8 @@ fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String {
for cert in rows { for cert in rows {
let (badge_class, badge_label) = status_badge(cert); let (badge_class, badge_label) = status_badge(cert);
body.push_str(&format!( body.push_str(&format!(
"<tr><td><a href=\"/vaults/{}/{}\">{}</a><div class=\"item-sub\">{}</div></td><td>{}</td><td>{}</td><td class=\"fingerprint\">{}</td><td>{}</td><td><span class=\"badge {}\">{}</span>{}</td><td class=\"fingerprint\">{}</td></tr>", "<tr><td>{}</td><td><a href=\"/vaults/{}/{}\">{}</a><div class=\"item-sub\">{}</div></td><td>{}</td><td>{}</td><td class=\"fingerprint\">{}</td><td>{}</td><td><span class=\"badge {}\">{}</span>{}</td><td class=\"fingerprint\">{}</td></tr>",
checkbox(cert, selected),
escape(vault_id), escape(vault_id),
escape(&cert.id), escape(&cert.id),
escape(&cert.name), escape(&cert.name),
@@ -132,11 +259,11 @@ fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String {
)); ));
} }
format!( format!(
"<div class=\"table-responsive\"><table class=\"table table-hover align-middle mb-0\"><thead><tr><th>Name</th><th>Issuer</th><th>Type</th><th>Serial</th><th>Expires</th><th>Status</th><th>SHA-256</th></tr></thead><tbody>{body}</tbody></table></div>" "<div class=\"table-responsive\"><table class=\"table table-hover align-middle mb-0\"><thead><tr><th></th><th>Name</th><th>Issuer</th><th>Type</th><th>Serial</th><th>Expires</th><th>Status</th><th>SHA-256</th></tr></thead><tbody>{body}</tbody></table></div>"
) )
} }
fn tree_html(rows: &[CertificateSummary], vault_id: &str) -> String { fn tree_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) -> String {
if rows.is_empty() { if rows.is_empty() {
return "<p class=\"empty-state\">No certificates match.</p>".to_string(); return "<p class=\"empty-state\">No certificates match.</p>".to_string();
} }
@@ -157,7 +284,7 @@ fn tree_html(rows: &[CertificateSummary], vault_id: &str) -> String {
roots.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); roots.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
let mut html = String::from("<div class=\"card-body\"><ul class=\"cert-tree\">"); let mut html = String::from("<div class=\"card-body\"><ul class=\"cert-tree\">");
for root in &roots { for root in &roots {
html.push_str(&node_html(root, &children, vault_id)); html.push_str(&node_html(root, &children, vault_id, selected));
} }
html.push_str("</ul></div>"); html.push_str("</ul></div>");
html html
@@ -167,6 +294,7 @@ fn node_html(
cert: &CertificateSummary, cert: &CertificateSummary,
children: &HashMap<String, Vec<CertificateSummary>>, children: &HashMap<String, Vec<CertificateSummary>>,
vault_id: &str, vault_id: &str,
selected: &[String],
) -> String { ) -> String {
let (badge_class, badge_label) = status_badge(cert); let (badge_class, badge_label) = status_badge(cert);
let sub = if cert.subject == cert.issuer_name { let sub = if cert.subject == cert.issuer_name {
@@ -175,7 +303,8 @@ fn node_html(
format!("issuer: {}", cert.issuer_name) format!("issuer: {}", cert.issuer_name)
}; };
let mut row = format!( let mut row = format!(
"<div class=\"cert-row\"><a class=\"item-name\" href=\"/vaults/{}/{}\">{}</a>{} <span class=\"badge {}\">{}</span><span class=\"item-sub\">{}</span><span class=\"item-sub\">expires {}</span><span class=\"item-sub fingerprint\">{}</span></div>", "<div class=\"cert-row\">{}<a class=\"item-name\" href=\"/vaults/{}/{}\">{}</a>{} <span class=\"badge {}\">{}</span><span class=\"item-sub\">{}</span><span class=\"item-sub\">expires {}</span><span class=\"item-sub fingerprint\">{}</span></div>",
checkbox(cert, selected),
escape(vault_id), escape(vault_id),
escape(&cert.id), escape(&cert.id),
escape(&cert.name), escape(&cert.name),
@@ -183,7 +312,7 @@ fn node_html(
badge_class, badge_class,
badge_label, badge_label,
escape(&sub), escape(&sub),
fmt_date(&cert.not_after), if cert.no_expiration { "No expiration".to_string() } else { fmt_date(&cert.not_after) },
fmt_fp(&cert.fingerprint_sha256), fmt_fp(&cert.fingerprint_sha256),
); );
let kids = children.get(&cert.id); let kids = children.get(&cert.id);
@@ -191,7 +320,7 @@ fn node_html(
if !kids.is_empty() { if !kids.is_empty() {
row = format!( row = format!(
"<li><details class=\"cert-node\" open><summary>{row}</summary><ul class=\"cert-tree\">{}</ul></details></li>", "<li><details class=\"cert-node\" open><summary>{row}</summary><ul class=\"cert-tree\">{}</ul></details></li>",
kids.iter().map(|child| node_html(child, children, vault_id)).collect::<String>() kids.iter().map(|child| node_html(child, children, vault_id, selected)).collect::<String>()
); );
} else { } else {
row = format!("<li>{row}</li>"); row = format!("<li>{row}</li>");
@@ -202,6 +331,38 @@ fn node_html(
row row
} }
fn checkbox(cert: &CertificateSummary, selected: &[String]) -> String {
if cert.issuer_id.is_none() {
return "<input class=\"form-check-input\" type=\"checkbox\" disabled title=\"Self-signed certificates cannot be revoked\">".to_string();
}
let checked = if selected.contains(&cert.id) {
" checked"
} else {
""
};
format!(
"<input class=\"form-check-input\" type=\"checkbox\" data-cert-id=\"{}\"{}>",
escape(&cert.id),
checked
)
}
fn reason_options() -> Vec<View> {
[
"unspecified",
"keyCompromise",
"CACompromise",
"affiliationChanged",
"superseded",
"cessationOfOperation",
"certificateHold",
"privilegeWithdrawn",
]
.iter()
.map(|reason| view! { option(value=*reason) { (*reason) } })
.collect::<Vec<View>>()
}
fn escape(value: &str) -> String { fn escape(value: &str) -> String {
value value
.replace('&', "&amp;") .replace('&', "&amp;")