Merge branch 'phase/012-014' (Schluessel, Personen, Private Key)

# Conflicts:
#	src/components/vorgang/useVorgang.ts
#	src/worker/handler.ts
This commit is contained in:
2026-10-12 10:54:09 +02:00
76 changed files with 5899 additions and 325 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 152 KiB

+45 -8
View File
@@ -1,23 +1,60 @@
# #012 Schlüssel erzeugen, importieren, exportieren
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.2.0
**Labels:** backend, ui
**Geschätzt:** 4 h **Tatsächlich:** –
**Geschätzt:** 4 h **Tatsächlich:** 0 h 25 min
## Ziel
Schlüsselliste und -formular: erzeugen (ed25519, rsa-4096), importieren (Public oder Paar), exportieren; Kategorien, Tags, Ablaufdatum.
## Aufgaben
- [ ] Liste mit Filter
- [ ] Erzeugen-Dialog
- [ ] Import mit Prüfung
- [ ] Export Public Key
- [ ] Schlüsseldetail-Gerüst mit Verwendungen
- [x] Liste mit Filter
- [x] Erzeugen-Dialog
- [x] Import mit Prüfung
- [x] Export Public Key
- [x] Schlüsseldetail-Gerüst mit Verwendungen
## Akzeptanzkriterien
- [ ] Private Keys erscheinen nie in Listen-Antworten
- [x] Private Keys erscheinen nie in Listen-Antworten
## Abhängigkeiten
#006
## Notizen / Entscheidungen
- **Migration `20261012100000_schluessel_vorgaenge`:** Vorgangsarten `SCHLUESSEL_ERZEUGEN`,
`SCHLUESSEL_IMPORTIEREN`, `PRIVATE_KEY_LOESCHEN` (für #014 gleich mit), CHECK
`schluessel_slug_gueltig` (`^[a-z0-9-]{1,64}$`, der Slug landet in Dateinamen) und
`schluessel_geloescht_ohne_private_key`.
- **Erzeugen und Paar-Import laufen im Worker** (`src/worker/ablaeufe/schluessel.ts`). Das Web
prüft nur Metadaten (Slug frei, Person/Vorlage vorhanden, Ablauf nicht in der Vergangenheit)
und reiht den Vorgang ein. Passphrase und importierter Private Key reisen wie das
Bootstrap-Passwort mit `SSHKV_APP_SECRET` verschlüsselt im Job (`geheim`), nie in
`Vorgang.eingabe`; der Worker löscht den Job danach. Der Worker prüft die Metadaten erneut.
- **Kategorie:** Erzeugen und Paar-Import legen immer `DIENST` an. Personen-Keys speichert sshkv
nie mit Private Key (DB-CHECK), Deployment-Keys verwaltet der Worker selbst. Der
Public-Key-Import kennt `PERSON` und `DIENST`.
- **Passphrase:** beim Erzeugen optional; gespeichert wird dann der passphrasengeschützte
OpenSSH-Key (`passphraseGeschuetzt = true`). Beim Import dient sie nur zum Lesen von Public Key
und Fingerprint und wird verworfen; gespeichert wird der Key so, wie er kam. Hinweis in der
Oberfläche: „Für automatische Anmeldungen (Host-zu-Host) ungeeignet.“
- **Typ:** `RSA4096` nur bei genau 4096 Bit, sonst `ANDERER` (z. B. RSA-3072, ecdsa). Die RSA-
Mindestlänge 2048 gilt beim Import.
- **Slug:** aus dem Zweck abgeleitet (Umlaute gefaltet), bei Kollision `-2`, `-3` …; ein
ausdrücklich gewünschter Kurzname muss frei sein. Slug, Typ und Kommentar sind danach nicht
mehr änderbar – sie stehen schon in Dateinamen und authorized_keys-Zeilen.
- **Status von Hand** nur `AKTIV` ↔ `ABGELAUFEN`; `ROTIERT`/`WIDERRUFEN` setzen #023/#024,
Deployment-Keys sind ausgenommen (sonst erzeugte der Worker beim Start einen neuen).
- **Ablaufdatum** ohne Angabe aus `Vorlage.standardLaufzeitTage`.
- **„Private Key gespeichert?“** liest das Web aus `masterKeyVersion` (der CHECK
`schluessel_umschlag_vollstaendig` koppelt sie an das Chiffrat) – das Chiffrat wird im Web
nie selektiert.
- **Grenze abgesichert:** `tests/schluessel-grenze.test.ts` verlangt für jeden zeilenliefernden
`prisma.schluessel.*`-Aufruf außerhalb von `src/worker` ein `select` und verbietet
`schluessel: true` als Relation; `tests/schluessel.test.ts` prüft, dass weder Vorgang,
Protokoll noch pg-boss-Job nach Erzeugen/Import den Key oder die Passphrase enthalten.
- **Platzhalter** für #016/#017: `src/app/(app)/schluessel/[id]/PublicAusrollenAktion.tsx` und
`PrivateAusrollenAktion.tsx`, Props `{ schluesselId: string; darf: boolean }`, rendern `null`.
- Personen-Keys zeigen im Detail nur die Karte „Public Key ausgerollt auf“ (eine Private-Karte
wäre immer leer).
- Prototyp `/schluessel/beispiel` samt `src/beispiel/schluessel.ts` entfernt; die Übersicht
(noch Prototyp, #026) verlinkt auf `/schluessel`.
+26 -5
View File
@@ -1,20 +1,41 @@
# #013 Personen und Self-Service
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.2.0
**Labels:** backend, ui
**Geschätzt:** 2 h **Tatsächlich:** –
**Geschätzt:** 2 h **Tatsächlich:** 0 h 10 min
## Ziel
Personen verwalten; jeder angemeldete Benutzer kann eigene Public Keys hinterlegen, wirksam erst nach Zuweisung.
## Aufgaben
- [ ] Personenliste/-detail
- [ ] Seite „Meine Schlüssel“
- [x] Personenliste/-detail
- [x] Seite „Meine Schlüssel“
## Akzeptanzkriterien
- [ ] Leser kann nur eigene Keys hinterlegen, nicht zuweisen
- [x] Leser kann nur eigene Keys hinterlegen, nicht zuweisen
## Abhängigkeiten
#012
## Notizen / Entscheidungen
- **Personen pflegt, wer Schlüssel verwaltet** (Recht `schluesselVerwalten`, Admin und Operator) –
ein eigenes Recht wäre nur ein Alias. Lesen dürfen alle Rollen.
- **Archivieren statt Löschen:** die Schlüssel bleiben der Person zugeordnet (wer hatte bis wann
welchen Key?), die Kontoverknüpfung entfällt, damit der Self-Service nicht bei einer
archivierten Person landet. Die Notiz steht nicht im Protokoll, nur dass sie sich änderte.
- **Self-Service `/meine-schluessel`** (Recht `eigeneSchluesselHinterlegen`, alle Rollen,
Navigation „Meine Schlüssel“): beim ersten Aufruf wird die eigene Person angelegt oder
verknüpft – per `benutzerId`, sonst per E-Mail, aber **nur bei vom Anbieter bestätigter
Adresse** und genau einer freien, nicht archivierten Person. Dafür neue Spalte
`Benutzer.emailBestaetigt` (Migration `20261012103000_benutzer_email_bestaetigt`), gesetzt bei
jeder OIDC-Anmeldung. Ohne sie könnte jemand mit selbst gewählter Adresse die Keys einer fremden
Person sehen.
- Hinterlegte Keys: Kategorie `PERSON`, `selbstHinterlegt = true`, Protokoll
`schluessel.selbst_hinterlegt`. Hinweis in der Oberfläche: „Wirksam erst, wenn ein Operator
oder Admin den Schlüssel einem Host zuweist.“
- **Entfernen** nur eigene, selbst hinterlegte Keys ohne jede Verwendung (auch keine entfernte –
der Verlauf am Key bleibt). Das Löschen ist bedingt (`deleteMany` mit „keine Verwendungen“),
damit eine gleichzeitige Zuweisung nicht per Cascade verschwindet. Fremde IDs gelten als „nicht
vorhanden“. Tests: `tests/personen.test.ts`.
- Die Services nehmen den angemeldeten Benutzer entgegen und leiten die Person selbst ab; die
Actions reichen keine `personId` von außen durch (struktureller Test).
+37 -5
View File
@@ -1,20 +1,52 @@
# #014 Private Key anzeigen und herunterladen
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.2.0
**Labels:** sicherheit
**Geschätzt:** 2 h **Tatsächlich:** –
**Geschätzt:** 2 h **Tatsächlich:** 0 h 10 min
## Ziel
Nur Admin, nur nach Re-Auth, immer protokolliert; Entschlüsselung im Worker.
## Aufgaben
- [ ] Einmal-Abholung
- [ ] Option „Private Key nach Ausrollen löschen“
- [x] Einmal-Abholung
- [x] Option „Private Key nach Ausrollen löschen“
## Akzeptanzkriterien
- [ ] Kein Caching, kein Logging
- [x] Kein Caching, kein Logging
## Abhängigkeiten
#012, #004
## Notizen / Entscheidungen
- **Ablauf:** Web legt einen Vorgang `PRIVATE_KEY_ABHOLEN` an (Protokoll `privatekey.angefordert`)
→ der Worker öffnet den Umschlag und legt den Key mit `SSHKV_APP_SECRET` verschlüsselt in
`Vorgang.ergebnis` ab (`{ abholung: "v1.…" }`) → das Web holt ihn **genau einmal** ab: ein
einziges `UPDATE … FROM (SELECT … FOR UPDATE) RETURNING` liest das alte Ergebnis und setzt
`{ abgeholt: true }`; fünf gleichzeitige Abholungen ergeben genau eine (Test). Abholen darf
nur, wer angefordert hat. Protokoll `privatekey.angezeigt` / `privatekey.heruntergeladen` mit
Fingerprint, nie mit Key.
- **Die Status-Schnittstelle** `/api/vorgaenge/[id]` zeigt für eine bereitliegende Abholung nur
`{ bereit: true }`, nie das Chiffrat.
- **Verfall nach 2 Minuten** (`ABHOLFRIST_MS` in `src/lib/schluessel.ts`), dreifach abgesichert:
der Worker räumt alle 30 s und vor jeder neuen Abholung auf (`abholungenAufraeumen`, ein
einziges UPDATE → `{ verfallen: true }`), und das Web gibt eine ältere Abholung nicht mehr
heraus (überschreibt sie aber trotzdem). Ein Worker-Takt statt pg-boss-Zeitplan: dessen Raster
ist eine Minute.
- **Anzeige** im Dialog (Monospace, Kopier-Knopf, „Wird nicht erneut angezeigt“); der Key lebt
nur im Zustand des Dialogs und wird beim Schließen verworfen. **Download** als Datei `<slug>`
über `POST /api/private-key/[vorgangId]` (Formular, kein GET – ein Link oder Vorabruf würde die
einmalige Abholung verbrauchen), `Content-Disposition: attachment`,
`Cache-Control: no-store`. Der Route Handler prüft selbst Recht, Bestätigung und Herkunft:
wegen `Referrer-Policy: no-referrer` senden Browser bei Formular-POSTs `Origin: null`,
maßgeblich ist deshalb `Sec-Fetch-Site: same-origin` (oder ein passender Origin).
- **Re-Auth:** Fehlt die frische Bestätigung, leitet die Oberfläche nach
`/bestaetigen?ziel=/schluessel/<id>?privatekey=1` und öffnet danach den Dialog wieder. Der
Mock-OIDC liefert `auth_time` nur, wenn man es in die Claims schreibt – die E2E tut das.
- **Löschen** („Private Key aus SSHKeyVault löschen“): neues Recht `privateKeyLoeschen` (nur
Admin, mit Bestätigung), ausgeführt als Worker-Vorgang `PRIVATE_KEY_LOESCHEN`
(`privateKeyVerwerfen` in `src/worker/geheimnisse.ts`), damit nur diese Datei die
Chiffrat-Felder anfasst – auch zum Leeren. Setzt Chiffrat, DEK und Version auf `null` und
`privatGeloeschtAm`; Protokoll `privatekey.geloescht`. CHECK
`schluessel_geloescht_ohne_private_key` (Migration aus #012).
- **Deployment-Keys** gibt sshkv weder heraus noch löscht es deren Private Key: der Key ist root
auf allen Hosts, und ohne ihn käme der Worker nirgends mehr hin. Im Notfall wird rotiert.
@@ -0,0 +1,19 @@
-- Schluessel erzeugen, importieren, Private Key loeschen (#012, #014).
--
-- Neue Vorgangsarten: Erzeugen und Import eines Paars laufen im Worker, weil nur er den
-- Master-Key hat. Das Loeschen eines gespeicherten Private Keys laeuft ebenfalls dort, damit
-- die Grenze „nur src/worker/geheimnisse.ts fasst das Chiffrat an“ einfach bleibt.
ALTER TYPE "VorgangsArt" ADD VALUE 'SCHLUESSEL_ERZEUGEN';
ALTER TYPE "VorgangsArt" ADD VALUE 'SCHLUESSEL_IMPORTIEREN';
ALTER TYPE "VorgangsArt" ADD VALUE 'PRIVATE_KEY_LOESCHEN';
-- Der Slug landet in Dateinamen (<slug>.pub, ~/.ssh/id_<slug>) und Kommentaren. Was hier
-- nicht passt, kommt nie bis in einen Pfad oder ein Shell-Skript.
ALTER TABLE "Schluessel" ADD CONSTRAINT "schluessel_slug_gueltig" CHECK (
"slug" ~ '^[a-z0-9-]{1,64}$'
);
-- „Private Key geloescht“ und ein gespeichertes Chiffrat schliessen sich aus (#014).
ALTER TABLE "Schluessel" ADD CONSTRAINT "schluessel_geloescht_ohne_private_key" CHECK (
"privatGeloeschtAm" IS NULL OR "privatVerschluesselt" IS NULL
);
@@ -0,0 +1,4 @@
-- Self-Service „Meine Schluessel“ (#013): ein Konto wird per E-Mail nur dann mit einer
-- vorhandenen Person verknuepft, wenn der OIDC-Anbieter die Adresse bestaetigt hat. Sonst
-- koennte jemand mit selbst gewaehlter Adresse die Schluessel einer fremden Person sehen.
ALTER TABLE "Benutzer" ADD COLUMN "emailBestaetigt" BOOLEAN NOT NULL DEFAULT false;
+9
View File
@@ -104,6 +104,12 @@ enum VorgangsArt {
ROTATION
WIDERRUF
PRIVATE_KEY_ABHOLEN
/// Schluesselpaar im Worker erzeugen und versiegeln (#012).
SCHLUESSEL_ERZEUGEN
/// Importiertes Paar im Worker pruefen und versiegeln (#012).
SCHLUESSEL_IMPORTIEREN
/// Gespeicherten Private Key endgueltig verwerfen (#014).
PRIVATE_KEY_LOESCHEN
}
enum VorgangsStatus {
@@ -129,6 +135,9 @@ model Benutzer {
/// `sub` des OIDC-Anbieters; leer beim Notfall-Admin.
oidcSub String? @unique
email String? @unique
/// Hat der OIDC-Anbieter die Adresse bestaetigt (`email_verified`)? Nur dann verknuepft der
/// Self-Service das Konto per E-Mail mit einer vorhandenen Person (#013).
emailBestaetigt Boolean @default(false)
/// Anmeldename des Notfall-Admins.
anmeldename String? @unique
name String
+89
View File
@@ -124,10 +124,99 @@ async function demoDaten(): Promise<void> {
console.log(`Demo-Daten geprüft: ${DEMO_HOSTS.length} Hosts, ${DEMO_GRUPPEN.length} Gruppen.`);
}
/**
* Demo-Personen und -Schluessel (#012, #013). Nur Public Keys: einen Private Key kann nur
* der Worker versiegeln. Die Paare werden hier frisch erzeugt und der Private Key sofort
* verworfen – es sind Attrappen, die nirgends Zugang geben.
*/
const DEMO_PERSONEN = [
{ name: "Florian Wilde", email: "florian@example.org", organisation: "IT" },
{ name: "Anna Berger", email: "a.berger@netzwerk-gmbh.example", organisation: "Netzwerk GmbH" },
{ name: "Mehmet Yılmaz", email: "mehmet@example.org", organisation: "IT" },
] as const;
const TAG_MS = 24 * 60 * 60 * 1000;
const DEMO_SCHLUESSEL: {
name: string;
slug: string;
kategorie: "PERSON" | "DIENST";
person?: string;
tags: string[];
ablaufInTagen?: number;
kommentar: string;
}[] = [
{
name: "Laptop Florian",
slug: "laptop-florian",
kategorie: "PERSON",
person: "Florian Wilde",
tags: ["laptop"],
kommentar: "florian@laptop",
},
{
name: "Wartungszugang Netzwerk GmbH",
slug: "wartung-netzwerk-gmbh",
kategorie: "PERSON",
person: "Anna Berger",
tags: ["dienstleister"],
ablaufInTagen: 18,
kommentar: "anna@netzwerk-gmbh",
},
{
name: "Workstation Mehmet",
slug: "workstation-mehmet",
kategorie: "PERSON",
person: "Mehmet Yılmaz",
tags: [],
ablaufInTagen: 200,
kommentar: "mehmet@ws-07",
},
{
name: "Monitoring zabbix → alle Hosts",
slug: "monitoring-zabbix",
kategorie: "DIENST",
tags: ["monitoring"],
kommentar: "zabbix@srv-mon01",
},
];
async function demoSchluessel(): Promise<void> {
if (process.env["SSHKV_SEED_DEMO"] !== "1") return;
const { schluesselpaarErzeugen } = await import("../src/lib/ssh/openssh");
const personen = new Map<string, string>();
for (const p of DEMO_PERSONEN) {
const vorhanden = await prisma.person.findFirst({ where: { name: p.name }, select: { id: true } });
const person = vorhanden ?? (await prisma.person.create({ data: p, select: { id: true } }));
personen.set(p.name, person.id);
}
for (const s of DEMO_SCHLUESSEL) {
if (await prisma.schluessel.findUnique({ where: { slug: s.slug }, select: { id: true } })) continue;
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: s.kommentar });
await prisma.schluessel.create({
data: {
name: s.name,
slug: s.slug,
typ: "ED25519",
kategorie: s.kategorie,
publicKey: paar.publicKeyZeile,
fingerprint: paar.fingerprint,
kommentar: s.kommentar,
tags: s.tags,
personId: s.person ? (personen.get(s.person) ?? null) : null,
ablaufAm: s.ablaufInTagen ? new Date(Date.now() + s.ablaufInTagen * TAG_MS) : null,
},
select: { id: true },
});
}
console.log(`Demo-Daten geprüft: ${DEMO_PERSONEN.length} Personen, ${DEMO_SCHLUESSEL.length} Schlüssel.`);
}
try {
await notfallAdmin();
await vorlagen();
await demoDaten();
await demoSchluessel();
} finally {
await prisma.$disconnect();
}
+10 -1
View File
@@ -27,7 +27,16 @@ const ZIEL = process.env["SSHKV_BILDER"] ?? join(process.cwd(), "docs/anleitung/
const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: string }[] = [
{ name: "anmelden", pfad: "/anmelden", oeffentlich: true },
{ name: "uebersicht", pfad: "/" },
{ name: "schluessel-detail", pfad: "/schluessel/beispiel" },
{ name: "schluessel", pfad: "/schluessel" },
{ name: "schluessel-neu", pfad: "/schluessel/neu" },
// Der Demo-Key mit nahem Ablaufdatum zeigt die meisten Badges (SSHKV_SEED_DEMO=1, #012).
{
name: "schluessel-detail",
pfad: "/schluessel?q=wartung",
folgen: 'main table a[href^="/schluessel/"]',
},
{ name: "personen", pfad: "/personen" },
{ name: "meine-schluessel", pfad: "/meine-schluessel" },
{ name: "hosts", pfad: "/hosts" },
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' },
];
+78
View File
@@ -0,0 +1,78 @@
import { KeyRound } from "lucide-react";
import type { Metadata } from "next";
import { KeyEntfernen, KeyHinterlegen } from "@/components/personen/MeineSchluessel";
import { StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
import { Badge } from "@/components/ui/Badge";
import { Karte } from "@/components/ui/Karte";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { MonoKopieren } from "@/components/ui/MonoKopieren";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { datum } from "@/lib/format";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { eigeneSchluessel } from "@/server/services/meine-schluessel";
export const metadata: Metadata = { title: "Meine Schlüssel" };
/**
* Self-Service (#013): eigene Public Keys hinterlegen und – solange nirgends zugewiesen –
* wieder entfernen. Beim ersten Aufruf entsteht die eigene Person (oder wird verknuepft).
*/
export default async function MeineSchluesselSeite() {
const { benutzer } = await sitzungErforderlich("eigeneSchluesselHinterlegen", "/meine-schluessel");
const { schluessel } = await eigeneSchluessel(benutzer);
return (
<div className="flex max-w-4xl flex-col gap-6 sm:gap-7">
<Seitenkopf
titel="Meine Schlüssel"
untertitel="Ihre Public Keys für die Anmeldung an Servern. Wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist."
/>
<Karte titel="Hinterlegt" untertitel={`${schluessel.length} Schlüssel`}>
{schluessel.length === 0 ? (
<Leerzustand
icon={<KeyRound className="size-5" />}
titel="Noch keine Schlüssel"
text="Hinterlegen Sie unten Ihren Public Key. Danach kann ein Operator oder Admin ihn den Servern zuweisen, auf die Sie Zugriff brauchen."
/>
) : (
<ul className="flex flex-col">
{schluessel.map((s) => (
<li
key={s.id}
className="border-border flex flex-col gap-2 border-b py-4 first:pt-0 last:border-b-0 last:pb-0 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0">
<p className="text-text text-[14.5px] font-medium">{s.name}</p>
<MonoKopieren
wert={s.fingerprint}
beschriftung="Fingerprint"
umbrechen
className="text-[12.5px]"
/>
<span className="mt-1 flex flex-wrap items-center gap-1.5">
<TypBadge typ={s.typ} publicKey={s.publicKey} />
<StatusBadge status={s.status} />
{s.verwendungen > 0 ? (
<Badge variante="ok">
{s.verwendungen === 1 ? "1 Host-Zuweisung" : `${s.verwendungen} Host-Zuweisungen`}
</Badge>
) : (
<Badge variante="messing">noch nicht zugewiesen</Badge>
)}
<span className="text-muted text-[13px]">seit {datum(s.angelegtAm)}</span>
</span>
</div>
{s.selbstHinterlegt && s.verwendungen === 0 && (
<KeyEntfernen schluesselId={s.id} name={s.name} />
)}
</li>
))}
</ul>
)}
</Karte>
<KeyHinterlegen />
</div>
);
}
+1 -1
View File
@@ -136,7 +136,7 @@ export default async function Uebersicht() {
return (
<li key={s.zweck} className="border-border border-b last:border-b-0">
<Link
href="/schluessel/beispiel"
href="/schluessel"
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 items-center justify-between gap-3 px-2 py-2.5 transition-colors"
>
<span className="min-w-0">
@@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PersonFormular } from "@/components/personen/PersonFormular";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kontenAuswahl, personDetail } from "@/server/queries/personen";
export const metadata: Metadata = { title: "Person bearbeiten" };
export default async function PersonBearbeiten({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await sitzungErforderlich("schluesselVerwalten", `/personen/${id}/bearbeiten`);
const [person, konten] = await Promise.all([personDetail(id), kontenAuswahl(id)]);
if (!person || person.archiviertAm) notFound();
return (
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={
<Breadcrumb
pfad={[
{ titel: "Personen", href: "/personen" },
{ titel: person.name, href: `/personen/${person.id}` },
{ titel: "Bearbeiten" },
]}
/>
}
titel="Person bearbeiten"
/>
<PersonFormular
vorgabe={{
id: person.id,
name: person.name,
email: person.email ?? "",
organisation: person.organisation ?? "",
notiz: person.notiz ?? "",
benutzerId: person.benutzerId ?? "",
}}
konten={konten}
abbrechenZiel={`/personen/${person.id}`}
/>
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { KeyRound, Pencil, Plus } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import { PersonArchivieren } from "@/components/personen/PersonFormular";
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
import { Badge } from "@/components/ui/Badge";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Button } from "@/components/ui/Button";
import { Karte } from "@/components/ui/Karte";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { datum } from "@/lib/format";
import { fingerprintKurz } from "@/lib/schluessel";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { personDetail } from "@/server/queries/personen";
export const metadata: Metadata = { title: "Person" };
function Info({ titel, children }: { titel: string; children: ReactNode }) {
return (
<div className="min-w-0">
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
<dd className="text-text mt-1 text-[14.5px] break-words">{children}</dd>
</div>
);
}
/** Persondetail (#013) mit ihren Schlüsseln. */
export default async function PersonSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { benutzer } = await sitzungErforderlich("lesen", `/personen/${id}`);
const person = await personDetail(id);
if (!person) notFound();
const archiviert = person.archiviertAm !== null;
const verwalten = darf(benutzer.rolle, "schluesselVerwalten") && !archiviert;
const jetzt = new Date();
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Personen", href: "/personen" }, { titel: person.name }]} />}
titel={person.name}
badges={
<>
{archiviert && person.archiviertAm && (
<Badge variante="gefahr">archiviert am {datum(person.archiviertAm)}</Badge>
)}
{person.organisation && <Badge>{person.organisation}</Badge>}
{person.benutzer && <Badge variante="ok">Konto verknüpft</Badge>}
</>
}
aktionen={
verwalten && (
<Button href={`/personen/${person.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
Bearbeiten
</Button>
)
}
/>
<Karte as="div">
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 min-[480px]:grid-cols-2 xl:grid-cols-3">
<Info titel="E-Mail">{person.email ?? <span className="text-muted">–</span>}</Info>
<Info titel="Konto">
{person.benutzer ? (
<>
{person.benutzer.name}
{person.benutzer.email && (
<span className="text-muted block text-[13px]">{person.benutzer.email}</span>
)}
</>
) : (
<span className="text-muted">nicht verknüpft</span>
)}
</Info>
<Info titel="Angelegt">{datum(person.angelegtAm)}</Info>
</dl>
{person.notiz && (
<p className="border-border text-muted mt-5 border-t pt-4 text-[14px] whitespace-pre-line">
{person.notiz}
</p>
)}
</Karte>
<Karte
titel="Schlüssel"
untertitel={`${person.schluessel.length} Schlüssel dieser Person`}
aktionen={
verwalten && (
<Button
href={`/schluessel/neu?person=${person.id}`}
klein
icon={<Plus className="size-4" strokeWidth={2.4} />}
>
Public Key hinterlegen
</Button>
)
}
>
{person.schluessel.length === 0 ? (
<Leerzustand
icon={<KeyRound className="size-5" />}
titel="Noch keine Schlüssel"
text="Die Person kann ihre Public Keys auch selbst unter „Meine Schlüssel“ hinterlegen, sobald ihr Konto verknüpft ist."
/>
) : (
<ul className="flex flex-col">
{person.schluessel.map((s) => (
<li key={s.id} className="border-border border-b py-3 first:pt-0 last:border-b-0 last:pb-0">
<Link
href={`/schluessel/${s.id}`}
className="text-text hover:text-accent text-[14.5px] font-medium transition-colors"
>
{s.name}
</Link>
<span className="text-muted block font-mono text-[12.5px]">
{fingerprintKurz(s.fingerprint)}
</span>
<span className="mt-1.5 flex flex-wrap items-center gap-1.5">
<TypBadge typ={s.typ} publicKey={s.publicKey} />
<KategorieBadge kategorie={s.kategorie} />
<StatusBadge status={s.status} />
{s.selbstHinterlegt && <Badge>selbst hinterlegt</Badge>}
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} />
<span className="text-muted text-[13px]">
{s.verwendungen === 0
? "· noch keinem Host zugewiesen"
: `· ${s.verwendungen} ${s.verwendungen === 1 ? "Verwendung" : "Verwendungen"}`}
</span>
</span>
</li>
))}
</ul>
)}
</Karte>
{verwalten && (
<Karte titel="Archivieren" untertitel="Person stilllegen, ohne die Historie zu verlieren">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted max-w-xl text-[14px]">
Archivierte Personen verschwinden aus Listen. Ihre Schlüssel bleiben bestehen und lassen sich
weiterhin widerrufen.
</p>
<PersonArchivieren personId={person.id} name={person.name} />
</div>
</Karte>
)}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { LEERE_PERSON, PersonFormular } from "@/components/personen/PersonFormular";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kontenAuswahl } from "@/server/queries/personen";
export const metadata: Metadata = { title: "Person anlegen" };
export default async function PersonNeu() {
await sitzungErforderlich("schluesselVerwalten", "/personen/neu");
const konten = await kontenAuswahl();
return (
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Personen", href: "/personen" }, { titel: "Neue Person" }]} />}
titel="Person anlegen"
untertitel="Mitarbeitende, Admins oder Dienstleister, denen Personen-Keys gehören."
/>
<PersonFormular vorgabe={LEERE_PERSON} konten={konten} abbrechenZiel="/personen" />
</div>
);
}
+128 -4
View File
@@ -1,13 +1,137 @@
import { Plus, Search, Users } from "lucide-react";
import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
import Link from "next/link";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Feld } from "@/components/ui/Feld";
import { Karte } from "@/components/ui/Karte";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { personenListe } from "@/server/queries/personen";
export const metadata: Metadata = { title: "Personen" };
export default async function Personen() {
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
function einzeln(wert: string | string[] | undefined): string {
return (Array.isArray(wert) ? wert[0] : wert)?.trim() ?? "";
}
function Anzahl({ n }: { n: number }) {
return n === 0 ? <span className="text-muted">keine</span> : <>{n}</>;
}
/** Personenliste (#013): wem welche Schlüssel gehören. */
export default async function Personen({ searchParams }: { searchParams: Suchparameter }) {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
const { benutzer } = await sitzungErforderlich("lesen", "/personen");
const q = einzeln((await searchParams)["q"]);
const personen = await personenListe({ q });
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
const anlegen = verwalten && (
<Button href="/personen/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
Person anlegen
</Button>
);
return (
<Platzhalterseite titel="Personen" untertitel="Wem welche Schlüssel gehören" phase={2} issue="#013" />
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
titel="Personen"
untertitel={q ? `${personen.length} Treffer` : "Wem welche Schlüssel gehören"}
aktionen={anlegen}
/>
<form method="get" role="search" className="grid items-end gap-3 sm:grid-cols-[minmax(0,1fr)_auto]">
<Feld
beschriftung="Suche"
name="q"
type="search"
defaultValue={q}
placeholder="Name, E-Mail oder Organisation"
autoComplete="off"
/>
<div className="flex gap-2.5">
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
Suchen
</Button>
{q && (
<Button href="/personen" className="flex-1 sm:flex-none">
Zurücksetzen
</Button>
)}
</div>
</form>
{personen.length === 0 ? (
<Leerzustand
icon={<Users className="size-5" />}
titel={q ? "Keine Treffer" : "Noch keine Personen"}
text={
q
? "Keine Person passt zur Suche."
: "Personen entstehen hier oder von selbst, wenn jemand unter „Meine Schlüssel“ einen Key hinterlegt."
}
aktion={q ? <Button href="/personen">Suche zurücksetzen</Button> : anlegen}
/>
) : (
<Karte>
<ul className="flex flex-col md:hidden">
{personen.map((p) => (
<li key={p.id} className="border-border border-b last:border-b-0">
<Link
href={`/personen/${p.id}`}
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 flex-col gap-1 px-2 py-3 transition-colors"
>
<span className="text-text text-[14.5px] font-medium">{p.name}</span>
{p.email && <span className="text-muted truncate text-[13px]">{p.email}</span>}
<span className="flex flex-wrap items-center gap-1.5 pt-1">
{p.organisation && <Badge>{p.organisation}</Badge>}
{p.benutzer && <Badge variante="ok">Konto verknüpft</Badge>}
</span>
<span className="text-muted text-[13px]">
Aktive Schlüssel: <Anzahl n={p.aktiveSchluessel} />
</span>
</Link>
</li>
))}
</ul>
<Tabelle
className="hidden md:block"
beschriftung="Personen"
spalten={[
{ titel: "Name" },
{ titel: "E-Mail" },
{ titel: "Organisation" },
{ titel: "Konto" },
{ titel: "Aktive Schlüssel" },
]}
>
{personen.map((p) => (
<TabellenZeile key={p.id}>
<TabellenZelle>
<Link
href={`/personen/${p.id}`}
className="text-text hover:text-accent -my-2 inline-flex min-h-11 items-center text-[14px] font-medium transition-colors"
>
{p.name}
</Link>
</TabellenZelle>
<TabellenZelle className="text-muted">{p.email ?? "–"}</TabellenZelle>
<TabellenZelle>{p.organisation ?? <span className="text-muted">–</span>}</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
{p.benutzer ? p.benutzer.name : <span className="text-muted">–</span>}
</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
<Anzahl n={p.aktiveSchluessel} />
</TabellenZelle>
</TabellenZeile>
))}
</Tabelle>
</Karte>
)}
</div>
);
}
@@ -0,0 +1,10 @@
"use client";
/**
* Platzhalter fuer „+ Ausrollen“ in der Karte „Private Key ausgerollt auf“ (#012).
* Inhalt folgt mit #017 (Private Key auf einen Host legen); bis dahin bewusst leer.
*/
export function PrivateAusrollenAktion(props: { schluesselId: string; darf: boolean }): null {
void props;
return null;
}
@@ -0,0 +1,224 @@
"use client";
import { Download, Eye, Trash2, TriangleAlert } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState, useTransition } from "react";
import { Ankreuzfeld, Button, Formularfehler } from "@/components/ui";
import { MonoBlock } from "@/components/ui/MonoBlock";
import { istLaufend, useVorgang } from "@/components/vorgang/useVorgang";
import {
abholungAnfordernAktion,
privateKeyAnzeigenAktion,
privateKeyLoeschenAktion,
type PrivateKeyAntwort,
} from "@/server/actions/privatekey";
/**
* Private Key anzeigen, herunterladen und loeschen (#014). Alles verlangt frische
* Bestaetigung; fehlt sie, geht es nach /bestaetigen und von dort mit `?privatekey=1` zurueck,
* damit der Dialog gleich wieder offen ist.
*
* Der Key lebt nur im Zustand dieses Dialogs und wird beim Schliessen verworfen.
*/
const DIALOG_KLASSEN =
"border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-2xl border p-5 backdrop:bg-black/70 sm:p-6";
function useBestaetigungUmleitung() {
const router = useRouter();
const pfad = usePathname();
return (antwort: PrivateKeyAntwort): boolean => {
if (!antwort.bestaetigen) return false;
router.push(`/bestaetigen?ziel=${encodeURIComponent(`${pfad}?privatekey=1`)}`);
return true;
};
}
export function PrivateKeyAnzeigen({ schluesselId, name }: { schluesselId: string; name: string }) {
const dialog = useRef<HTMLDialogElement>(null);
const download = useRef<HTMLFormElement>(null);
const suche = useSearchParams();
const umleiten = useBestaetigungUmleitung();
const [zweck, setZweck] = useState<"anzeigen" | "herunterladen">();
const [vorgangId, setVorgangId] = useState<string>();
const [privateKey, setPrivateKey] = useState<string>();
const [fehler, setFehler] = useState<string>();
const [startet, starten] = useTransition();
const { stand, abfrageFehler } = useVorgang(vorgangId);
const abgeholt = useRef<string | undefined>(undefined);
// Zurueck von /bestaetigen: Dialog gleich oeffnen.
useEffect(() => {
if (suche.get("privatekey") === "1") dialog.current?.showModal();
}, [suche]);
// Bereit? Dann genau einmal abholen – als Anzeige oder als Download-Formular.
useEffect(() => {
if (!vorgangId || stand?.status !== "ERLEDIGT" || abgeholt.current === vorgangId) return;
abgeholt.current = vorgangId;
if (zweck === "herunterladen") {
download.current?.submit();
return;
}
void privateKeyAnzeigenAktion(vorgangId).then((antwort) => {
if (umleiten(antwort)) return;
if (antwort.fehler) setFehler(antwort.fehler);
else setPrivateKey(antwort.privateKey);
});
}, [vorgangId, stand?.status, zweck, umleiten]);
function anfordern(art: "anzeigen" | "herunterladen") {
starten(async () => {
setFehler(undefined);
setPrivateKey(undefined);
setZweck(art);
const antwort = await abholungAnfordernAktion(schluesselId, art);
if (umleiten(antwort)) return;
if (antwort.fehler) setFehler(antwort.fehler);
else setVorgangId(antwort.vorgangId);
});
}
function schliessen() {
// Nichts vom Key bleibt im Zustand stehen.
setPrivateKey(undefined);
setVorgangId(undefined);
setFehler(undefined);
dialog.current?.close();
}
const laeuft = startet || (vorgangId !== undefined && istLaufend(stand) && !abfrageFehler);
const vorgangsFehler = stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined;
const downloadGestartet =
zweck === "herunterladen" && vorgangId !== undefined && stand?.status === "ERLEDIGT";
return (
<>
<button
type="button"
onClick={() => dialog.current?.showModal()}
className="text-accent -my-3 inline-flex min-h-11 items-center font-medium hover:underline"
>
anzeigen (Re-Auth)
</button>
<dialog ref={dialog} aria-labelledby="privatekey-titel" className={DIALOG_KLASSEN} onClose={schliessen}>
<div className="flex flex-col gap-4">
<h2 id="privatekey-titel" className="text-[17px] font-semibold">
Private Key von „{name}“
</h2>
{privateKey ? (
<>
<MonoBlock wert={privateKey} beschriftung="Private Key" hoehe="22rem" />
<p className="text-accent flex items-start gap-2 text-[14px]">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
Wird nicht erneut angezeigt. Kopieren Sie ihn jetzt an einen sicheren Ort.
</p>
</>
) : downloadGestartet ? (
<p role="status" className="text-ok text-[14.5px]">
Download gestartet. Der Private Key wird nicht erneut angeboten.
</p>
) : (
<>
<p className="text-muted text-[14.5px]">
Der Private Key wird im Worker entschlüsselt und genau einmal ausgeliefert. Jede Anzeige und
jeder Download steht im Audit-Log.
</p>
<div className="flex flex-wrap gap-2.5">
<Button
variante="primaer"
icon={<Eye className="size-4" />}
disabled={laeuft}
onClick={() => anfordern("anzeigen")}
>
{laeuft && zweck === "anzeigen" ? "Entschlüssele …" : "Anzeigen"}
</Button>
<Button
icon={<Download className="size-4" />}
disabled={laeuft}
onClick={() => anfordern("herunterladen")}
>
{laeuft && zweck === "herunterladen" ? "Entschlüssele …" : "Herunterladen"}
</Button>
</div>
</>
)}
<Formularfehler text={fehler ?? abfrageFehler ?? vorgangsFehler} />
<div className="flex justify-end">
<Button onClick={schliessen}>Schließen</Button>
</div>
</div>
{vorgangId && <form ref={download} method="post" action={`/api/private-key/${vorgangId}`} hidden />}
</dialog>
</>
);
}
/** „Private Key aus SSHKeyVault loeschen“ (#014) – endgueltig, mit Rueckfrage. */
export function PrivateKeyLoeschen({ schluesselId, name }: { schluesselId: string; name: string }) {
const dialog = useRef<HTMLDialogElement>(null);
const router = useRouter();
const umleiten = useBestaetigungUmleitung();
const [verstanden, setVerstanden] = useState(false);
const [vorgangId, setVorgangId] = useState<string>();
const [fehler, setFehler] = useState<string>();
const [startet, starten] = useTransition();
const { stand, abfrageFehler } = useVorgang(vorgangId);
useEffect(() => {
if (stand?.status === "ERLEDIGT") {
dialog.current?.close();
router.refresh();
}
}, [stand?.status, router]);
const laeuft = startet || (vorgangId !== undefined && istLaufend(stand) && !abfrageFehler);
const vorgangsFehler = stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined;
return (
<>
<Button
variante="gefahr"
icon={<Trash2 className="size-4" />}
onClick={() => dialog.current?.showModal()}
>
Private Key löschen
</Button>
<dialog ref={dialog} aria-labelledby="privatekey-loeschen-titel" className={DIALOG_KLASSEN}>
<div className="flex flex-col gap-4">
<h2 id="privatekey-loeschen-titel" className="text-[17px] font-semibold">
Private Key von „{name}“ aus SSHKeyVault löschen?
</h2>
<p className="text-muted text-[14.5px]">
sshkv verwirft den verschlüsselten Private Key endgültig. Der Public Key und alle Zuweisungen
bleiben; auf den Hosts ändert sich nichts. Danach kann sshkv den Private Key weder anzeigen noch
auf weitere Hosts ausrollen.
</p>
<Ankreuzfeld
checked={verstanden}
onChange={(e) => setVerstanden(e.target.checked)}
beschriftung="Ich habe verstanden, dass sich das nicht rückgängig machen lässt."
/>
<Formularfehler text={fehler ?? abfrageFehler ?? vorgangsFehler} />
<div className="flex flex-wrap justify-end gap-2.5">
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
<Button
variante="gefahr"
disabled={!verstanden || laeuft}
onClick={() =>
starten(async () => {
setFehler(undefined);
const antwort = await privateKeyLoeschenAktion(schluesselId);
if (umleiten(antwort)) return;
if (antwort.fehler) setFehler(antwort.fehler);
else setVorgangId(antwort.vorgangId);
})
}
>
{laeuft ? "Lösche …" : "Endgültig löschen"}
</Button>
</div>
</div>
</dialog>
</>
);
}
@@ -0,0 +1,10 @@
"use client";
/**
* Platzhalter fuer „+ Ausrollen“ in der Karte „Public Key ausgerollt auf“ (#012).
* Inhalt folgt mit #016 (Zuweisung, Dry-Run, Ausrollen); bis dahin bewusst leer.
*/
export function PublicAusrollenAktion(props: { schluesselId: string; darf: boolean }): null {
void props;
return null;
}
@@ -0,0 +1,65 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { SchluesselBearbeiten } from "@/components/schluessel/SchluesselBearbeiten";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { personenAuswahl, schluesselDetail, vorlagenAuswahl } from "@/server/queries/schluessel";
export const metadata: Metadata = { title: "Schlüssel bearbeiten" };
export default async function SchluesselBearbeitenSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await sitzungErforderlich("schluesselVerwalten", `/schluessel/${id}/bearbeiten`);
const [s, personen, vorlagen] = await Promise.all([
schluesselDetail(id),
personenAuswahl(),
vorlagenAuswahl(),
]);
if (!s) notFound();
// Eine inzwischen archivierte Person oder Vorlage bleibt waehlbar, solange sie zugeordnet
// ist – sonst loeschte schon das blosse Speichern die Zuordnung.
const personListe = personen.map((p) => ({ id: p.id, name: p.name, zusatz: p.organisation }));
if (s.person && !personListe.some((p) => p.id === s.person?.id))
personListe.push({ ...s.person, zusatz: null });
const vorlagenListe = vorlagen.map((v) => ({ id: v.id, name: v.name }));
if (s.vorlage && !vorlagenListe.some((v) => v.id === s.vorlage?.id)) vorlagenListe.push(s.vorlage);
return (
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={
<Breadcrumb
pfad={[
{ titel: "Schlüssel", href: "/schluessel" },
{ titel: s.name, href: `/schluessel/${s.id}` },
{ titel: "Bearbeiten" },
]}
/>
}
titel="Schlüssel bearbeiten"
untertitel={
<>
Kurzname <span className="text-text font-mono">{s.slug}</span> und Kommentar bleiben unverändert.
</>
}
/>
<SchluesselBearbeiten
werte={{
id: s.id,
name: s.name,
tags: s.tags,
ablaufAm: s.ablaufAm ? s.ablaufAm.toISOString().slice(0, 10) : "",
personId: s.personId ?? "",
vorlageId: s.vorlageId ?? "",
status: s.status,
statusAenderbar:
s.kategorie !== "DEPLOYMENT" && (s.status === "AKTIV" || s.status === "ABGELAUFEN"),
}}
personen={personListe}
vorlagen={vorlagenListe}
/>
</div>
);
}
+312
View File
@@ -0,0 +1,312 @@
import { Download, Lock, LockOpen, Pencil } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
import { Badge } from "@/components/ui/Badge";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Button } from "@/components/ui/Button";
import { InnereBox, Karte } from "@/components/ui/Karte";
import { MonoBlock } from "@/components/ui/MonoBlock";
import { MonoKopieren } from "@/components/ui/MonoKopieren";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { Statuspunkt, type HostStatus } from "@/components/ui/Statuspunkt";
import { datum, datumZeit } from "@/lib/format";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel";
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
export const metadata: Metadata = { title: "Schlüssel" };
function InfoFeld({ titel, children }: { titel: string; children: ReactNode }) {
return (
<div className="min-w-0 px-5 py-4 sm:px-6">
<dt className="text-muted mb-1.5 text-[13px] font-medium">{titel}</dt>
<dd className="min-w-0">{children}</dd>
</div>
);
}
/** Soll-Zustand einer Verwendung als Statuspunkt – Form und Text tragen die Information mit. */
const ZUSTAND: Record<string, { status: HostStatus; text: string }> = {
GEPLANT: { status: "unbekannt", text: "geplant" },
AUSGEROLLT: { status: "synchron", text: "ausgerollt" },
ABWEICHUNG: { status: "drift", text: "Abweichung" },
ENTFERNEN: { status: "unbekannt", text: "wird entfernt" },
FEHLGESCHLAGEN: { status: "drift", text: "fehlgeschlagen" },
};
type Eintrag = { id: string; ziel: string; href?: string; pfad: string; zusatz?: string; zustand: string };
function AusrollKarte({
titel,
untertitel,
eintraege,
leer,
aktion,
}: {
titel: string;
untertitel: string;
eintraege: Eintrag[];
leer: string;
aktion: ReactNode;
}) {
return (
<Karte titel={titel} untertitel={untertitel} aktionen={aktion}>
{eintraege.length === 0 ? (
<p className="text-muted border-border-strong rounded-box border border-dashed px-4 py-6 text-center text-[14px]">
{leer}
</p>
) : (
<ul className="flex flex-col gap-3">
{eintraege.map((e) => {
const z = ZUSTAND[e.zustand] ?? { status: "unbekannt" as const, text: e.zustand };
return (
<li key={e.id}>
<InnereBox className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="text-text font-mono text-[13.5px] font-medium break-all">
{e.href ? (
<Link href={e.href} className="hover:text-accent transition-colors">
{e.ziel}
</Link>
) : (
e.ziel
)}
</p>
<p className="text-muted mt-1 font-mono text-[12.5px] break-all">{e.pfad}</p>
{e.zusatz && <p className="text-muted font-mono text-[12.5px] break-all">{e.zusatz}</p>}
</div>
<Statuspunkt status={z.status} text={z.text} className="shrink-0 sm:pt-0.5" />
</InnereBox>
</li>
);
})}
</ul>
)}
</Karte>
);
}
function privateEintraege(s: SchluesselDetail): Eintrag[] {
return s.privateVerwendungen.map((v) => {
const config =
v.sshConfig && typeof v.sshConfig === "object" ? (v.sshConfig as { alias?: unknown }) : null;
return {
id: v.id,
ziel: `${v.zielbenutzer}@${v.host.name}`,
href: `/hosts/${v.host.id}`,
pfad: v.pfad,
...(typeof config?.alias === "string" ? { zusatz: `config: Host ${config.alias}` } : {}),
zustand: v.zustand,
};
});
}
function publicEintraege(s: SchluesselDetail): Eintrag[] {
return s.publicVerwendungen.map((v) => ({
id: v.id,
ziel: v.host
? `${v.zielbenutzer}@${v.host.name}`
: `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe)`,
...(v.host
? { href: `/hosts/${v.host.id}` }
: v.hostgruppe
? { href: `/hostgruppen/${v.hostgruppe.id}` }
: {}),
pfad: "~/.ssh/authorized_keys",
...(v.optionen ? { zusatz: v.optionen } : {}),
zustand: v.zustand,
}));
}
/** Was mit dem Private Key ist – ohne ihn je zu lesen (masterKeyVersion verraet es). */
function PrivatStatus({ s, anzeigen }: { s: SchluesselDetail; anzeigen?: ReactNode }) {
if (s.privatGespeichert) {
return (
<span className="inline-flex flex-wrap items-center gap-x-1.5 text-[14px]">
<Lock className="text-ok size-3.5" aria-hidden />
verschlüsselt gespeichert
{s.passphraseGeschuetzt && <span className="text-muted">· mit Passphrase</span>}
{anzeigen}
</span>
);
}
if (s.privatGeloeschtAm) {
return (
<span className="text-muted inline-flex items-center gap-1.5 text-[14px]">
<LockOpen className="size-3.5" aria-hidden />
gelöscht am <time dateTime={s.privatGeloeschtAm.toISOString()}>{datum(s.privatGeloeschtAm)}</time>
</span>
);
}
return (
<span className="text-muted text-[14px]">
nicht gespeichert{s.kategorie === "PERSON" ? " (Personen-Key)" : ""}
</span>
);
}
/** Schlüsseldetail (#012) nach dem Prototyp aus #002; Ausrollen folgt mit #016/#017. */
export default async function SchluesselSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { benutzer } = await sitzungErforderlich("lesen", `/schluessel/${id}`);
const s = await schluesselDetail(id);
if (!s) notFound();
const aktiv = s.status === "AKTIV";
const personenKey = s.kategorie === "PERSON";
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
// Den Deployment-Key gibt sshkv nie heraus (#014) – Knoepfe dafuer erscheinen gar nicht erst.
const privatHerausgebbar = s.privatGespeichert && s.kategorie !== "DEPLOYMENT";
const darfAnzeigen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyAnzeigen");
const darfLoeschen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyLoeschen");
const jetzt = new Date();
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: s.name }]} />}
titel={s.name}
untertitel={
s.person ? (
<>
Person:{" "}
<Link href={`/personen/${s.person.id}`} className="text-accent hover:underline">
{s.person.name}
</Link>
</>
) : undefined
}
badges={
<>
<TypBadge typ={s.typ} publicKey={s.publicKey} />
<KategorieBadge kategorie={s.kategorie} />
<StatusBadge status={s.status} />
{s.instanzStandard && <Badge variante="messing">Instanz-Standard</Badge>}
{s.selbstHinterlegt && <Badge>selbst hinterlegt</Badge>}
{s.vorlage && <Badge>Vorlage: {s.vorlage.name}</Badge>}
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} lang />
{s.tags.map((t) => (
<Badge key={t} mono className="text-muted">
#{t}
</Badge>
))}
</>
}
aktionen={
verwalten && (
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
Bearbeiten
</Button>
)
}
/>
<Karte as="div" innen="ohne">
<dl className="divide-border grid divide-y md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.1fr)] md:divide-x md:divide-y-0">
<InfoFeld titel="Fingerprint">
<MonoKopieren wert={s.fingerprint} beschriftung="Fingerprint" umbrechen className="-my-[11px]" />
</InfoFeld>
<InfoFeld titel="Erstellt">
<time dateTime={s.angelegtAm.toISOString()} className="font-mono text-[13px]">
{datumZeit(s.angelegtAm)}
</time>
<span className="text-muted block text-[13px]">
{s.erstelltVon ? `von ${s.erstelltVon.name}` : "von sshkv"}
</span>
</InfoFeld>
<InfoFeld titel="Private Key">
<PrivatStatus
s={s}
anzeigen={
darfAnzeigen && (
<>
<span aria-hidden>·</span>
<PrivateKeyAnzeigen schluesselId={s.id} name={s.name} />
</>
)
}
/>
</InfoFeld>
</dl>
</Karte>
{/* Personen-Keys haben nie einen gespeicherten Private Key (DB-CHECK) – die Karte dafuer
waere immer leer. */}
<div className={personenKey ? "grid gap-6" : "grid gap-6 lg:grid-cols-2"}>
{!personenKey && (
<AusrollKarte
titel="Private Key ausgerollt auf"
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
eintraege={privateEintraege(s)}
leer={
s.privatGespeichert
? "Der Private Key liegt noch auf keinem Host."
: "Ohne gespeicherten Private Key kann sshkv ihn nicht ausrollen."
}
aktion={
<PrivateAusrollenAktion
schluesselId={s.id}
darf={aktiv && s.privatGespeichert && darf(benutzer.rolle, "privateKeyAusrollen")}
/>
}
/>
)}
<AusrollKarte
titel="Public Key ausgerollt auf"
untertitel="Konten, die diesen Schlüssel annehmen"
eintraege={publicEintraege(s)}
leer={
personenKey
? "Noch keinem Konto zugewiesen – wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist."
: "Noch keinem Konto zugewiesen."
}
aktion={
<PublicAusrollenAktion
schluesselId={s.id}
darf={aktiv && darf(benutzer.rolle, "publicKeyAusrollen")}
/>
}
/>
</div>
<Karte
titel="Public Key"
untertitel={
<>
Kommentar: <span className="font-mono">{s.kommentar || "–"}</span>
</>
}
aktionen={
<a
href={`/api/schluessel/${s.id}/public-key`}
download={`${s.slug}.pub`}
className="border-border-strong bg-surface text-text hover:bg-raised rounded-box inline-flex min-h-11 items-center gap-2 border px-3 text-[13px] font-medium transition-colors"
>
<Download className="size-4" aria-hidden />
{s.slug}.pub
</a>
}
>
<MonoBlock wert={s.publicKey} beschriftung="Public Key" umbrechen />
</Karte>
{darfLoeschen && (
<Karte titel="Private Key löschen" untertitel="Nur noch auf den Hosts, nicht mehr in SSHKeyVault">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted max-w-xl text-[14px]">
Sinnvoll, sobald der Private Key ausgerollt ist und sshkv ihn nicht mehr braucht. Danach kann
ihn niemand mehr aus SSHKeyVault holen – auch kein Admin.
</p>
<PrivateKeyLoeschen schluesselId={s.id} name={s.name} />
</div>
</Karte>
)}
</div>
);
}
-184
View File
@@ -1,184 +0,0 @@
import { Ban, Check, Lock, PlugZap, Plus, RotateCw } from "lucide-react";
import type { Metadata } from "next";
import type { ReactNode } from "react";
import {
beispielSchluessel as s,
dryRunDatei,
dryRunDiff,
privatAusgerollt,
publicAusgerollt,
zusatzaktionen,
type Ausrollung,
} from "@/beispiel/schluessel";
import { BEISPIEL_JETZT } from "@/beispiel/uebersicht";
import { Badge } from "@/components/ui/Badge";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Button } from "@/components/ui/Button";
import { DiffBox } from "@/components/ui/DiffBox";
import { InnereBox, Karte } from "@/components/ui/Karte";
import { MonoKopieren } from "@/components/ui/MonoKopieren";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { Statuspunkt } from "@/components/ui/Statuspunkt";
import { datum, datumZeit, tageBis } from "@/lib/format";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: `${s.zweck} (Beispiel)` };
/** Prototyp des Schlüsseldetails mit Beispieldaten (#002); echte Daten ab #012/#016/#017. */
export default async function SchluesselBeispiel() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: s.zweck }]} />}
titel={s.zweck}
badges={
<>
<Badge mono>{s.typ}</Badge>
<Badge>{s.kategorie}</Badge>
<Badge>Vorlage: {s.vorlage}</Badge>
<Badge variante="messing">
<span>
läuft ab am <time dateTime={s.ablauf}>{datum(s.ablauf)}</time> · in {tage} Tagen
</span>
</Badge>
<Badge variante="neutral" className="text-muted">
Beispieldaten
</Badge>
</>
}
aktionen={
<>
<Button icon={<PlugZap className="size-4" />}>Verbindung testen</Button>
<Button icon={<RotateCw className="size-4" />}>Rotieren</Button>
<Button variante="gefahr" icon={<Ban className="size-4" />}>
Widerrufen
</Button>
</>
}
/>
<Karte as="div" innen="ohne">
<dl className="divide-border grid divide-y md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.1fr)] md:divide-x md:divide-y-0">
<InfoFeld titel="Fingerprint">
<MonoKopieren wert={s.fingerprint} beschriftung="Fingerprint" umbrechen className="-my-[11px]" />
</InfoFeld>
<InfoFeld titel="Erstellt">
<time dateTime={s.erstellt} className="font-mono text-[13px]">
{datumZeit(s.erstellt)}
</time>
<span className="text-muted block text-[13px]">von {s.erstelltVon}</span>
</InfoFeld>
<InfoFeld titel="Private Key">
<span className="inline-flex flex-wrap items-center gap-x-1.5 text-[14px]">
<Lock className="text-ok size-3.5" aria-hidden />
verschlüsselt gespeichert ·
<button
type="button"
className="text-accent -my-3 inline-flex min-h-11 items-center font-medium hover:underline"
>
anzeigen (Re-Auth)
</button>
</span>
</InfoFeld>
</dl>
</Karte>
<div className="grid gap-6 lg:grid-cols-2">
<AusrollKarte
titel="Private Key ausgerollt auf"
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
eintraege={privatAusgerollt}
/>
<AusrollKarte
titel="Public Key ausgerollt auf"
untertitel="Konten, die diesen Schlüssel annehmen"
eintraege={publicAusgerollt}
/>
</div>
<Karte
titel="Dry-Run-Vorschau"
untertitel={
<>
Abgleich für <span className="font-mono">backup@srv-pve02</span> – noch nichts geschrieben
</>
}
>
<DiffBox datei={dryRunDatei} zeilen={dryRunDiff} />
<fieldset className="mt-5">
<legend className="text-muted mb-1 text-[13px] font-medium">Zusätzlich ausführen</legend>
<ul className="flex flex-col">
{zusatzaktionen.map((z) => (
<li key={z.id}>
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-[14.5px]">
<input
type="checkbox"
name={z.id}
defaultChecked={z.an}
className="accent-accent size-[18px] shrink-0"
/>
<span>{z.text}</span>
</label>
</li>
))}
</ul>
</fieldset>
<div className="border-border mt-5 flex flex-wrap gap-2.5 border-t pt-5">
<Button variante="primaer" icon={<Check className="size-4" strokeWidth={2.4} />}>
Änderung anwenden
</Button>
<Button>Verwerfen</Button>
</div>
</Karte>
</div>
);
}
function InfoFeld({ titel, children }: { titel: string; children: ReactNode }) {
return (
<div className="min-w-0 px-5 py-4 sm:px-6">
<dt className="text-muted mb-1.5 text-[13px] font-medium">{titel}</dt>
<dd className="min-w-0">{children}</dd>
</div>
);
}
function AusrollKarte({
titel,
untertitel,
eintraege,
}: {
titel: string;
untertitel: string;
eintraege: Ausrollung[];
}) {
return (
<Karte
titel={titel}
untertitel={untertitel}
aktionen={
<Button klein icon={<Plus className="size-4" strokeWidth={2.4} />}>
Ausrollen
</Button>
}
>
<ul className="flex flex-col gap-3">
{eintraege.map((e) => (
<li key={`${e.ziel}${e.pfad}`}>
<InnereBox className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="text-text font-mono text-[13.5px] font-medium break-all">{e.ziel}</p>
<p className="text-muted mt-1 font-mono text-[12.5px] break-all">{e.pfad}</p>
{e.zusatz && <p className="text-muted font-mono text-[12.5px] break-all">{e.zusatz}</p>}
</div>
<Statuspunkt status={e.status} text={e.statusText} className="shrink-0 sm:pt-0.5" />
</InnereBox>
</li>
))}
</ul>
</Karte>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { SchluesselNeu } from "@/components/schluessel/SchluesselNeu";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { personenAuswahl, vorlagenAuswahl } from "@/server/queries/schluessel";
export const metadata: Metadata = { title: "Schlüssel anlegen" };
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
export default async function SchluesselNeuSeite({ searchParams }: { searchParams: Suchparameter }) {
await sitzungErforderlich("schluesselVerwalten", "/schluessel/neu");
const person = (await searchParams)["person"];
const [personen, vorlagen] = await Promise.all([personenAuswahl(), vorlagenAuswahl()]);
const vorgabePersonId =
typeof person === "string" && personen.some((p) => p.id === person) ? person : undefined;
return (
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={
<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: "Neuer Schlüssel" }]} />
}
titel="Schlüssel anlegen"
untertitel="Neu erzeugen oder einen vorhandenen Schlüssel übernehmen."
/>
<SchluesselNeu
personen={personen.map((p) => ({ id: p.id, name: p.name, zusatz: p.organisation }))}
vorlagen={vorlagen.map((v) => ({
id: v.id,
name: v.name,
zusatz: v.standardLaufzeitTage ? `${v.standardLaufzeitTage} Tage` : null,
}))}
{...(vorgabePersonId ? { vorgabePersonId } : {})}
/>
</div>
);
}
+211 -15
View File
@@ -1,27 +1,223 @@
import { KeyRound, Lock, Plus, Search } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
import { Button } from "@/components/ui/Button";
import { Auswahlliste, Feld } from "@/components/ui/Feld";
import { Karte } from "@/components/ui/Karte";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
import { datum } from "@/lib/format";
import { ablaufZustand, fingerprintKurz, KATEGORIE_TEXT, STATUS_TEXT } from "@/lib/schluessel";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { schluesselListe, type SchluesselListenEintrag } from "@/server/queries/schluessel";
export const metadata: Metadata = { title: "Schlüssel" };
export default async function Schluessel() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
function einzeln(wert: string | string[] | undefined): string {
return (Array.isArray(wert) ? wert[0] : wert)?.trim() ?? "";
}
function Verwendungen({ anzahl }: { anzahl: number }) {
if (anzahl === 0) return <span className="text-muted">keine</span>;
return <>{anzahl}</>;
}
/** In der Tabelle: Badge, wenn es drängt; sonst nur das Datum. */
function Ablauf({ ablaufAm, jetzt }: { ablaufAm: Date | null; jetzt: Date }) {
if (!ablaufAm) return <span className="text-muted">–</span>;
if (ablaufZustand(ablaufAm, jetzt).art === "spaeter") {
return (
<time dateTime={ablaufAm.toISOString()} className="text-muted">
{datum(ablaufAm)}
</time>
);
}
return <AblaufBadge ablaufAm={ablaufAm} jetzt={jetzt} />;
}
function PrivatHinweis({ s }: { s: SchluesselListenEintrag }) {
if (!s.privatGespeichert) return null;
return (
<div className="flex flex-col gap-5">
<Platzhalterseite
<span title="Private Key verschlüsselt gespeichert" className="text-muted inline-flex">
<Lock className="size-3.5" aria-hidden />
<span className="sr-only">Private Key gespeichert</span>
</span>
);
}
/** Schlüsselliste mit Filter (#012). Filter per GET, damit Links teilbar sind. */
export default async function Schluessel({ searchParams }: { searchParams: Suchparameter }) {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
const { benutzer } = await sitzungErforderlich("lesen", "/schluessel");
const parameter = await searchParams;
const q = einzeln(parameter["q"]);
const kategorie = einzeln(parameter["kategorie"]);
const status = einzeln(parameter["status"]);
const liste = await schluesselListe({ q, kategorie, status });
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
const gefiltert = q !== "" || kategorie !== "" || status !== "";
const jetzt = new Date();
const anlegen = verwalten && (
<Button href="/schluessel/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
Schlüssel anlegen
</Button>
);
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
titel="Schlüssel"
untertitel="SSH-Schlüssel erzeugen, importieren und ausrollen"
phase={2}
issue="#012"
untertitel={
gefiltert
? `${liste.length} Treffer`
: `${liste.length} ${liste.length === 1 ? "verwalteter Schlüssel" : "verwaltete Schlüssel"}`
}
aktionen={anlegen}
/>
<p className="text-muted text-[14px]">
Prototyp ansehen:{" "}
<Link href="/schluessel/beispiel" className="text-accent font-medium hover:underline">
Schlüsseldetail mit Beispieldaten
</Link>
</p>
<form
method="get"
role="search"
className="grid items-end gap-3 sm:grid-cols-2 lg:grid-cols-[minmax(0,1fr)_11rem_11rem_auto]"
>
<Feld
beschriftung="Suche"
name="q"
type="search"
defaultValue={q}
placeholder="Zweck, Fingerprint, Tag, Person"
autoComplete="off"
spellCheck={false}
className="sm:col-span-2 lg:col-span-1"
/>
<Auswahlliste beschriftung="Kategorie" name="kategorie" defaultValue={kategorie}>
<option value="">Alle Kategorien</option>
{Object.entries(KATEGORIE_TEXT).map(([wert, text]) => (
<option key={wert} value={wert}>
{text}
</option>
))}
</Auswahlliste>
<Auswahlliste beschriftung="Status" name="status" defaultValue={status}>
<option value="">Alle</option>
{Object.entries(STATUS_TEXT).map(([wert, text]) => (
<option key={wert} value={wert}>
{text}
</option>
))}
</Auswahlliste>
<div className="flex gap-2.5 sm:col-span-2 lg:col-span-1">
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 lg:flex-none">
Filtern
</Button>
{gefiltert && (
<Button href="/schluessel" className="flex-1 lg:flex-none">
Zurücksetzen
</Button>
)}
</div>
</form>
{liste.length === 0 ? (
gefiltert ? (
<Leerzustand
icon={<Search className="size-5" />}
titel="Keine Treffer"
text="Kein Schlüssel passt zu Suche und Filter."
aktion={<Button href="/schluessel">Filter zurücksetzen</Button>}
/>
) : (
<Leerzustand
icon={<KeyRound className="size-5" />}
titel="Noch keine Schlüssel"
text="Erzeugen Sie einen Schlüssel oder importieren Sie einen vorhandenen Public Key."
aktion={anlegen}
/>
)
) : (
<Karte>
{/* Unter md eine Liste statt der Tabelle: sechs Spalten passen nicht auf 390 px. */}
<ul className="flex flex-col md:hidden">
{liste.map((s) => (
<li key={s.id} className="border-border border-b last:border-b-0">
<Link
href={`/schluessel/${s.id}`}
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 flex-col gap-2 px-2 py-3 transition-colors"
>
<span className="text-text flex min-w-0 items-center gap-2 text-[14.5px] font-medium">
<span className="truncate">{s.name}</span>
<PrivatHinweis s={s} />
</span>
<span className="text-muted block truncate font-mono text-[12.5px]">
{fingerprintKurz(s.fingerprint)}
</span>
<span className="flex flex-wrap items-center gap-1.5">
<TypBadge typ={s.typ} publicKey={s.publicKey} />
<KategorieBadge kategorie={s.kategorie} />
<StatusBadge status={s.status} />
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} />
</span>
<span className="text-muted text-[13px]">
Verwendungen: <Verwendungen anzahl={s.verwendungen} />
{s.person && <> · {s.person.name}</>}
</span>
</Link>
</li>
))}
</ul>
<Tabelle
className="hidden md:block"
beschriftung="Schlüssel"
spalten={[
{ titel: "Name" },
{ titel: "Typ" },
{ titel: "Kategorie" },
{ titel: "Fingerprint" },
{ titel: "Ablauf" },
{ titel: "Verwendungen" },
]}
>
{liste.map((s) => (
<TabellenZeile key={s.id}>
<TabellenZelle>
<span className="flex min-w-0 items-center gap-2">
<Link
href={`/schluessel/${s.id}`}
className="text-text hover:text-accent -my-2 inline-flex min-h-11 items-center text-[14px] font-medium transition-colors"
>
{s.name}
</Link>
<PrivatHinweis s={s} />
<StatusBadge status={s.status} />
</span>
{s.person && <span className="text-muted block text-[12.5px]">{s.person.name}</span>}
</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
<TypBadge typ={s.typ} publicKey={s.publicKey} />
</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
<KategorieBadge kategorie={s.kategorie} />
</TabellenZelle>
<TabellenZelle mono className="text-muted whitespace-nowrap">
<span title={s.fingerprint}>{fingerprintKurz(s.fingerprint)}</span>
</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
<Ablauf ablaufAm={s.ablaufAm} jetzt={jetzt} />
</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
<Verwendungen anzahl={s.verwendungen} />
</TabellenZelle>
</TabellenZeile>
))}
</Tabelle>
</Karte>
)}
</div>
);
}
@@ -0,0 +1,63 @@
import { NextResponse, type NextRequest } from "next/server";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { akteurVon } from "@/server/actions/formular";
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { HostFehler } from "@/server/services/hosts";
import { privateKeyAbholen } from "@/server/services/privatekey";
/**
* Download des Private Keys als Datei `<slug>` (#014). Nur POST aus einem Formular dieser
* Anwendung: ein GET liesse sich per Link oder Vorabruf ausloesen und verbraeuchte die
* einmalige Abholung. Fremde Seiten haelt die Herkunftspruefung fern.
*
* Prueft selbst Recht und frische Bestaetigung. Antwort ohne Caching; Fehlertexte enthalten
* nie Schluesselmaterial.
*/
const KEIN_CACHE = { "Cache-Control": "no-store, max-age=0", Pragma: "no-cache" } as const;
function text(meldung: string, status: number): NextResponse {
return new NextResponse(`${meldung}\n`, {
status,
headers: { "Content-Type": "text/plain; charset=utf-8", ...KEIN_CACHE },
});
}
/**
* Kommt die Anfrage von dieser Anwendung? Wegen `Referrer-Policy: no-referrer` schicken
* Browser bei Formular-POSTs `Origin: null`; massgeblich ist deshalb `Sec-Fetch-Site`, das
* Seiten nicht faelschen koennen. Ein passender Origin genuegt ebenso.
*/
function vonHier(anfrage: NextRequest): boolean {
if (anfrage.headers.get("sec-fetch-site") === "same-origin") return true;
return anfrage.headers.get("origin") === oeffentlicheAdresse("/", anfrage.nextUrl.origin).origin;
}
export async function POST(anfrage: NextRequest, { params }: { params: Promise<{ vorgangId: string }> }) {
if (!vonHier(anfrage)) return text("Anfrage von fremder Herkunft.", 403);
let benutzer;
try {
({ benutzer } = await rechtErforderlich("privateKeyAnzeigen"));
} catch (fehler) {
if (fehler instanceof RechteFehler || fehler instanceof BestaetigungFehler)
return text(fehler.message, 403);
throw fehler;
}
const { vorgangId } = await params;
try {
const abholung = await privateKeyAbholen(vorgangId.slice(0, 64), "herunterladen", akteurVon(benutzer));
return new NextResponse(abholung.privateKey, {
headers: {
"Content-Type": "application/octet-stream",
// Der Slug passt per DB-CHECK in einen Dateinamen; so heisst die Datei wie in ~/.ssh.
"Content-Disposition": `attachment; filename="${abholung.slug}"`,
"X-Content-Type-Options": "nosniff",
...KEIN_CACHE,
},
});
} catch (fehler) {
if (fehler instanceof HostFehler) return text(fehler.message, 410);
throw fehler;
}
}
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { publicKeyExport } from "@/server/queries/schluessel";
/**
* Public Key als Datei `<slug>.pub` (#012). Ein Public Key ist kein Geheimnis – jede
* angemeldete Rolle darf ihn holen. Der Slug passt per DB-CHECK in einen Dateinamen.
*/
export async function GET(_anfrage: Request, { params }: { params: Promise<{ id: string }> }) {
try {
await rechtErforderlich("lesen");
} catch (fehler) {
if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 });
throw fehler;
}
const schluessel = await publicKeyExport((await params).id);
if (!schluessel) return NextResponse.json({ fehler: "Unbekannter Schlüssel." }, { status: 404 });
return new NextResponse(`${schluessel.publicKey}\n`, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": `attachment; filename="${schluessel.slug}.pub"`,
"Cache-Control": "no-store",
},
});
}
-75
View File
@@ -1,75 +0,0 @@
/**
* BEISPIELDATEN für den Prototyp des Schlüsseldetails (#002) – kein echter Schlüssel.
* Ersetzt durch das Lesemodell aus #012/#016/#017.
*/
import type { HostStatus } from "@/components/ui/Statuspunkt";
import type { DiffZeile } from "@/components/ui/DiffBox";
export const beispielSchluessel = {
zweck: "Backup srv-backup01 → srv-pve01",
typ: "ed25519",
kategorie: "Dienst",
vorlage: "Backup-Zugang",
ablauf: "2026-10-30T10:00:00Z",
fingerprint: "SHA256:q3vN8xP0b4Tz7KfL2mWcR9yHdE1sJ6uAoGi5VnBQe0k",
erstellt: "2026-04-30T08:12:00Z",
erstelltVon: "Florian Wilde",
};
export type Ausrollung = {
ziel: string;
pfad: string;
zusatz?: string;
status: HostStatus;
statusText?: string;
};
export const privatAusgerollt: Ausrollung[] = [
{
ziel: "backup@srv-backup01",
pfad: "~/.ssh/id_ed25519_pve01",
zusatz: "config: Host srv-pve01",
status: "synchron",
},
];
export const publicAusgerollt: Ausrollung[] = [
{
ziel: "backup@srv-pve01",
pfad: "~/.ssh/authorized_keys",
zusatz: 'from="10.20.0.15",no-pty,no-agent-forwarding',
status: "synchron",
},
{
ziel: "backup@srv-pve02",
pfad: "~/.ssh/authorized_keys",
zusatz: 'from="10.20.0.15",no-pty',
status: "drift",
statusText: "Optionen geändert",
},
];
export const dryRunDatei = "backup@srv-pve02:~/.ssh/authorized_keys";
export const dryRunDiff: DiffZeile[] = [
{ art: "kontext", text: "# --- sshkv verwaltet: Anfang ---" },
{
art: "kontext",
text: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMvq2Lr7… monitoring@zabbix",
},
{
art: "entfernt",
text: 'from="10.20.0.15",no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFt0b8c1… backup@srv-backup01',
},
{
art: "hinzu",
text: 'from="10.20.0.15",no-pty,no-agent-forwarding ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFt0b8c1… backup@srv-backup01',
},
{ art: "kontext", text: "# --- sshkv verwaltet: Ende ---" },
];
export const zusatzaktionen = [
{ id: "backup", text: "Backup der Datei anlegen (authorized_keys.sshkv-bak)", an: true },
{ id: "config", text: "~/.ssh/config-Eintrag auf srv-backup01 anlegen", an: true },
{ id: "knownhosts", text: "known_hosts auf srv-backup01 aktualisieren", an: false },
];
+3
View File
@@ -1,6 +1,7 @@
import {
Boxes,
KeyRound,
KeySquare,
LayoutDashboard,
LayoutTemplate,
Network,
@@ -20,6 +21,8 @@ export const HAUPTNAVIGATION: NavPunkt[] = [
{ href: "/hostgruppen", titel: "Hostgruppen", icon: Boxes },
{ href: "/schluessel", titel: "Schlüssel", icon: KeyRound },
{ href: "/personen", titel: "Personen", icon: Users },
// Self-Service fuer jede Rolle (#013): eigene Public Keys hinterlegen.
{ href: "/meine-schluessel", titel: "Meine Schlüssel", icon: KeySquare },
{ href: "/zuweisungen", titel: "Zuweisungen", icon: Network },
{ href: "/vorlagen", titel: "Vorlagen", icon: LayoutTemplate },
];
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { Plus, Trash2 } from "lucide-react";
import { useActionState, useEffect, useRef } from "react";
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
import { Button, Feld, Formularfehler, Karte, Textbereich } from "@/components/ui";
import type { FormularZustand } from "@/server/actions/formular";
import {
eigenenKeyEntfernenAktion,
eigenenKeyHinterlegenAktion,
type HinterlegenZustand,
} from "@/server/actions/meine-schluessel";
/** Eigenen Public Key hinterlegen (#013). Nach Erfolg leert sich das Formular. */
export function KeyHinterlegen() {
const formular = useRef<HTMLFormElement>(null);
const { zustand, absenden, laeuft } = useFormularAktion(
eigenenKeyHinterlegenAktion as (v: FormularZustand, f: FormData) => Promise<HinterlegenZustand>,
);
const erfolg = (zustand as HinterlegenZustand).erfolg;
useEffect(() => {
if (erfolg) formular.current?.reset();
}, [erfolg]);
const f = zustand.feldfehler ?? {};
return (
<Karte
titel="Public Key hinterlegen"
untertitel="Eine Zeile im OpenSSH-Format, z. B. aus ~/.ssh/id_ed25519.pub"
>
<form ref={formular} onSubmit={absenden} noValidate className="flex flex-col gap-4">
<Formularfehler text={zustand.fehler} />
{erfolg && (
<p role="status" className="text-ok text-[14px] break-all">
{erfolg}
</p>
)}
<Feld
beschriftung="Bezeichnung"
name="name"
required
maxLength={120}
placeholder="Laptop, Workstation …"
autoComplete="off"
fehler={f["name"]}
/>
<Textbereich
beschriftung="Public Key"
name="publicKey"
required
rows={3}
spellCheck={false}
placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA… name@rechner"
fehler={f["publicKey"]}
className="[&_textarea]:font-mono [&_textarea]:text-[13px]"
/>
<p className="text-muted text-[13.5px]">
Wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist. Den Private Key
behalten Sie bei sich – sshkv fragt nie danach.
</p>
<div>
<Button
type="submit"
variante="primaer"
disabled={laeuft}
icon={<Plus className="size-4" strokeWidth={2.4} />}
>
{laeuft ? "Speichere …" : "Hinterlegen"}
</Button>
</div>
</form>
</Karte>
);
}
/** Eigenen, noch nirgends zugewiesenen Key entfernen – mit Rueckfrage. */
export function KeyEntfernen({ schluesselId, name }: { schluesselId: string; name: string }) {
const dialog = useRef<HTMLDialogElement>(null);
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(eigenenKeyEntfernenAktion, {});
return (
<>
<Button
variante="gefahr"
klein
icon={<Trash2 className="size-4" />}
onClick={() => dialog.current?.showModal()}
aria-label={`${name} entfernen`}
>
Entfernen
</Button>
<dialog
ref={dialog}
aria-labelledby={`entfernen-${schluesselId}`}
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-md border p-5 backdrop:bg-black/70 sm:p-6"
>
<form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="id" value={schluesselId} />
<h2 id={`entfernen-${schluesselId}`} className="text-[17px] font-semibold">
„{name}“ entfernen?
</h2>
<p className="text-muted text-[14.5px]">
Der Public Key verschwindet aus sshkv. Er ist noch keinem Host zugewiesen; auf keinem Server
ändert sich etwas.
</p>
<Formularfehler text={zustand.fehler} />
<div className="flex flex-wrap justify-end gap-2.5">
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
<Button type="submit" variante="gefahr" disabled={laeuft}>
{laeuft ? "Entferne …" : "Entfernen"}
</Button>
</div>
</form>
</dialog>
</>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { Archive } from "lucide-react";
import { useActionState, useRef } from "react";
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
import { Auswahlliste, Button, Feld, Formularfehler, Karte, Textbereich } from "@/components/ui";
import type { FormularZustand } from "@/server/actions/formular";
import {
personAnlegenAktion,
personArchivierenAktion,
personBearbeitenAktion,
} from "@/server/actions/personen";
export type PersonFormularVorgabe = {
id?: string;
name: string;
email: string;
organisation: string;
notiz: string;
benutzerId: string;
};
export const LEERE_PERSON: PersonFormularVorgabe = {
name: "",
email: "",
organisation: "",
notiz: "",
benutzerId: "",
};
/** Formular fuer neue und bestehende Personen (#013). Regeln prueft der Server. */
export function PersonFormular({
vorgabe,
konten,
abbrechenZiel,
}: {
vorgabe: PersonFormularVorgabe;
konten: { id: string; name: string; email: string | null }[];
abbrechenZiel: string;
}) {
const bearbeiten = Boolean(vorgabe.id);
const { zustand, absenden, laeuft } = useFormularAktion(
bearbeiten ? personBearbeitenAktion : personAnlegenAktion,
);
const f = zustand.feldfehler ?? {};
return (
<form onSubmit={absenden} noValidate className="flex flex-col gap-6">
{vorgabe.id && <input type="hidden" name="id" value={vorgabe.id} />}
<Formularfehler text={zustand.fehler} />
<Karte titel="Person">
<div className="grid gap-4 sm:grid-cols-2">
<Feld
beschriftung="Name"
name="name"
defaultValue={vorgabe.name}
required
maxLength={120}
autoComplete="off"
fehler={f["name"]}
className="sm:col-span-2"
/>
<Feld
beschriftung="E-Mail (optional)"
name="email"
type="email"
defaultValue={vorgabe.email}
maxLength={254}
autoComplete="off"
fehler={f["email"]}
/>
<Feld
beschriftung="Organisation (optional)"
name="organisation"
defaultValue={vorgabe.organisation}
maxLength={120}
placeholder="z. B. Dienstleister"
fehler={f["organisation"]}
/>
<Auswahlliste
beschriftung="Verknüpftes Konto (optional)"
name="benutzerId"
defaultValue={vorgabe.benutzerId}
className="sm:col-span-2"
>
<option value="">keines</option>
{konten.map((k) => (
<option key={k.id} value={k.id}>
{k.email ? `${k.name} (${k.email})` : k.name}
</option>
))}
</Auswahlliste>
{f["benutzerId"] && <p className="text-danger text-[13px] sm:col-span-2">{f["benutzerId"]}</p>}
<Textbereich
beschriftung="Notiz (optional)"
name="notiz"
defaultValue={vorgabe.notiz}
maxLength={1000}
fehler={f["notiz"]}
className="sm:col-span-2"
/>
</div>
</Karte>
<div className="flex flex-wrap gap-2.5">
<Button type="submit" variante="primaer" disabled={laeuft}>
{laeuft ? "Speichere …" : bearbeiten ? "Änderungen speichern" : "Person anlegen"}
</Button>
<Button href={abbrechenZiel}>Abbrechen</Button>
</div>
</form>
);
}
/** Archivieren mit Rueckfrage, nativer `<dialog>` wie bei Hosts (#008). */
export function PersonArchivieren({ personId, name }: { personId: string; name: string }) {
const dialog = useRef<HTMLDialogElement>(null);
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(personArchivierenAktion, {});
return (
<>
<Button
variante="gefahr"
icon={<Archive className="size-4" />}
onClick={() => dialog.current?.showModal()}
>
Person archivieren
</Button>
<dialog
ref={dialog}
aria-labelledby="person-archivieren-titel"
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-md border p-5 backdrop:bg-black/70 sm:p-6"
>
<form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="id" value={personId} />
<h2 id="person-archivieren-titel" className="text-[17px] font-semibold">
{name} archivieren?
</h2>
<p className="text-muted text-[14.5px]">
Die Person verschwindet aus Listen und Auswahlfeldern, die Verknüpfung zum Konto entfällt. Ihre
Schlüssel bleiben zugeordnet und unverändert – widerrufen ist ein eigener Schritt.
</p>
<Formularfehler text={zustand.fehler} />
<div className="flex flex-wrap justify-end gap-2.5">
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
<Button type="submit" variante="gefahr" disabled={laeuft}>
{laeuft ? "Archiviere …" : "Archivieren"}
</Button>
</div>
</form>
</dialog>
</>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { Badge } from "@/components/ui/Badge";
import { datum } from "@/lib/format";
import {
ablaufZustand,
KATEGORIE_TEXT,
STATUS_TEXT,
typAnzeige,
type Kategorie,
type Status,
} from "@/lib/schluessel";
/** Kleine Anzeigebausteine fuer Schluessel (#012) – reine Darstellung. */
export function TypBadge({ typ, publicKey }: { typ: "ED25519" | "RSA4096" | "ANDERER"; publicKey: string }) {
return <Badge mono>{typAnzeige(typ, publicKey)}</Badge>;
}
export function KategorieBadge({ kategorie }: { kategorie: Kategorie }) {
return <Badge>{KATEGORIE_TEXT[kategorie]}</Badge>;
}
/** AKTIV zeigen wir nicht eigens – nur Abweichungen vom Normalfall. */
export function StatusBadge({ status }: { status: Status }) {
if (status === "AKTIV") return null;
return <Badge variante={status === "ROTIERT" ? "neutral" : "gefahr"}>{STATUS_TEXT[status]}</Badge>;
}
/**
* Ablauf: in Messing, sobald er in hoechstens 30 Tagen ansteht (Design #002), in Koralle,
* wenn er vorbei ist. `lang` zeigt zusaetzlich das Datum (Detailseite).
*/
export function AblaufBadge({
ablaufAm,
jetzt,
lang = false,
}: {
ablaufAm: Date | null;
jetzt: Date;
lang?: boolean;
}) {
const zustand = ablaufZustand(ablaufAm, jetzt);
if (zustand.art === "keiner" || !ablaufAm) return null;
const zeit = <time dateTime={ablaufAm.toISOString()}>{datum(ablaufAm)}</time>;
if (zustand.art === "abgelaufen") {
return <Badge variante="gefahr">{lang ? <span>abgelaufen am {zeit}</span> : "abgelaufen"}</Badge>;
}
const in_ = zustand.tage === 0 ? "heute" : zustand.tage === 1 ? "morgen" : `in ${zustand.tage} Tagen`;
if (zustand.art === "bald") {
return (
<Badge variante="messing">
{lang ? (
<span>
läuft ab am {zeit} · {in_}
</span>
) : (
<span>läuft ab {in_}</span>
)}
</Badge>
);
}
return lang ? (
<Badge variante="neutral" className="text-muted">
<span>läuft ab am {zeit}</span>
</Badge>
) : null;
}
@@ -0,0 +1,99 @@
"use client";
import { Auswahlliste, Button, Feld, Formularfehler, Karte } from "@/components/ui";
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
import { schluesselBearbeitenAktion } from "@/server/actions/schluessel";
import type { Auswahl } from "./SchluesselNeu";
export type SchluesselFormularWerte = {
id: string;
name: string;
tags: string[];
/** JJJJ-MM-TT oder leer */
ablaufAm: string;
personId: string;
vorlageId: string;
status: "AKTIV" | "ROTIERT" | "WIDERRUFEN" | "ABGELAUFEN";
statusAenderbar: boolean;
};
/**
* Metadaten bearbeiten (#012). Slug, Typ und Kommentar bleiben – sie stehen schon in
* Dateinamen und authorized_keys-Zeilen auf den Hosts.
*/
export function SchluesselBearbeiten({
werte,
personen,
vorlagen,
}: {
werte: SchluesselFormularWerte;
personen: Auswahl[];
vorlagen: Auswahl[];
}) {
const { zustand, absenden, laeuft } = useFormularAktion(schluesselBearbeitenAktion);
const f = zustand.feldfehler ?? {};
return (
<form onSubmit={absenden} noValidate className="flex flex-col gap-6">
<input type="hidden" name="id" value={werte.id} />
<Formularfehler text={zustand.fehler} />
<Karte titel="Metadaten">
<div className="grid gap-4 sm:grid-cols-2">
<Feld
beschriftung="Zweck"
name="name"
defaultValue={werte.name}
required
maxLength={120}
fehler={f["name"]}
className="sm:col-span-2"
/>
<Auswahlliste beschriftung="Person" name="personId" defaultValue={werte.personId}>
<option value="">keine</option>
{personen.map((p) => (
<option key={p.id} value={p.id}>
{p.zusatz ? `${p.name} (${p.zusatz})` : p.name}
</option>
))}
</Auswahlliste>
<Auswahlliste beschriftung="Vorlage" name="vorlageId" defaultValue={werte.vorlageId}>
<option value="">keine</option>
{vorlagen.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</Auswahlliste>
<Feld
beschriftung="Ablaufdatum"
name="ablaufAm"
type="date"
defaultValue={werte.ablaufAm}
fehler={f["ablaufAm"]}
/>
<Feld
beschriftung="Tags"
name="tags"
mono
defaultValue={werte.tags.join(", ")}
autoComplete="off"
spellCheck={false}
hinweis="Durch Komma oder Leerzeichen getrennt, max. 20."
fehler={f["tags"]}
/>
{werte.statusAenderbar && (
<Auswahlliste beschriftung="Status" name="status" defaultValue={werte.status}>
<option value="AKTIV">aktiv</option>
<option value="ABGELAUFEN">abgelaufen</option>
</Auswahlliste>
)}
</div>
</Karte>
<div className="flex flex-wrap gap-2.5">
<Button type="submit" variante="primaer" disabled={laeuft}>
{laeuft ? "Speichere …" : "Änderungen speichern"}
</Button>
<Button href={`/schluessel/${werte.id}`}>Abbrechen</Button>
</div>
</form>
);
}
+400
View File
@@ -0,0 +1,400 @@
"use client";
import { Download, KeyRound, Sparkles } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition, type FormEvent, type ReactNode } from "react";
import { clsx } from "clsx";
import {
Ankreuzfeld,
Auswahlliste,
Button,
Feld,
Feldgruppe,
Formularfehler,
Karte,
Textbereich,
} from "@/components/ui";
import { istLaufend, useVorgang } from "@/components/vorgang/useVorgang";
import {
paarImportierenAktion,
publicKeyImportierenAktion,
schluesselErzeugenAktion,
type SchluesselAntwort,
} from "@/server/actions/schluessel";
/**
* Neuer Schluessel (#012): erzeugen oder importieren (nur Public Key oder Paar).
*
* Erzeugen und Paar-Import laufen als Vorgang im Worker; das Formular verfolgt ihn und
* springt nach Erfolg zum Schluessel. Passphrase und Private Key stehen nur so lange im
* Formular, bis sie abgeschickt sind.
*/
type Modus = "erzeugen" | "public" | "paar";
export type Auswahl = { id: string; name: string; zusatz?: string | null };
const HINWEIS_PASSPHRASE = "Für automatische Anmeldungen (Host-zu-Host) ungeeignet.";
function Umschalter({
wert,
setzen,
optionen,
beschriftung,
}: {
wert: string;
setzen: (wert: string) => void;
optionen: { wert: string; text: string; icon?: ReactNode }[];
beschriftung: string;
}) {
return (
<div
role="radiogroup"
aria-label={beschriftung}
className="border-border-strong bg-sidebar rounded-box inline-flex w-full max-w-md gap-1 border p-1 sm:w-auto sm:self-start"
>
{optionen.map((o) => (
<button
key={o.wert}
type="button"
role="radio"
aria-checked={wert === o.wert}
onClick={() => setzen(o.wert)}
className={clsx(
"rounded-feld inline-flex min-h-10 flex-1 items-center justify-center gap-2 px-4 text-[14px] font-medium transition-colors sm:flex-none",
wert === o.wert ? "bg-raised text-text" : "text-muted hover:text-text",
)}
>
{o.icon}
{o.text}
</button>
))}
</div>
);
}
export function SchluesselNeu({
personen,
vorlagen,
vorgabePersonId,
}: {
personen: Auswahl[];
vorlagen: Auswahl[];
vorgabePersonId?: string;
}) {
const router = useRouter();
const [art, setArt] = useState<"erzeugen" | "importieren">("erzeugen");
const [importArt, setImportArt] = useState<"public" | "paar">("public");
const modus: Modus = art === "erzeugen" ? "erzeugen" : importArt;
const [antwort, setAntwort] = useState<SchluesselAntwort>({});
const [sendet, senden] = useTransition();
const { stand, abfrageFehler } = useVorgang(antwort.vorgangId);
// Geheimnisse kontrolliert, damit sie nach dem Absenden sofort aus dem Formular verschwinden.
const [privateKey, setPrivateKey] = useState("");
const [passphrase, setPassphrase] = useState("");
const [wiederholung, setWiederholung] = useState("");
const ergebnisId =
stand?.status === "ERLEDIGT" && stand.ergebnis && typeof stand.ergebnis === "object"
? (stand.ergebnis as { schluesselId?: unknown }).schluesselId
: undefined;
useEffect(() => {
if (typeof ergebnisId === "string") router.push(`/schluessel/${ergebnisId}`);
}, [ergebnisId, router]);
const vorgangLaeuft = antwort.vorgangId !== undefined && istLaufend(stand) && !abfrageFehler;
const beschaeftigt = sendet || vorgangLaeuft || typeof ergebnisId === "string";
const f = antwort.feldfehler ?? {};
const fehler =
antwort.fehler ??
abfrageFehler ??
(stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined);
function absenden(ereignis: FormEvent<HTMLFormElement>) {
ereignis.preventDefault();
const daten = new FormData(ereignis.currentTarget);
const aktion =
modus === "erzeugen"
? schluesselErzeugenAktion
: modus === "paar"
? paarImportierenAktion
: publicKeyImportierenAktion;
senden(async () => {
setAntwort({});
const neu = await aktion({}, daten);
if (neu.vorgangId || neu.schluesselId) {
setPrivateKey("");
setPassphrase("");
setWiederholung("");
}
if (neu.schluesselId) router.push(`/schluessel/${neu.schluesselId}`);
setAntwort(neu);
});
}
const knopfText = beschaeftigt
? modus === "erzeugen"
? "Erzeuge …"
: "Importiere …"
: modus === "erzeugen"
? "Schlüssel erzeugen"
: "Schlüssel importieren";
return (
<form onSubmit={absenden} noValidate className="flex flex-col gap-6" autoComplete="off">
<div className="flex flex-col gap-3">
<Umschalter
beschriftung="Art"
wert={art}
setzen={(w) => {
setArt(w as typeof art);
setAntwort({});
}}
optionen={[
{ wert: "erzeugen", text: "Erzeugen", icon: <Sparkles className="size-4" aria-hidden /> },
{ wert: "importieren", text: "Importieren", icon: <Download className="size-4" aria-hidden /> },
]}
/>
{art === "importieren" && (
<Umschalter
beschriftung="Was importieren?"
wert={importArt}
setzen={(w) => {
setImportArt(w as typeof importArt);
setAntwort({});
}}
optionen={[
{ wert: "public", text: "Nur Public Key" },
{ wert: "paar", text: "Schlüsselpaar" },
]}
/>
)}
</div>
<Formularfehler text={fehler} />
<Karte titel="Zweck">
<div className="grid gap-4 sm:grid-cols-2">
<Feld
beschriftung="Zweck"
name="name"
required
maxLength={120}
placeholder="Backup srv-backup01 → srv-pve01"
fehler={f["name"]}
className="sm:col-span-2"
/>
<Feld
beschriftung="Kurzname (optional)"
name="slug"
mono
maxLength={64}
spellCheck={false}
placeholder="wird aus dem Zweck gebildet"
hinweis="Für Dateinamen und Kommentare: a–z, 0–9, Bindestrich."
fehler={f["slug"]}
/>
<Feld
beschriftung="Kommentar (optional)"
name="kommentar"
mono
maxLength={200}
spellCheck={false}
placeholder={modus === "erzeugen" ? "sshkv:<kurzname>" : "aus dem Schlüssel"}
hinweis="Steht hinter dem Public Key in authorized_keys."
fehler={f["kommentar"]}
/>
</div>
</Karte>
{modus === "erzeugen" && (
<Karte
titel="Schlüssel"
untertitel="sshkv erzeugt das Paar im Worker und speichert den Private Key verschlüsselt"
>
<div className="flex flex-col gap-5">
<Feldgruppe legende="Typ" fehler={f["typ"]}>
<div className="grid gap-x-4 sm:grid-cols-2">
<Ankreuzfeld
type="radio"
name="typ"
value="ED25519"
defaultChecked
beschriftung={<span className="font-mono">ed25519</span>}
erklaerung="Empfohlen – kurz, schnell, sicher."
/>
<Ankreuzfeld
type="radio"
name="typ"
value="RSA4096"
beschriftung={<span className="font-mono">rsa-4096</span>}
erklaerung="Nur für alte Gegenstellen ohne ed25519."
/>
</div>
</Feldgruppe>
<p className="text-muted text-[13.5px]">
Kategorie: <span className="text-text">Dienst</span> – Personen hinterlegen ihre Public Keys
selbst unter „Meine Schlüssel“, deren Private Keys speichert sshkv nie.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
beschriftung="Passphrase (optional)"
name="passphrase"
type="password"
autoComplete="new-password"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
hinweis={`Wird nicht gespeichert. ${HINWEIS_PASSPHRASE}`}
fehler={f["passphrase"]}
/>
<Feld
beschriftung="Passphrase wiederholen"
name="passphraseWiederholung"
type="password"
autoComplete="new-password"
value={wiederholung}
onChange={(e) => setWiederholung(e.target.value)}
disabled={!passphrase}
fehler={f["passphraseWiederholung"]}
/>
</div>
</div>
</Karte>
)}
{modus === "public" && (
<Karte titel="Public Key" untertitel="Eine Zeile im OpenSSH-Format, z. B. aus id_ed25519.pub">
<div className="flex flex-col gap-5">
<Textbereich
beschriftung="Public Key"
name="publicKey"
required
rows={4}
spellCheck={false}
placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA… name@rechner"
fehler={f["publicKey"]}
className="[&_textarea]:font-mono [&_textarea]:text-[13px]"
/>
<Feldgruppe legende="Kategorie" fehler={f["kategorie"]}>
<div className="grid gap-x-4 sm:grid-cols-2">
<Ankreuzfeld
type="radio"
name="kategorie"
value="DIENST"
defaultChecked={!vorgabePersonId}
beschriftung="Dienst"
erklaerung="Ein Programm oder Host meldet sich damit an."
/>
<Ankreuzfeld
type="radio"
name="kategorie"
value="PERSON"
defaultChecked={Boolean(vorgabePersonId)}
beschriftung="Person"
erklaerung="Ein Mensch meldet sich damit an."
/>
</div>
</Feldgruppe>
</div>
</Karte>
)}
{modus === "paar" && (
<Karte
titel="Schlüsselpaar"
untertitel="Private Key im OpenSSH-Format – sshkv prüft ihn im Worker und speichert ihn verschlüsselt"
>
<div className="flex flex-col gap-5">
<Textbereich
beschriftung="Private Key"
name="privateKey"
required
rows={7}
spellCheck={false}
autoComplete="off"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder={"-----BEGIN OPENSSH PRIVATE KEY-----\n…\n-----END OPENSSH PRIVATE KEY-----"}
fehler={f["privateKey"]}
className="[&_textarea]:font-mono [&_textarea]:text-[12.5px]"
/>
<Feld
beschriftung="Passphrase (nur falls der Key eine hat)"
name="passphrase"
type="password"
autoComplete="off"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
hinweis={`Nur zur Prüfung, wird verworfen. Der Key bleibt passphrasengeschützt gespeichert – ${HINWEIS_PASSPHRASE.toLowerCase()}`}
fehler={f["passphrase"]}
className="sm:max-w-md"
/>
<p className="text-muted text-[13.5px]">
Kategorie: <span className="text-text">Dienst</span> – Private Keys von Personen speichert sshkv
nie.
</p>
</div>
</Karte>
)}
<Karte titel="Einordnung">
<div className="grid gap-4 sm:grid-cols-2">
<Auswahlliste
beschriftung={modus === "public" ? "Person (optional)" : "Verantwortliche Person (optional)"}
name="personId"
defaultValue={vorgabePersonId ?? ""}
>
<option value="">keine</option>
{personen.map((p) => (
<option key={p.id} value={p.id}>
{p.zusatz ? `${p.name} (${p.zusatz})` : p.name}
</option>
))}
</Auswahlliste>
<Auswahlliste beschriftung="Vorlage (optional)" name="vorlageId" defaultValue="">
<option value="">keine</option>
{vorlagen.map((v) => (
<option key={v.id} value={v.id}>
{v.zusatz ? `${v.name} (${v.zusatz})` : v.name}
</option>
))}
</Auswahlliste>
<Feld
beschriftung="Ablaufdatum (optional)"
name="ablaufAm"
type="date"
hinweis="Ohne Angabe gilt die Laufzeit der Vorlage, sonst keine."
fehler={f["ablaufAm"]}
/>
<Feld
beschriftung="Tags"
name="tags"
mono
autoComplete="off"
spellCheck={false}
placeholder="backup, pve"
hinweis="Durch Komma oder Leerzeichen getrennt, max. 20."
fehler={f["tags"]}
/>
</div>
</Karte>
<div className="flex flex-wrap items-center gap-2.5">
<Button
type="submit"
variante="primaer"
disabled={beschaeftigt}
icon={<KeyRound className="size-4" />}
>
{knopfText}
</Button>
<Button href="/schluessel">Abbrechen</Button>
<p role="status" className="text-muted text-[13px]">
{vorgangLaeuft && "Der Worker arbeitet …"}
</p>
</div>
</form>
);
}
+8 -1
View File
@@ -1,5 +1,6 @@
"use client";
import { clsx } from "clsx";
import { Check, Copy } from "lucide-react";
import { useState } from "react";
@@ -8,10 +9,13 @@ export function MonoBlock({
wert,
beschriftung,
hoehe,
umbrechen = false,
}: {
wert: string;
beschriftung: string;
hoehe?: string;
/** Lange Einzeiler (Public Keys) umbrechen statt seitlich scrollen (#012). */
umbrechen?: boolean;
}) {
const [kopiert, setKopiert] = useState(false);
return (
@@ -33,7 +37,10 @@ export function MonoBlock({
{kopiert ? <Check className="text-ok size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
</button>
<pre
className="text-text overflow-auto p-4 pr-16 font-mono text-[12.5px] leading-[1.55]"
className={clsx(
"text-text overflow-auto p-4 pr-16 font-mono text-[12.5px] leading-[1.55]",
umbrechen && "break-all whitespace-pre-wrap",
)}
style={hoehe ? { maxHeight: hoehe } : undefined}
tabIndex={0}
aria-label={beschriftung}
+37 -22
View File
@@ -2,52 +2,67 @@
import { useEffect, useState } from "react";
/** Was /api/vorgaenge/[id] liefert (#009, #016). */
/**
* Verfolgt einen Vorgang bis zum Ende (#012, #016): fragt /api/vorgaenge/[id] ab, bis der
* Status nicht mehr WARTEND oder LAEUFT ist. Der Stand ist nach Vorgangs-ID gemerkt – ein
* neuer Vorgang zeigt so nie den Stand des vorigen, ohne dass der Effekt synchron
* zuruecksetzen muss.
*/
export type VorgangsStand = {
readonly id: string;
readonly id?: string;
readonly status: string;
readonly fehler: string | null;
readonly plan: unknown;
readonly plan?: unknown;
readonly ergebnis: unknown;
};
const LAUFEND = new Set(["WARTEND", "LAEUFT"]);
/** Fuer Formulare: ohne Stand gilt der Vorgang als laufend (er wurde gerade angelegt). */
export function istLaufend(stand: VorgangsStand | undefined): boolean {
return stand === undefined || LAUFEND.has(stand.status);
}
/** Fuer Anzeigen: ohne Stand laeuft (noch) nichts. */
export function laeuftNoch(stand: VorgangsStand | undefined): boolean {
return stand !== undefined && LAUFEND.has(stand.status);
}
/** Fragt einen Vorgang jede Sekunde ab, bis er fertig ist. */
export function useVorgang(vorgangId: string | undefined): { stand?: VorgangsStand; fehler?: string } {
// Der Stand merkt sich seine Vorgangs-ID: so zeigt ein neuer Vorgang nie den alten Stand,
// ohne dass der Effekt synchron zuruecksetzen muss.
const [stand, setStand] = useState<VorgangsStand>();
const [fehler, setFehler] = useState<{ id: string; text: string }>();
type Gemerkt = { readonly id: string; readonly stand?: VorgangsStand; readonly fehler?: string };
export function useVorgang(vorgangId: string | undefined): {
stand: VorgangsStand | undefined;
/** Die Statusabfrage selbst scheiterte (nicht der Vorgang). */
abfrageFehler: string | undefined;
/** Gleichbedeutend mit abfrageFehler (aeltere Aufrufer, #016). */
fehler: string | undefined;
} {
const [gemerkt, setGemerkt] = useState<Gemerkt>();
useEffect(() => {
if (!vorgangId) return;
let abgebrochen = false;
void (async () => {
while (!abgebrochen) {
let abbrechen = false;
const abfragen = async () => {
while (!abbrechen) {
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
if (abbrechen) return;
if (!antwort.ok) {
setFehler({ id: vorgangId, text: "Status des Vorgangs nicht abrufbar." });
setGemerkt({ id: vorgangId, fehler: "Status des Vorgangs nicht abrufbar." });
return;
}
const neu = (await antwort.json()) as VorgangsStand;
if (abgebrochen) return;
setStand(neu);
if (abbrechen) return;
setGemerkt({ id: vorgangId, stand: neu });
if (!LAUFEND.has(neu.status)) return;
await new Promise((weiter) => setTimeout(weiter, 1000));
await new Promise((weiter) => setTimeout(weiter, 800));
}
})();
};
void abfragen();
return () => {
abgebrochen = true;
abbrechen = true;
};
}, [vorgangId]);
return {
...(stand && stand.id === vorgangId ? { stand } : {}),
...(fehler && fehler.id === vorgangId ? { fehler: fehler.text } : {}),
};
const aktuell = gemerkt && gemerkt.id === vorgangId ? gemerkt : undefined;
return { stand: aktuell?.stand, abfrageFehler: aktuell?.fehler, fehler: aktuell?.fehler };
}
+52
View File
@@ -0,0 +1,52 @@
import { z } from "@/lib/validierung";
/**
* Eingaben fuer Personen und den Self-Service (#013) – framework-frei, damit Service,
* Actions und Tests dieselben Regeln teilen.
*/
const optionalerText = (max: number) =>
z
.string()
.trim()
.max(max, `Höchstens ${max} Zeichen.`)
.nullish()
.transform((wert) => (wert ? wert : null));
export const personEingabeSchema = z.object({
name: z.string().trim().min(1, "Bitte einen Namen angeben.").max(120, "Höchstens 120 Zeichen."),
email: z
.string()
.trim()
.toLowerCase()
.max(254)
.nullish()
.transform((wert) => (wert ? wert : null))
.refine(
(wert) => wert === null || z.email().safeParse(wert).success,
"Bitte eine gültige E-Mail-Adresse.",
),
organisation: optionalerText(120),
notiz: optionalerText(1000),
benutzerId: z
.string()
.trim()
.max(64)
.nullish()
.transform((wert) => (wert ? wert : null)),
});
export type PersonEingabe = z.input<typeof personEingabeSchema>;
export type PersonDaten = z.output<typeof personEingabeSchema>;
/** Eigener Public Key im Self-Service: Zweck und die Zeile, sonst nichts. */
export const eigenerKeySchema = z.object({
name: z.string().trim().min(1, "Bitte eine Bezeichnung angeben.").max(120, "Höchstens 120 Zeichen."),
publicKey: z
.string()
.trim()
.min(1, "Bitte den Public Key einfügen.")
.max(16 * 1024, "Der Text ist zu lang für einen Public Key."),
});
export type EigenerKeyEingabe = z.input<typeof eigenerKeySchema>;
+292
View File
@@ -0,0 +1,292 @@
import { MAX_TAGS, TAG } from "@/lib/hosts";
import { tageBis } from "@/lib/format";
import { z } from "@/lib/validierung";
/**
* Eingaben und Anzeigeregeln fuer Schluessel (#012).
*
* Framework-frei: Web-Service, Server Actions, Worker-Ablaeufe und Tests teilen dieselben
* Regeln. Der Worker prueft die Metadaten aus `Vorgang.eingabe` noch einmal, weil zwischen
* Anfrage und Ausfuehrung Zeit vergeht und die Eingabe in der Datenbank liegt.
*/
/** Wie der DB-CHECK `schluessel_slug_gueltig`. */
export const SLUG = /^[a-z0-9-]{1,64}$/;
export const NAME_MAX = 120;
export const KOMMENTAR_MAX = 200;
/** Ein OpenSSH-Private-Key mit RSA-16384 bleibt unter 16 KiB; mehr ist kein Schluessel. */
export const PRIVATE_KEY_MAX = 16 * 1024;
export const PASSPHRASE_MAX = 1024;
/**
* Wie lange ein vom Worker geoeffneter Private Key zur Abholung bereitliegt (#014). Worker
* (Aufraeumen) und Web (Abholen) pruefen dieselbe Frist.
*/
export const ABHOLFRIST_MS = 2 * 60 * 1000;
/** Ab hier wird ein Ablaufdatum in Messing hervorgehoben. */
export const ABLAUF_WARNUNG_TAGE = 30;
const UMLAUTE: Record<string, string> = { ä: "ae", ö: "oe", ü: "ue", ß: "ss" };
/**
* Slug aus dem Zweck: „Backup srv-backup01 → srv-pve01“ wird zu
* `backup-srv-backup01-srv-pve01`. Ohne verwertbare Zeichen bleibt „schluessel“.
*/
export function slugAus(name: string): string {
const slug = name
.toLowerCase()
.replace(/[äöüß]/g, (z) => UMLAUTE[z] ?? z)
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64)
.replace(/-+$/, "");
return slug || "schluessel";
}
/** Haengt `-2`, `-3` … an, bis `istFrei` zustimmt; kuerzt vorher, damit 64 Zeichen reichen. */
export async function freierSlug(
basis: string,
istFrei: (slug: string) => Promise<boolean>,
): Promise<string> {
if (await istFrei(basis)) return basis;
for (let n = 2; n < 1000; n++) {
const anhang = `-${n}`;
const kandidat = `${basis.slice(0, 64 - anhang.length).replace(/-+$/, "")}${anhang}`;
if (await istFrei(kandidat)) return kandidat;
}
throw new Error("Kein freier Slug gefunden.");
}
/** Freitext aus einem Formular: leer heisst „nicht gesetzt“. */
const optionalerText = (max: number) =>
z
.string()
.trim()
.max(max, `Höchstens ${max} Zeichen.`)
.nullish()
.transform((wert) => (wert ? wert : null));
const optionaleId = z
.string()
.trim()
.max(64)
.nullish()
.transform((wert) => (wert ? wert : null));
const tagsSchema = z
.preprocess(
(wert) => (typeof wert === "string" ? wert.split(/[\s,]+/).filter(Boolean) : (wert ?? [])),
z.array(
z
.string()
.trim()
.toLowerCase()
.regex(TAG, "Tags: 1–32 Zeichen aus a–z, 0–9, Punkt, Unterstrich, Bindestrich."),
),
)
.transform((tags) => [...new Set(tags)])
.refine((tags) => tags.length <= MAX_TAGS, `Höchstens ${MAX_TAGS} Tags.`);
/**
* Ablaufdatum als `JJJJ-MM-TT` aus `<input type="date">`. Gespeichert wird Mitternacht UTC
* des Tages – `datum()` zeigt in Europe/Berlin denselben Kalendertag.
*/
const ablaufSchema = z
.string()
.trim()
.nullish()
.transform((wert, kontext) => {
if (!wert) return null;
if (!/^\d{4}-\d{2}-\d{2}$/.test(wert)) {
kontext.addIssue({ code: "custom", message: "Bitte ein Datum angeben." });
return z.NEVER;
}
const datum = new Date(`${wert}T00:00:00Z`);
if (Number.isNaN(datum.getTime()) || datum.toISOString().slice(0, 10) !== wert) {
kontext.addIssue({ code: "custom", message: "Bitte ein gültiges Datum angeben." });
return z.NEVER;
}
return datum;
});
/** Kommentar der Public-Key-Zeile: keine Steuerzeichen, sie braechen authorized_keys auf. */
const kommentarSchema = optionalerText(KOMMENTAR_MAX).refine(
(wert) => wert === null || !/[\x00-\x1f\x7f]/.test(wert),
"Der Kommentar enthält unzulässige Zeichen.",
);
const slugSchema = z
.string()
.trim()
.toLowerCase()
.nullish()
.transform((wert) => (wert ? wert : null))
.refine((wert) => wert === null || SLUG.test(wert), "1–64 Zeichen aus a–z, 0–9 und Bindestrich.");
/** Metadaten, die jeder Schluessel hat – beim Anlegen und Bearbeiten. */
const metadatenFelder = {
name: z.string().trim().min(1, "Bitte den Zweck angeben.").max(NAME_MAX, `Höchstens ${NAME_MAX} Zeichen.`),
tags: tagsSchema,
ablaufAm: ablaufSchema,
personId: optionaleId,
vorlageId: optionaleId,
};
/**
* Erzeugen (#012). Kategorie ist fest DIENST: Personen-Keys speichert sshkv nie mit Private
* Key (DB-CHECK `schluessel_person_ohne_private_key`), Deployment-Keys verwaltet der Worker.
*/
export const erzeugenSchema = z.object({
...metadatenFelder,
slug: slugSchema,
typ: z.enum(["ED25519", "RSA4096"]).default("ED25519"),
kommentar: kommentarSchema,
});
/** Import eines Paars: dieselben Regeln wie beim Erzeugen, der Typ steckt im Key selbst. */
export const paarImportSchema = z.object({
...metadatenFelder,
slug: slugSchema,
kommentar: kommentarSchema,
});
/** Import nur des Public Keys: auch fuer Personen-Keys. */
export const publicImportSchema = z.object({
...metadatenFelder,
slug: slugSchema,
kategorie: z.enum(["PERSON", "DIENST"]).default("DIENST"),
publicKey: z
.string()
.trim()
.min(1, "Bitte den Public Key einfügen.")
.max(16 * 1024, "Der Text ist zu lang für einen Public Key."),
kommentar: kommentarSchema,
});
/**
* Bearbeiten: Slug, Typ und Kommentar bleiben – sie stehen schon in Dateinamen und
* authorized_keys-Zeilen auf den Hosts. ROTIERT/WIDERRUFEN setzen nur Rotation und Widerruf
* (#023, #024); von Hand gibt es nur AKTIV und ABGELAUFEN.
*/
export const bearbeitenSchema = z.object({
...metadatenFelder,
status: z.enum(["AKTIV", "ABGELAUFEN"]).optional(),
});
/** Tags kommen als Text („pve, backup“) oder Liste; `z.preprocess` liesse sonst nur `unknown` uebrig. */
type MitTags<T> = Omit<T, "tags"> & { tags?: string | string[] };
export type ErzeugenEingabe = MitTags<z.input<typeof erzeugenSchema>>;
export type ErzeugenDaten = z.output<typeof erzeugenSchema>;
export type PaarImportEingabe = MitTags<z.input<typeof paarImportSchema>>;
export type PaarImportDaten = z.output<typeof paarImportSchema>;
export type PublicImportEingabe = MitTags<z.input<typeof publicImportSchema>>;
export type PublicImportDaten = z.output<typeof publicImportSchema>;
export type BearbeitenEingabe = MitTags<z.input<typeof bearbeitenSchema>>;
export type BearbeitenDaten = z.output<typeof bearbeitenSchema>;
/**
* Was ein Erzeugen- oder Import-Vorgang in `Vorgang.eingabe` traegt – nur Metadaten, nie ein
* Geheimnis. Datumswerte als ISO-Text, weil `eingabe` JSON ist.
*/
export type SchluesselVorgangEingabe = {
readonly name: string;
readonly slug: string;
readonly tags: string[];
readonly ablaufAm: string | null;
readonly personId: string | null;
readonly vorlageId: string | null;
readonly kommentar: string | null;
readonly typ?: "ED25519" | "RSA4096";
/** Nur Import: soll der Worker eine Passphrase erwarten? Die Passphrase selbst reist im Job. */
readonly mitPassphrase?: boolean;
};
/** Prueft `Vorgang.eingabe` im Worker – sie lag zwischendurch in der Datenbank. */
export const vorgangEingabeSchema = z.object({
name: z.string().trim().min(1).max(NAME_MAX),
slug: z.string().regex(SLUG),
tags: z.array(z.string().regex(TAG)).max(MAX_TAGS),
ablaufAm: z.iso.datetime().nullable(),
personId: z.string().max(64).nullable(),
vorlageId: z.string().max(64).nullable(),
kommentar: z
.string()
.max(KOMMENTAR_MAX)
.refine((wert) => !/[\x00-\x1f\x7f]/.test(wert))
.nullable(),
typ: z.enum(["ED25519", "RSA4096"]).optional(),
mitPassphrase: z.boolean().optional(),
});
/** Was im verschluesselten `geheim` eines Erzeugen- oder Import-Jobs steht. */
export type SchluesselGeheimnis = {
readonly privateKey?: string;
readonly passphrase?: string;
};
// ---------------------------------------------------------------- Anzeige
export type Kategorie = "PERSON" | "DIENST" | "DEPLOYMENT";
export type Status = "AKTIV" | "ROTIERT" | "WIDERRUFEN" | "ABGELAUFEN";
export const KATEGORIE_TEXT: Record<Kategorie, string> = {
PERSON: "Person",
DIENST: "Dienst",
DEPLOYMENT: "Deployment",
};
export const STATUS_TEXT: Record<Status, string> = {
AKTIV: "aktiv",
ROTIERT: "rotiert",
WIDERRUFEN: "widerrufen",
ABGELAUFEN: "abgelaufen",
};
/** Kurzform fuer Listen: `ed25519`, `rsa-4096`, sonst der Typ aus der Public-Key-Zeile. */
export function typAnzeige(typ: "ED25519" | "RSA4096" | "ANDERER", publicKey: string): string {
if (typ === "ED25519") return "ed25519";
if (typ === "RSA4096") return "rsa-4096";
const algorithmus = publicKey.split(/\s+/)[0] ?? "";
if (algorithmus === "ssh-rsa") return "rsa";
return algorithmus.replace(/^ssh-/, "").replace(/@openssh\.com$/, "") || "unbekannt";
}
/** `SHA256:q3vN8xP0…Qe0k` – lang genug zum Wiedererkennen, kurz genug fuer eine Tabellenzeile. */
export function fingerprintKurz(fingerprint: string): string {
const [praefix, rest] = fingerprint.includes(":") ? fingerprint.split(":", 2) : ["", fingerprint];
const wert = rest ?? "";
if (wert.length <= 14) return fingerprint;
return `${praefix ? `${praefix}:` : ""}${wert.slice(0, 8)}…${wert.slice(-4)}`;
}
export type AblaufZustand =
| { readonly art: "keiner" }
| { readonly art: "spaeter"; readonly tage: number }
| { readonly art: "bald"; readonly tage: number }
| { readonly art: "abgelaufen"; readonly tage: number };
/** Wie dringend ist das Ablaufdatum? `bald` heisst: in hoechstens 30 Tagen. */
export function ablaufZustand(ablaufAm: Date | string | null, jetzt: Date = new Date()): AblaufZustand {
if (!ablaufAm) return { art: "keiner" };
const tage = tageBis(ablaufAm, jetzt);
if (tage < 0) return { art: "abgelaufen", tage };
if (tage <= ABLAUF_WARNUNG_TAGE) return { art: "bald", tage };
return { art: "spaeter", tage };
}
/** Typ fuer die Datenbank aus Algorithmus und Laenge: nur ed25519 und RSA-4096 haben eigene Werte. */
export function schluesselTypVon(
algorithmus: string,
bits: number | null,
): "ED25519" | "RSA4096" | "ANDERER" {
if (algorithmus === "ssh-ed25519") return "ED25519";
if (algorithmus === "ssh-rsa" && bits === 4096) return "RSA4096";
return "ANDERER";
}
/** Public-Key-Zeile mit dem gespeicherten Kommentar: `typ base64 kommentar`. */
export function publicKeyZeileMitKommentar(zeile: string, kommentar: string): string {
const [typ, base64] = zeile.trim().split(/\s+/);
return kommentar ? `${typ} ${base64} ${kommentar}` : `${typ} ${base64}`;
}
+43
View File
@@ -0,0 +1,43 @@
"use server";
import { revalidatePath } from "next/cache";
import { fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { eigenenPublicKeyHinterlegen, eigenenSchluesselEntfernen } from "@/server/services/meine-schluessel";
/**
* Self-Service (#013). Recht `eigeneSchluesselHinterlegen` hat jede Rolle; der Service
* arbeitet ausschliesslich mit der Person des angemeldeten Kontos.
*/
export type HinterlegenZustand = FormularZustand & { readonly erfolg?: string };
export async function eigenenKeyHinterlegenAktion(
_vorher: HinterlegenZustand,
formular: FormData,
): Promise<HinterlegenZustand> {
try {
const { benutzer } = await rechtErforderlich("eigeneSchluesselHinterlegen");
const neu = await eigenenPublicKeyHinterlegen(benutzer, {
name: textfeld(formular, "name") ?? "",
publicKey: textfeld(formular, "publicKey") ?? "",
});
revalidatePath("/meine-schluessel");
return { erfolg: `Hinterlegt: ${neu.fingerprint}` };
} catch (fehler) {
return fehlerZustand(fehler);
}
}
export async function eigenenKeyEntfernenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
try {
const { benutzer } = await rechtErforderlich("eigeneSchluesselHinterlegen");
await eigenenSchluesselEntfernen(benutzer, textfeld(formular, "id") ?? "");
revalidatePath("/meine-schluessel");
return {};
} catch (fehler) {
return fehlerZustand(fehler);
}
}
+68
View File
@@ -0,0 +1,68 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import type { PersonEingabe } from "@/lib/personen";
import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { personAnlegen, personArchivieren, personBearbeiten } from "@/server/services/personen";
/**
* Formular-Aktionen fuer Personen (#013). Personen pflegt, wer Schluessel verwaltet (Admin,
* Operator) – eine Person ist die Klammer um Personen-Keys.
*/
function personFelder(formular: FormData): PersonEingabe {
return {
name: textfeld(formular, "name") ?? "",
email: textfeld(formular, "email"),
organisation: textfeld(formular, "organisation"),
notiz: textfeld(formular, "notiz"),
benutzerId: textfeld(formular, "benutzerId"),
};
}
export async function personAnlegenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
let id: string;
try {
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
({ id } = await personAnlegen(personFelder(formular), akteurVon(benutzer)));
} catch (fehler) {
return fehlerZustand(fehler);
}
revalidatePath("/personen");
redirect(`/personen/${id}`);
}
export async function personBearbeitenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
const id = textfeld(formular, "id") ?? "";
try {
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
await personBearbeiten(id, personFelder(formular), akteurVon(benutzer));
} catch (fehler) {
return fehlerZustand(fehler);
}
revalidatePath("/personen", "layout");
redirect(`/personen/${id}`);
}
export async function personArchivierenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
const id = textfeld(formular, "id") ?? "";
try {
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
await personArchivieren(id, akteurVon(benutzer));
} catch (fehler) {
return fehlerZustand(fehler);
}
revalidatePath("/personen", "layout");
redirect("/personen");
}
+70
View File
@@ -0,0 +1,70 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { akteurVon } from "@/server/actions/formular";
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { HostFehler } from "@/server/services/hosts";
import {
abholungAnfordern,
privateKeyAbholen,
privateKeyLoeschenAnfordern,
} from "@/server/services/privatekey";
/**
* Private Key anzeigen und loeschen (#014). Jede Aktion prueft selbst Recht **und** frische
* Bestaetigung (`rechtErforderlich` mit einem Recht aus BESTAETIGUNG_NOETIG). Fehlt die
* Bestaetigung, meldet die Antwort `bestaetigen: true`, und die Oberflaeche leitet nach
* /bestaetigen weiter.
*
* `privateKeyAnzeigenAktion` ist die einzige Stelle, an der ein Private Key als Antwort an den
* Browser geht – genau einmal, fuer die Anzeige im Dialog.
*/
export type PrivateKeyAntwort = {
readonly vorgangId?: string;
readonly fehler?: string;
readonly bestaetigen?: boolean;
readonly privateKey?: string;
};
const id = z.string().min(1).max(64);
const zweck = z.enum(["anzeigen", "herunterladen"]);
function fehlerAntwort(fehler: unknown): PrivateKeyAntwort {
if (fehler instanceof BestaetigungFehler) return { fehler: fehler.message, bestaetigen: true };
if (fehler instanceof RechteFehler || fehler instanceof HostFehler) return { fehler: fehler.message };
if (fehler instanceof z.ZodError) return { fehler: "Ungültige Anfrage." };
throw fehler;
}
export async function abholungAnfordernAktion(schluesselId: string, art: string): Promise<PrivateKeyAntwort> {
try {
const { benutzer } = await rechtErforderlich("privateKeyAnzeigen");
return {
vorgangId: await abholungAnfordern(id.parse(schluesselId), zweck.parse(art), akteurVon(benutzer)),
};
} catch (fehler) {
return fehlerAntwort(fehler);
}
}
export async function privateKeyAnzeigenAktion(vorgangId: string): Promise<PrivateKeyAntwort> {
try {
const { benutzer } = await rechtErforderlich("privateKeyAnzeigen");
const { privateKey } = await privateKeyAbholen(id.parse(vorgangId), "anzeigen", akteurVon(benutzer));
return { privateKey };
} catch (fehler) {
return fehlerAntwort(fehler);
}
}
export async function privateKeyLoeschenAktion(schluesselId: string): Promise<PrivateKeyAntwort> {
try {
const { benutzer } = await rechtErforderlich("privateKeyLoeschen");
const vorgangId = await privateKeyLoeschenAnfordern(id.parse(schluesselId), akteurVon(benutzer));
revalidatePath(`/schluessel/${schluesselId}`);
return { vorgangId };
} catch (fehler) {
return fehlerAntwort(fehler);
}
}
+137
View File
@@ -0,0 +1,137 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
erzeugenAnfordern,
paarImportAnfordern,
publicKeyImportieren,
schluesselBearbeiten,
} from "@/server/services/schluessel";
/**
* Aktionen fuer Schluessel (#012). Duenn: Recht pruefen, Felder lesen, Service rufen.
*
* Passphrase und Private Key verlassen diese Funktionen nur verschluesselt im Job (der
* Service verschluesselt sie) – nie in einer Antwort, einem Log oder dem Protokoll.
*/
export type SchluesselAntwort = FormularZustand & {
/** Vorgang im Worker (Erzeugen, Paar-Import) – die Oberflaeche verfolgt ihn. */
readonly vorgangId?: string;
/** Sofort angelegt (Public-Key-Import). */
readonly schluesselId?: string;
};
function metadaten(formular: FormData) {
return {
name: textfeld(formular, "name") ?? "",
slug: textfeld(formular, "slug"),
kommentar: textfeld(formular, "kommentar"),
tags: textfeld(formular, "tags") ?? "",
ablaufAm: textfeld(formular, "ablaufAm"),
personId: textfeld(formular, "personId"),
vorlageId: textfeld(formular, "vorlageId"),
};
}
/** Passphrase mit Wiederholung – die Wiederholung prueft der Server, nicht nur das Formular. */
function passphraseLesen(formular: FormData): { passphrase?: string; fehler?: SchluesselAntwort } {
const passphrase = textfeld(formular, "passphrase");
const wiederholung = textfeld(formular, "passphraseWiederholung");
if (passphrase && passphrase !== wiederholung) {
return {
fehler: {
fehler: "Die Passphrasen stimmen nicht überein.",
feldfehler: { passphraseWiederholung: "Stimmt nicht mit der Passphrase überein." },
},
};
}
return passphrase ? { passphrase } : {};
}
export async function schluesselErzeugenAktion(
_vorher: SchluesselAntwort,
formular: FormData,
): Promise<SchluesselAntwort> {
try {
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
const { passphrase, fehler } = passphraseLesen(formular);
if (fehler) return fehler;
const vorgangId = await erzeugenAnfordern(
{ ...metadaten(formular), typ: textfeld(formular, "typ") as "ED25519" | "RSA4096" | undefined },
passphrase,
akteurVon(benutzer),
);
return { vorgangId };
} catch (fehler) {
return fehlerZustand(fehler);
}
}
export async function paarImportierenAktion(
_vorher: SchluesselAntwort,
formular: FormData,
): Promise<SchluesselAntwort> {
try {
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
const vorgangId = await paarImportAnfordern(
metadaten(formular),
textfeld(formular, "privateKey"),
// Beim Import nur zur Pruefung – keine Wiederholung noetig, falsche merkt der Worker.
textfeld(formular, "passphrase"),
akteurVon(benutzer),
);
return { vorgangId };
} catch (fehler) {
return fehlerZustand(fehler);
}
}
export async function publicKeyImportierenAktion(
_vorher: SchluesselAntwort,
formular: FormData,
): Promise<SchluesselAntwort> {
try {
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
const schluessel = await publicKeyImportieren(
{
...metadaten(formular),
kategorie: textfeld(formular, "kategorie") as "PERSON" | "DIENST" | undefined,
publicKey: textfeld(formular, "publicKey") ?? "",
},
akteurVon(benutzer),
);
revalidatePath("/schluessel");
return { schluesselId: schluessel.id };
} catch (fehler) {
return fehlerZustand(fehler);
}
}
export async function schluesselBearbeitenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
const id = textfeld(formular, "id") ?? "";
try {
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
await schluesselBearbeiten(
id,
{
name: textfeld(formular, "name") ?? "",
tags: textfeld(formular, "tags") ?? "",
ablaufAm: textfeld(formular, "ablaufAm"),
personId: textfeld(formular, "personId"),
vorlageId: textfeld(formular, "vorlageId"),
status: textfeld(formular, "status") as "AKTIV" | "ABGELAUFEN" | undefined,
},
akteurVon(benutzer),
);
} catch (fehler) {
return fehlerZustand(fehler);
}
revalidatePath("/schluessel", "layout");
redirect(`/schluessel/${id}`);
}
+4
View File
@@ -16,6 +16,7 @@ export type Recht =
| "publicKeyAusrollen"
| "privateKeyAusrollen"
| "privateKeyAnzeigen"
| "privateKeyLoeschen"
| "deploymentKeyVerwalten"
| "massenwiderruf"
| "zielkontenVerwalten"
@@ -33,6 +34,8 @@ export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
// Zugriff hat, kann ihn faktisch lesen – deshalb nur Admins.
privateKeyAusrollen: ["ADMIN"],
privateKeyAnzeigen: ["ADMIN"],
// Endgueltig und nicht rueckgaengig zu machen (#014) – wie Anzeigen nur mit Re-Auth.
privateKeyLoeschen: ["ADMIN"],
deploymentKeyVerwalten: ["ADMIN"],
massenwiderruf: ["ADMIN"],
zielkontenVerwalten: ["ADMIN"],
@@ -42,6 +45,7 @@ export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
/** Rechte, die zusaetzlich eine frische Bestaetigung (Re-Auth) verlangen. */
export const BESTAETIGUNG_NOETIG: ReadonlySet<Recht> = new Set<Recht>([
"privateKeyAnzeigen",
"privateKeyLoeschen",
"deploymentKeyVerwalten",
"massenwiderruf",
"zielkontenVerwalten",
+103
View File
@@ -0,0 +1,103 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
/**
* Lesemodelle fuer Personen (#013). Jede angemeldete Rolle darf sie sehen; Schluessel nur mit
* festem `select` ohne Chiffrat-Felder.
*/
export type PersonFilter = { readonly q?: string; readonly archivierte?: boolean };
export async function personenListe(filter: PersonFilter = {}) {
const q = filter.q?.trim().slice(0, 100);
const where: Prisma.PersonWhereInput = {
...(filter.archivierte ? {} : { archiviertAm: null }),
...(q
? {
OR: [
{ name: { contains: q, mode: "insensitive" } },
{ email: { contains: q, mode: "insensitive" } },
{ organisation: { contains: q, mode: "insensitive" } },
],
}
: {}),
};
const personen = await prisma.person.findMany({
where,
orderBy: { name: "asc" },
select: {
id: true,
name: true,
email: true,
organisation: true,
archiviertAm: true,
benutzer: { select: { id: true, name: true } },
_count: { select: { schluessel: { where: { status: "AKTIV" } } } },
},
});
return personen.map(({ _count, ...rest }) => ({ ...rest, aktiveSchluessel: _count.schluessel }));
}
export type PersonListenEintrag = Awaited<ReturnType<typeof personenListe>>[number];
export async function personDetail(id: string) {
const person = await prisma.person.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
organisation: true,
notiz: true,
archiviertAm: true,
angelegtAm: true,
benutzerId: true,
benutzer: { select: { id: true, name: true, email: true, rolle: true } },
schluessel: {
orderBy: [{ status: "asc" }, { name: "asc" }],
select: {
id: true,
name: true,
typ: true,
kategorie: true,
status: true,
publicKey: true,
fingerprint: true,
ablaufAm: true,
selbstHinterlegt: true,
_count: {
select: {
publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
},
},
},
},
},
});
if (!person) return null;
return {
...person,
schluessel: person.schluessel.map(({ _count, ...rest }) => ({
...rest,
verwendungen: _count.publicVerwendungen + _count.privateVerwendungen,
})),
};
}
export type PersonDetail = NonNullable<Awaited<ReturnType<typeof personDetail>>>;
/**
* Konten, die sich mit einer Person verknuepfen lassen: noch keiner Person zugeordnet, oder
* der Person `personId` selbst. Der Notfall-Admin ist kein Mensch mit eigenen Keys.
*/
export async function kontenAuswahl(personId?: string) {
return prisma.benutzer.findMany({
where: {
istNotfall: false,
OR: [{ person: null }, ...(personId ? [{ person: { id: personId } }] : [])],
},
orderBy: { name: "asc" },
select: { id: true, name: true, email: true },
});
}
+168
View File
@@ -0,0 +1,168 @@
import type { Prisma } from "@/generated/prisma/client";
import type { SchluesselKategorie, SchluesselStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
/**
* Lesemodelle fuer Schluessel (#012). Alles hier darf jede angemeldete Rolle sehen.
*
* Feste `select`-Listen ohne Chiffrat-Felder (CLAUDE.md, „Feste Regeln“). Ob ein Private Key
* gespeichert ist, verraet `masterKeyVersion`: der DB-CHECK `schluessel_umschlag_vollstaendig`
* setzt sie genau dann, wenn auch ein Chiffrat da ist – so muss das Chiffrat nie gelesen
* werden, nicht einmal fuer ein „ist null?“.
*/
export const SCHLUESSEL_LISTE_SELECT = {
id: true,
name: true,
slug: true,
typ: true,
kategorie: true,
status: true,
publicKey: true,
fingerprint: true,
ablaufAm: true,
masterKeyVersion: true,
passphraseGeschuetzt: true,
person: { select: { id: true, name: true } },
_count: {
select: {
publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
},
},
} satisfies Prisma.SchluesselSelect;
export const SCHLUESSEL_DETAIL_SELECT = {
id: true,
name: true,
slug: true,
typ: true,
kategorie: true,
status: true,
publicKey: true,
fingerprint: true,
kommentar: true,
masterKeyVersion: true,
privatGeloeschtAm: true,
passphraseGeschuetzt: true,
instanzStandard: true,
selbstHinterlegt: true,
tags: true,
ablaufAm: true,
zuletztVerwendetAm: true,
angelegtAm: true,
personId: true,
vorlageId: true,
person: { select: { id: true, name: true } },
vorlage: { select: { id: true, name: true } },
erstelltVon: { select: { name: true } },
publicVerwendungen: {
where: { zustand: { not: "ENTFERNT" } },
orderBy: { angelegtAm: "asc" },
select: {
id: true,
zielbenutzer: true,
optionen: true,
zustand: true,
zuletztAngewendetAm: true,
host: { select: { id: true, name: true } },
hostgruppe: { select: { id: true, name: true } },
},
},
privateVerwendungen: {
where: { zustand: { not: "ENTFERNT" } },
orderBy: { angelegtAm: "asc" },
select: {
id: true,
zielbenutzer: true,
pfad: true,
sshConfig: true,
zustand: true,
zuletztAngewendetAm: true,
host: { select: { id: true, name: true } },
},
},
} satisfies Prisma.SchluesselSelect;
export type SchluesselFilter = {
readonly q?: string;
readonly kategorie?: string;
readonly status?: string;
};
const KATEGORIEN: readonly SchluesselKategorie[] = ["PERSON", "DIENST", "DEPLOYMENT"];
const STATI: readonly SchluesselStatus[] = ["AKTIV", "ROTIERT", "WIDERRUFEN", "ABGELAUFEN"];
function alsKategorie(wert: string | undefined): SchluesselKategorie | undefined {
return KATEGORIEN.find((k) => k === wert);
}
function alsStatus(wert: string | undefined): SchluesselStatus | undefined {
return STATI.find((s) => s === wert);
}
export async function schluesselListe(filter: SchluesselFilter = {}) {
const q = filter.q?.trim().slice(0, 100);
const kategorie = alsKategorie(filter.kategorie);
const status = alsStatus(filter.status);
const where: Prisma.SchluesselWhereInput = {
...(kategorie ? { kategorie } : {}),
...(status ? { status } : {}),
...(q
? {
OR: [
{ name: { contains: q, mode: "insensitive" } },
{ slug: { contains: q.toLowerCase() } },
{ fingerprint: { contains: q } },
{ kommentar: { contains: q, mode: "insensitive" } },
{ tags: { has: q.toLowerCase() } },
{ person: { name: { contains: q, mode: "insensitive" } } },
],
}
: {}),
};
const zeilen = await prisma.schluessel.findMany({
where,
orderBy: [{ name: "asc" }],
select: SCHLUESSEL_LISTE_SELECT,
});
return zeilen.map(({ _count, masterKeyVersion, ...rest }) => ({
...rest,
privatGespeichert: masterKeyVersion !== null,
verwendungen: _count.publicVerwendungen + _count.privateVerwendungen,
}));
}
export type SchluesselListenEintrag = Awaited<ReturnType<typeof schluesselListe>>[number];
export async function schluesselDetail(id: string) {
const schluessel = await prisma.schluessel.findUnique({ where: { id }, select: SCHLUESSEL_DETAIL_SELECT });
if (!schluessel) return null;
const { masterKeyVersion, ...rest } = schluessel;
return { ...rest, privatGespeichert: masterKeyVersion !== null };
}
export type SchluesselDetail = NonNullable<Awaited<ReturnType<typeof schluesselDetail>>>;
/** Nur Slug und Public Key – fuer den Download `<slug>.pub`. */
export async function publicKeyExport(id: string) {
return prisma.schluessel.findUnique({ where: { id }, select: { slug: true, publicKey: true } });
}
// ---------------------------------------------------------------- Auswahllisten
export async function personenAuswahl() {
return prisma.person.findMany({
where: { archiviertAm: null },
orderBy: { name: "asc" },
select: { id: true, name: true, organisation: true },
});
}
export async function vorlagenAuswahl() {
return prisma.vorlage.findMany({
where: { archiviertAm: null },
orderBy: { name: "asc" },
select: { id: true, name: true, standardLaufzeitTage: true },
});
}
+2
View File
@@ -73,6 +73,8 @@ export async function oidcBenutzerUebernehmen(anspruecke: OidcAnspruecke): Promi
const daten = {
oidcSub: anspruecke.sub,
email: anspruecke.email,
// Fuer die Verknuepfung im Self-Service (#013).
emailBestaetigt: anspruecke.emailBestaetigt,
name: anspruecke.name,
rolle,
letzteAnmeldungAm: new Date(),
+15
View File
@@ -0,0 +1,15 @@
import { HostFehler } from "@/server/services/hosts";
/**
* Fachlicher Fehler mit Meldung fuer die Oberflaeche und optional je Feld (#012, #013).
*
* Erbt von `HostFehler`, weil `fehlerZustand()` in src/server/actions/formular.ts genau den
* als „Meldung fuers Formular“ erkennt. So bleibt die eine Stelle, die Fehler in
* Formularzustaende uebersetzt, unveraendert.
*/
export class FachFehler extends HostFehler {
constructor(nachricht: string, feldfehler: Record<string, string> = {}) {
super(nachricht, feldfehler);
this.name = "FachFehler";
}
}
+195
View File
@@ -0,0 +1,195 @@
import type { Benutzer } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { feldfehlerAus } from "@/lib/hosts";
import { eigenerKeySchema, type EigenerKeyEingabe } from "@/lib/personen";
import { FachFehler } from "@/server/services/fachfehler";
import { istEindeutigkeitsFehler } from "@/server/services/hosts";
import { protokollieren } from "@/server/services/protokoll";
import { publicKeyAnlegen } from "@/server/services/schluessel";
/**
* Self-Service „Meine Schluessel“ (#013, Freigabe Frage 4): jeder angemeldete Benutzer
* hinterlegt eigene Public Keys. Wirksam werden sie erst, wenn ein Operator oder Admin sie
* einem Host zuweist – hier wird nichts ausgerollt.
*
* Jede Funktion nimmt den angemeldeten Benutzer und arbeitet nur mit **seiner** Person; eine
* Schluessel-ID von aussen wird immer gegen sie geprueft.
*/
type Konto = Pick<Benutzer, "id" | "name" | "email" | "emailBestaetigt">;
/**
* Liefert die Person des Kontos und legt sie beim ersten Aufruf an. Verknuepft per
* `benutzerId`, sonst per E-Mail – aber nur, wenn der Anbieter die Adresse bestaetigt hat und
* genau eine freie, nicht archivierte Person dazu passt.
*/
export async function eigenePersonSicherstellen(konto: Konto): Promise<{ id: string; name: string }> {
const vorhanden = await prisma.person.findUnique({
where: { benutzerId: konto.id },
select: { id: true, name: true },
});
if (vorhanden) return vorhanden;
const akteur = { id: konto.id, name: konto.name };
try {
if (konto.email && konto.emailBestaetigt) {
const passend = await prisma.person.findMany({
where: {
email: { equals: konto.email, mode: "insensitive" },
benutzerId: null,
archiviertAm: null,
},
select: { id: true },
take: 2,
});
// Bedingt: hat inzwischen jemand anderes die Person verknuepft, aendert sich nichts,
// und das Konto bekommt unten eine eigene Person.
const verknuepft =
passend.length === 1
? await prisma.person.updateMany({
where: { id: passend[0]!.id, benutzerId: null },
data: { benutzerId: konto.id },
})
: { count: 0 };
if (verknuepft.count === 1) {
const person = await prisma.person.findUniqueOrThrow({
where: { benutzerId: konto.id },
select: { id: true, name: true },
});
await protokollieren({
akteur,
aktion: "person.verknuepft",
ergebnis: "ERFOLG",
details: { person: person.name, ueber: "email" },
});
return person;
}
}
const person = await prisma.person.create({
data: { name: konto.name, email: konto.email, benutzerId: konto.id },
select: { id: true, name: true },
});
await protokollieren({
akteur,
aktion: "person.angelegt",
ergebnis: "ERFOLG",
details: { name: konto.name, ueber: "selbstauskunft" },
});
return person;
} catch (fehler) {
// Zwei gleichzeitige erste Aufrufe: der zweite findet die Person des ersten.
if (istEindeutigkeitsFehler(fehler)) {
return prisma.person.findUniqueOrThrow({
where: { benutzerId: konto.id },
select: { id: true, name: true },
});
}
throw fehler;
}
}
/** Die eigenen Schluessel – mit genau den Feldern, die die Seite zeigt. */
export async function eigeneSchluessel(konto: Konto) {
const person = await eigenePersonSicherstellen(konto);
const schluessel = await prisma.schluessel.findMany({
where: { personId: person.id },
orderBy: { angelegtAm: "desc" },
select: {
id: true,
name: true,
typ: true,
status: true,
publicKey: true,
fingerprint: true,
kommentar: true,
selbstHinterlegt: true,
angelegtAm: true,
_count: {
select: {
publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
},
},
},
});
return {
person,
schluessel: schluessel.map(({ _count, ...rest }) => ({
...rest,
verwendungen: _count.publicVerwendungen + _count.privateVerwendungen,
})),
};
}
export async function eigenenPublicKeyHinterlegen(konto: Konto, eingabe: EigenerKeyEingabe) {
const ergebnis = eigenerKeySchema.safeParse(eingabe);
if (!ergebnis.success) {
throw new FachFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error));
}
const person = await eigenePersonSicherstellen(konto);
return publicKeyAnlegen(
{
name: ergebnis.data.name,
slug: null,
kategorie: "PERSON",
publicKey: ergebnis.data.publicKey,
kommentar: null,
tags: [],
ablaufAm: null,
personId: person.id,
vorlageId: null,
selbstHinterlegt: true,
},
{ id: konto.id, name: konto.name },
"schluessel.selbst_hinterlegt",
);
}
/**
* Entfernt einen eigenen, selbst hinterlegten Key, solange er nirgends zugewiesen ist – auch
* keine entfernte Verwendung, damit der Verlauf am Key nicht verloren geht. Fremde Keys sind
* fuer diese Funktion „nicht vorhanden“, damit sie nichts ueber fremde IDs verraet.
*/
export async function eigenenSchluesselEntfernen(konto: Konto, schluesselId: string): Promise<void> {
const person = await eigenePersonSicherstellen(konto);
const schluessel = await prisma.schluessel.findFirst({
where: { id: schluesselId, personId: person.id },
select: {
id: true,
name: true,
fingerprint: true,
selbstHinterlegt: true,
_count: { select: { publicVerwendungen: true, privateVerwendungen: true } },
},
});
if (!schluessel) throw new FachFehler("Diesen Schlüssel gibt es unter Ihren Schlüsseln nicht.");
if (!schluessel.selbstHinterlegt) {
throw new FachFehler(
"Diesen Schlüssel hat ein Operator oder Admin angelegt – bitte dort entfernen lassen.",
);
}
if (schluessel._count.publicVerwendungen + schluessel._count.privateVerwendungen > 0) {
throw new FachFehler(
"Der Schlüssel ist schon einem Host zugewiesen. Bitte einen Operator oder Admin bitten.",
);
}
await prisma.$transaction(async (tx) => {
// Bedingt geloescht: weist ein Operator den Key gerade zu, darf das Loeschen die neue
// Verwendung nicht per Cascade mitnehmen.
const geloescht = await tx.schluessel.deleteMany({
where: { id: schluessel.id, publicVerwendungen: { none: {} }, privateVerwendungen: { none: {} } },
});
if (geloescht.count !== 1) {
throw new FachFehler("Der Schlüssel wurde gerade zugewiesen und bleibt deshalb bestehen.");
}
await protokollieren(
{
akteur: { id: konto.id, name: konto.name },
aktion: "schluessel.selbst_entfernt",
ergebnis: "ERFOLG",
fingerprint: schluessel.fingerprint,
details: { name: schluessel.name },
},
tx,
);
});
}
+139
View File
@@ -0,0 +1,139 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { feldfehlerAus } from "@/lib/hosts";
import { personEingabeSchema, type PersonDaten, type PersonEingabe } from "@/lib/personen";
import { FachFehler } from "@/server/services/fachfehler";
import { istEindeutigkeitsFehler, type Akteur } from "@/server/services/hosts";
import { protokollieren } from "@/server/services/protokoll";
/**
* Personen anlegen, bearbeiten, archivieren (#013).
*
* Archivieren statt Loeschen: an einer Person haengen Schluessel und Protokoll-Bezuege. Die
* Schluessel bleiben der archivierten Person zugeordnet – wer bis wann welchen Key hatte, soll
* nachvollziehbar bleiben. Widerrufen ist ein eigener Schritt (#024).
*/
function pruefen(eingabe: PersonEingabe): PersonDaten {
const ergebnis = personEingabeSchema.safeParse(eingabe);
if (!ergebnis.success) {
throw new FachFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error));
}
return ergebnis.data;
}
function benutzerVergeben(): FachFehler {
return new FachFehler("Dieses Konto ist schon mit einer anderen Person verknüpft.", {
benutzerId: "Schon mit einer anderen Person verknüpft.",
});
}
/** Das gewaehlte Konto muss existieren und frei sein (oder schon zu dieser Person gehoeren). */
async function benutzerPruefen(
tx: Prisma.TransactionClient,
benutzerId: string | null,
personId?: string,
): Promise<string | null> {
if (!benutzerId) return null;
const benutzer = await tx.benutzer.findUnique({
where: { id: benutzerId },
select: { name: true, person: { select: { id: true } } },
});
if (!benutzer)
throw new FachFehler("Das gewählte Konto gibt es nicht.", { benutzerId: "Bitte neu wählen." });
if (benutzer.person && benutzer.person.id !== personId) throw benutzerVergeben();
return benutzer.name;
}
export async function personAnlegen(eingabe: PersonEingabe, akteur: Akteur) {
const daten = pruefen(eingabe);
try {
return await prisma.$transaction(async (tx) => {
const konto = await benutzerPruefen(tx, daten.benutzerId);
const person = await tx.person.create({ data: daten, select: { id: true, name: true } });
await protokollieren(
{
akteur,
aktion: "person.angelegt",
ergebnis: "ERFOLG",
details: { name: daten.name, email: daten.email, organisation: daten.organisation, konto },
},
tx,
);
return person;
});
} catch (fehler) {
if (istEindeutigkeitsFehler(fehler)) throw benutzerVergeben();
throw fehler;
}
}
export async function personBearbeiten(id: string, eingabe: PersonEingabe, akteur: Akteur) {
const daten = pruefen(eingabe);
const alt = await prisma.person.findUnique({
where: { id },
select: {
name: true,
email: true,
organisation: true,
notiz: true,
benutzerId: true,
archiviertAm: true,
},
});
if (!alt) throw new FachFehler("Diese Person gibt es nicht.");
if (alt.archiviertAm) throw new FachFehler("Archivierte Personen lassen sich nicht bearbeiten.");
const geaendert = Object.fromEntries(
(Object.keys(daten) as (keyof PersonDaten)[])
.filter((feld) => daten[feld] !== alt[feld])
// Die Notiz kann Persoenliches enthalten – im Protokoll steht nur, dass sie sich aenderte.
.map((feld) => [feld, feld === "notiz" ? "geändert" : { vorher: alt[feld], nachher: daten[feld] }]),
);
try {
return await prisma.$transaction(async (tx) => {
await benutzerPruefen(tx, daten.benutzerId, id);
const person = await tx.person.update({ where: { id }, data: daten, select: { id: true, name: true } });
if (Object.keys(geaendert).length > 0) {
await protokollieren(
{ akteur, aktion: "person.geaendert", ergebnis: "ERFOLG", details: geaendert },
tx,
);
}
return person;
});
} catch (fehler) {
if (istEindeutigkeitsFehler(fehler)) throw benutzerVergeben();
throw fehler;
}
}
/**
* Archiviert eine Person. Die Verknuepfung zum Konto faellt weg, damit der Self-Service beim
* naechsten Aufruf nicht wieder bei einer archivierten Person landet.
*/
export async function personArchivieren(id: string, akteur: Akteur) {
const person = await prisma.person.findUnique({
where: { id },
select: { name: true, archiviertAm: true, _count: { select: { schluessel: true } } },
});
if (!person) throw new FachFehler("Diese Person gibt es nicht.");
if (person.archiviertAm) throw new FachFehler("Diese Person ist bereits archiviert.");
return prisma.$transaction(async (tx) => {
const archiviert = await tx.person.update({
where: { id },
data: { archiviertAm: new Date(), benutzerId: null },
select: { id: true },
});
await protokollieren(
{
akteur,
aktion: "person.archiviert",
ergebnis: "ERFOLG",
details: { name: person.name, schluessel: person._count.schluessel },
},
tx,
);
return archiviert;
});
}
+123
View File
@@ -0,0 +1,123 @@
import { prisma } from "@/lib/db";
import { entschluesseln } from "@/lib/geheimnisse";
import { ABHOLFRIST_MS } from "@/lib/schluessel";
import { FachFehler } from "@/server/services/fachfehler";
import type { Akteur } from "@/server/services/hosts";
import { protokollieren } from "@/server/services/protokoll";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
/**
* Private Key anzeigen, herunterladen, loeschen (#014). Die Rechte (`privateKeyAnzeigen`,
* `privateKeyLoeschen`, beide nur Admin mit frischer Bestaetigung) prueft die Action bzw. der
* Route Handler; hier stehen die fachlichen Regeln.
*
* Der Web-Prozess hat keinen Master-Key. Er bittet den Worker per Vorgang, den Key zu oeffnen;
* der legt ihn mit SSHKV_APP_SECRET verschluesselt in `Vorgang.ergebnis` ab, und
* `privateKeyAbholen` holt ihn genau einmal ab. Der Key steht nie im Protokoll, in einem Log
* oder einer Fehlermeldung – nur Fingerprint und Name.
*/
export type Zweck = "anzeigen" | "herunterladen";
/** Felder fuer die Pruefung – ob ein Key da ist, verraet `masterKeyVersion`, nie das Chiffrat. */
async function schluesselMitPrivateKey(schluesselId: string) {
const schluessel = await prisma.schluessel.findUnique({
where: { id: schluesselId },
select: { id: true, name: true, slug: true, kategorie: true, fingerprint: true, masterKeyVersion: true },
});
if (!schluessel) throw new FachFehler("Diesen Schlüssel gibt es nicht.");
if (schluessel.masterKeyVersion === null) {
throw new FachFehler("Zu diesem Schlüssel ist kein Private Key gespeichert.");
}
// Der Deployment-Key ist root auf allen Hosts; er verlaesst sshkv nie. Im Notfall wird er
// rotiert, nicht exportiert.
if (schluessel.kategorie === "DEPLOYMENT") {
throw new FachFehler(
"Den Private Key eines Deployment-Keys gibt sshkv nicht heraus und löscht ihn nicht.",
);
}
return schluessel;
}
/** Bittet den Worker, den Private Key zur einmaligen Abholung bereitzulegen. */
export async function abholungAnfordern(schluesselId: string, zweck: Zweck, akteur: Akteur): Promise<string> {
const schluessel = await schluesselMitPrivateKey(schluesselId);
const vorgangId = await vorgangEinreihen({
art: "PRIVATE_KEY_ABHOLEN",
angefordertVonId: akteur.id,
schluesselId: schluessel.id,
eingabe: { zweck },
});
await protokollieren({
akteur,
aktion: "privatekey.angefordert",
ergebnis: "ERFOLG",
fingerprint: schluessel.fingerprint,
details: { name: schluessel.name, zweck },
});
return vorgangId;
}
type Abholung = { ergebnis: unknown; beendetAm: Date | null; schluesselId: string | null };
/**
* Holt den bereitgelegten Private Key genau einmal ab: Lesen und Ueberschreiben geschehen in
* einer Anweisung unter Zeilensperre, ein zweiter Aufruf findet nichts mehr. Nur wer den
* Vorgang angefordert hat, kann abholen – und nur innerhalb der Abholfrist.
*/
export async function privateKeyAbholen(
vorgangId: string,
zweck: Zweck,
akteur: Akteur,
jetzt: Date = new Date(),
): Promise<{ privateKey: string; slug: string; name: string; fingerprint: string }> {
const [zeile] = await prisma.$queryRaw<Abholung[]>`
WITH alt AS (
SELECT "id", "ergebnis", "beendetAm", "schluesselId" FROM "Vorgang"
WHERE "id" = ${vorgangId}
AND "art" = 'PRIVATE_KEY_ABHOLEN'
AND "status" = 'ERLEDIGT'
AND "angefordertVonId" = ${akteur.id}
AND "ergebnis" ? 'abholung'
FOR UPDATE
)
UPDATE "Vorgang" v SET "ergebnis" = '{"abgeholt": true}'::jsonb
FROM alt WHERE v."id" = alt."id"
RETURNING alt."ergebnis", alt."beendetAm", alt."schluesselId"`;
if (!zeile || !zeile.schluesselId) {
throw new FachFehler("Nichts abzuholen – der Private Key wurde schon abgeholt oder ist verfallen.");
}
if (!zeile.beendetAm || jetzt.getTime() - zeile.beendetAm.getTime() > ABHOLFRIST_MS) {
throw new FachFehler("Die Abholung ist verfallen. Bitte den Private Key erneut anfordern.");
}
const chiffrat = (zeile.ergebnis as { abholung?: unknown }).abholung;
if (typeof chiffrat !== "string") throw new FachFehler("Die Abholung ist unvollständig.");
const schluessel = await prisma.schluessel.findUniqueOrThrow({
where: { id: zeile.schluesselId },
select: { name: true, slug: true, fingerprint: true },
});
const privateKey = entschluesseln(chiffrat);
await protokollieren({
akteur,
aktion: zweck === "anzeigen" ? "privatekey.angezeigt" : "privatekey.heruntergeladen",
ergebnis: "ERFOLG",
fingerprint: schluessel.fingerprint,
details: { name: schluessel.name },
});
return { privateKey, ...schluessel };
}
/**
* Fordert das endgueltige Loeschen des gespeicherten Private Keys an (#014, „Private Key aus
* SSHKeyVault loeschen“). Ausgefuehrt im Worker – nur src/worker/geheimnisse.ts fasst die
* Chiffrat-Felder an, auch zum Leeren.
*/
export async function privateKeyLoeschenAnfordern(schluesselId: string, akteur: Akteur): Promise<string> {
const schluessel = await schluesselMitPrivateKey(schluesselId);
return vorgangEinreihen({
art: "PRIVATE_KEY_LOESCHEN",
angefordertVonId: akteur.id,
schluesselId: schluessel.id,
});
}
+396
View File
@@ -0,0 +1,396 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { verschluesseln } from "@/lib/geheimnisse";
import { feldfehlerAus } from "@/lib/hosts";
import {
bearbeitenSchema,
erzeugenSchema,
freierSlug,
PASSPHRASE_MAX,
paarImportSchema,
PRIVATE_KEY_MAX,
publicImportSchema,
publicKeyZeileMitKommentar,
schluesselTypVon,
slugAus,
type BearbeitenEingabe,
type ErzeugenEingabe,
type PaarImportEingabe,
type PublicImportEingabe,
type SchluesselGeheimnis,
type SchluesselVorgangEingabe,
} from "@/lib/schluessel";
import { publicKeyZeileLesen, SchluesselFormatFehler } from "@/lib/ssh/openssh";
import type { z } from "@/lib/validierung";
import { FachFehler } from "@/server/services/fachfehler";
import { istEindeutigkeitsFehler, type Akteur } from "@/server/services/hosts";
import { protokollieren } from "@/server/services/protokoll";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
/**
* Schluessel anlegen, importieren, bearbeiten (#012).
*
* Alles, was einen Private Key beruehrt (Erzeugen, Import eines Paars), wird hier nur
* geprueft und als Vorgang an den Worker gegeben – der Private Key reist mit
* SSHKV_APP_SECRET verschluesselt im Job, nie in `Vorgang.eingabe`. Ein reiner Public Key
* ist kein Geheimnis und wird direkt hier angelegt.
*
* Jede Prisma-Abfrage auf `Schluessel` hat ein festes `select` ohne Chiffrat-Felder
* (tests/schluessel-grenze.test.ts).
*/
/** Was die Oberflaeche nach dem Anlegen braucht – und nicht mehr. */
const ANGELEGT = { id: true, slug: true, fingerprint: true } satisfies Prisma.SchluesselSelect;
function pruefen<S extends z.ZodType>(schema: S, eingabe: unknown): z.output<S> {
const ergebnis = schema.safeParse(eingabe);
if (!ergebnis.success) {
throw new FachFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error));
}
return ergebnis.data;
}
async function slugFrei(slug: string, client: Prisma.TransactionClient = prisma): Promise<boolean> {
return (await client.schluessel.findUnique({ where: { slug }, select: { id: true } })) === null;
}
/**
* Ein ausdruecklich gewuenschter Slug muss frei sein; ein aus dem Namen abgeleiteter bekommt
* bei Bedarf eine Nummer angehaengt.
*/
export async function eindeutigerSlug(wunsch: string | null, name: string): Promise<string> {
if (wunsch) {
if (!(await slugFrei(wunsch))) {
throw new FachFehler(`Der Kurzname „${wunsch}“ ist schon vergeben.`, {
slug: "Dieser Kurzname ist schon vergeben.",
});
}
return wunsch;
}
return freierSlug(slugAus(name), (kandidat) => slugFrei(kandidat));
}
/** Gibt es diesen Fingerprint schon? Fuer eine Meldung mit Verweis auf den vorhandenen Key. */
export async function schluesselMitFingerprint(fingerprint: string) {
return prisma.schluessel.findUnique({
where: { fingerprint },
select: { id: true, name: true, slug: true },
});
}
export function bereitsVerwaltet(vorhanden: { id: string; name: string }): FachFehler {
return new FachFehler(
`Dieser Schlüssel wird bereits verwaltet: „${vorhanden.name}“ (/schluessel/${vorhanden.id}).`,
{ publicKey: "Dieser Schlüssel ist schon in sshkv." },
);
}
/** Heute 00:00 UTC – Vergleichsbasis fuer „Ablaufdatum liegt nicht in der Vergangenheit“. */
function heute(jetzt = new Date()): Date {
return new Date(Date.UTC(jetzt.getUTCFullYear(), jetzt.getUTCMonth(), jetzt.getUTCDate()));
}
type Bezuege = { readonly personId: string | null; readonly vorlageId: string | null };
/**
* Prueft Person und Vorlage und setzt ein fehlendes Ablaufdatum aus der Standardlaufzeit der
* Vorlage. Exportiert, weil der Worker dieselbe Pruefung beim Ausfuehren wiederholt.
*/
export async function bezuegePruefen(
daten: Bezuege & { readonly ablaufAm: Date | null },
client: Prisma.TransactionClient = prisma,
): Promise<{ ablaufAm: Date | null }> {
if (daten.personId) {
const person = await client.person.findUnique({
where: { id: daten.personId },
select: { archiviertAm: true },
});
if (!person || person.archiviertAm) {
throw new FachFehler("Die gewählte Person gibt es nicht (mehr).", { personId: "Bitte neu wählen." });
}
}
let ablaufAm = daten.ablaufAm;
if (daten.vorlageId) {
const vorlage = await client.vorlage.findUnique({
where: { id: daten.vorlageId },
select: { archiviertAm: true, standardLaufzeitTage: true },
});
if (!vorlage || vorlage.archiviertAm) {
throw new FachFehler("Die gewählte Vorlage gibt es nicht (mehr).", { vorlageId: "Bitte neu wählen." });
}
if (!ablaufAm && vorlage.standardLaufzeitTage) {
ablaufAm = new Date(heute().getTime() + vorlage.standardLaufzeitTage * 24 * 60 * 60 * 1000);
}
}
return { ablaufAm };
}
function ablaufNichtVergangen(ablaufAm: Date | null): void {
if (ablaufAm && ablaufAm < heute()) {
throw new FachFehler("Das Ablaufdatum liegt in der Vergangenheit.", {
ablaufAm: "Bitte ein Datum ab heute wählen.",
});
}
}
function passphrasePruefen(passphrase: string | undefined): string | undefined {
if (!passphrase) return undefined;
if (passphrase.length > PASSPHRASE_MAX) {
throw new FachFehler("Die Passphrase ist zu lang.", {
passphrase: `Höchstens ${PASSPHRASE_MAX} Zeichen.`,
});
}
return passphrase;
}
// ---------------------------------------------------------------- Erzeugen und Paar-Import
/**
* Fordert ein neues Schluesselpaar an (#012). Liefert die Vorgangs-ID; die Oberflaeche
* verfolgt den Vorgang und springt nach Erfolg zum Schluessel.
*/
export async function erzeugenAnfordern(
eingabe: ErzeugenEingabe,
passphraseRoh: string | undefined,
akteur: Akteur,
): Promise<string> {
const daten = pruefen(erzeugenSchema, eingabe);
const passphrase = passphrasePruefen(passphraseRoh);
ablaufNichtVergangen(daten.ablaufAm);
const { ablaufAm } = await bezuegePruefen(daten);
const slug = await eindeutigerSlug(daten.slug, daten.name);
const vorgangsEingabe: SchluesselVorgangEingabe = {
name: daten.name,
slug,
tags: daten.tags,
ablaufAm: ablaufAm?.toISOString() ?? null,
personId: daten.personId,
vorlageId: daten.vorlageId,
kommentar: daten.kommentar,
typ: daten.typ,
mitPassphrase: Boolean(passphrase),
};
const geheimnis: SchluesselGeheimnis = passphrase ? { passphrase } : {};
return vorgangEinreihen({
art: "SCHLUESSEL_ERZEUGEN",
angefordertVonId: akteur.id,
eingabe: vorgangsEingabe,
...(passphrase ? { geheim: verschluesseln(JSON.stringify(geheimnis)) } : {}),
});
}
/**
* Fordert den Import eines Paars an (#012). Der Private Key wird hier nur grob angesehen
* (Groesse, Kopfzeile) – lesen, pruefen und versiegeln tut der Worker. Eine Passphrase dient
* dort nur der Pruefung und wird verworfen; gespeichert wird der Key, wie er kam.
*/
export async function paarImportAnfordern(
eingabe: PaarImportEingabe,
privateKey: string | undefined,
passphraseRoh: string | undefined,
akteur: Akteur,
): Promise<string> {
const daten = pruefen(paarImportSchema, eingabe);
// Wie ssh-keygen ihn schreibt: LF-Zeilenenden und genau ein Zeilenumbruch am Ende.
const text = (privateKey ?? "").replace(/\r\n?/g, "\n").trim();
if (!text) {
throw new FachFehler("Bitte den Private Key einfügen.", {
privateKey: "Bitte den Private Key einfügen.",
});
}
if (text.length > PRIVATE_KEY_MAX || !text.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----")) {
// Feste Meldung – nie etwas aus dem eingefuegten Text zurueckgeben.
throw new FachFehler("Das ist kein Private Key im OpenSSH-Format.", {
privateKey: "Erwartet wird „-----BEGIN OPENSSH PRIVATE KEY-----“ …",
});
}
const passphrase = passphrasePruefen(passphraseRoh);
ablaufNichtVergangen(daten.ablaufAm);
const { ablaufAm } = await bezuegePruefen(daten);
const slug = await eindeutigerSlug(daten.slug, daten.name);
const vorgangsEingabe: SchluesselVorgangEingabe = {
name: daten.name,
slug,
tags: daten.tags,
ablaufAm: ablaufAm?.toISOString() ?? null,
personId: daten.personId,
vorlageId: daten.vorlageId,
kommentar: daten.kommentar,
mitPassphrase: Boolean(passphrase),
};
const geheimnis: SchluesselGeheimnis = { privateKey: `${text}\n`, ...(passphrase ? { passphrase } : {}) };
return vorgangEinreihen({
art: "SCHLUESSEL_IMPORTIEREN",
angefordertVonId: akteur.id,
eingabe: vorgangsEingabe,
geheim: verschluesseln(JSON.stringify(geheimnis)),
});
}
// ---------------------------------------------------------------- Public-Key-Import
/** Liest eine Public-Key-Zeile und uebersetzt Formatfehler in eine Feldmeldung. */
export function publicKeyPruefen(zeile: string) {
try {
// Die RSA-Mindestlaenge gilt beim Import (Standard von publicKeyZeileLesen).
return publicKeyZeileLesen(zeile);
} catch (fehler) {
if (fehler instanceof SchluesselFormatFehler) {
throw new FachFehler(fehler.message, { publicKey: fehler.message });
}
throw fehler;
}
}
/**
* Legt einen Schluessel nur mit Public Key an (#012). Auch Grundlage des Self-Service
* (#013), der Kategorie, Person und `selbstHinterlegt` selbst setzt.
*/
export async function publicKeyAnlegen(
daten: {
readonly name: string;
readonly slug: string | null;
readonly kategorie: "PERSON" | "DIENST";
readonly publicKey: string;
readonly kommentar: string | null;
readonly tags: string[];
readonly ablaufAm: Date | null;
readonly personId: string | null;
readonly vorlageId: string | null;
readonly selbstHinterlegt?: boolean;
},
akteur: Akteur,
aktion = "schluessel.importiert",
) {
const info = publicKeyPruefen(daten.publicKey);
const vorhanden = await schluesselMitFingerprint(info.fingerprint);
if (vorhanden) throw bereitsVerwaltet(vorhanden);
ablaufNichtVergangen(daten.ablaufAm);
const { ablaufAm } = await bezuegePruefen(daten);
const slug = await eindeutigerSlug(daten.slug, daten.name);
const kommentar = daten.kommentar ?? (info.kommentar || `sshkv:${slug}`);
try {
return await prisma.$transaction(async (tx) => {
const schluessel = await tx.schluessel.create({
data: {
name: daten.name,
slug,
typ: schluesselTypVon(info.typ, info.bits),
kategorie: daten.kategorie,
publicKey: publicKeyZeileMitKommentar(daten.publicKey, kommentar),
fingerprint: info.fingerprint,
kommentar,
tags: daten.tags,
ablaufAm,
personId: daten.personId,
vorlageId: daten.vorlageId,
erstelltVonId: akteur.id,
selbstHinterlegt: daten.selbstHinterlegt ?? false,
},
select: ANGELEGT,
});
await protokollieren(
{
akteur,
aktion,
ergebnis: "ERFOLG",
fingerprint: schluessel.fingerprint,
details: {
name: daten.name,
slug,
kategorie: daten.kategorie,
art: "public",
algorithmus: info.typ,
},
},
tx,
);
return schluessel;
});
} catch (fehler) {
// Zwischen Pruefung und Insert kann derselbe Key oder Slug angelegt worden sein.
if (istEindeutigkeitsFehler(fehler)) {
const jetzt = await schluesselMitFingerprint(info.fingerprint);
if (jetzt) throw bereitsVerwaltet(jetzt);
throw new FachFehler("Der Kurzname wurde gerade vergeben. Bitte erneut speichern.");
}
throw fehler;
}
}
export async function publicKeyImportieren(eingabe: PublicImportEingabe, akteur: Akteur) {
const daten = pruefen(publicImportSchema, eingabe);
return publicKeyAnlegen(daten, akteur);
}
// ---------------------------------------------------------------- Bearbeiten
export async function schluesselBearbeiten(id: string, eingabe: BearbeitenEingabe, akteur: Akteur) {
const daten = pruefen(bearbeitenSchema, eingabe);
const alt = await prisma.schluessel.findUnique({
where: { id },
select: {
name: true,
kategorie: true,
status: true,
tags: true,
ablaufAm: true,
personId: true,
vorlageId: true,
fingerprint: true,
},
});
if (!alt) throw new FachFehler("Diesen Schlüssel gibt es nicht.");
if (daten.status && daten.status !== alt.status) {
if (alt.status !== "AKTIV" && alt.status !== "ABGELAUFEN") {
throw new FachFehler("Rotierte oder widerrufene Schlüssel lassen sich nicht wieder aktivieren.", {
status: "Status nicht änderbar.",
});
}
// Der Deployment-Key der Instanz wird nur ueber seine eigene Rotation ersetzt; ein
// „abgelaufener“ Deployment-Key liesse den Worker beim naechsten Start einen neuen erzeugen.
if (alt.kategorie === "DEPLOYMENT") {
throw new FachFehler("Der Status eines Deployment-Keys lässt sich hier nicht ändern.", {
status: "Status nicht änderbar.",
});
}
}
if (daten.ablaufAm?.getTime() !== alt.ablaufAm?.getTime()) ablaufNichtVergangen(daten.ablaufAm);
if (daten.personId !== alt.personId || daten.vorlageId !== alt.vorlageId) {
await bezuegePruefen({ personId: daten.personId, vorlageId: daten.vorlageId, ablaufAm: daten.ablaufAm });
}
const nachher = {
name: daten.name,
tags: daten.tags,
ablaufAm: daten.ablaufAm,
personId: daten.personId,
vorlageId: daten.vorlageId,
status: daten.status ?? alt.status,
};
const geaendert = Object.fromEntries(
Object.entries(nachher)
.map(([feld, wert]) => [feld, { vorher: alt[feld as keyof typeof nachher], nachher: wert }] as const)
.filter(([, a]) => JSON.stringify(a.vorher) !== JSON.stringify(a.nachher)),
);
return prisma.$transaction(async (tx) => {
const schluessel = await tx.schluessel.update({ where: { id }, data: nachher, select: ANGELEGT });
if (Object.keys(geaendert).length > 0) {
await protokollieren(
{
akteur,
aktion: "schluessel.geaendert",
ergebnis: "ERFOLG",
fingerprint: alt.fingerprint,
details: geaendert,
},
tx,
);
}
return schluessel;
});
}
+13 -1
View File
@@ -35,8 +35,20 @@ export async function vorgangEinreihen(daten: VorgangAnlegen): Promise<string> {
return vorgang.id;
}
/** Was die Oberflaeche ueber einen Vorgang erfahren darf – ohne Job-Interna. */
/**
* Was die Oberflaeche ueber einen Vorgang erfahren darf – ohne Job-Interna. Eine bereitliegende
* Private-Key-Abholung (#014) wird nie ausgeliefert, auch nicht verschluesselt: abgeholt wird
* nur ueber src/server/services/privatekey.ts.
*/
export async function vorgangLesen(id: string) {
const vorgang = await vorgangRoh(id);
if (vorgang?.art !== "PRIVATE_KEY_ABHOLEN") return vorgang;
const ergebnis = vorgang.ergebnis;
const bereit = typeof ergebnis === "object" && ergebnis !== null && "abholung" in ergebnis;
return { ...vorgang, ergebnis: bereit ? { bereit: true } : ergebnis };
}
async function vorgangRoh(id: string) {
return prisma.vorgang.findUnique({
where: { id },
select: {
+84
View File
@@ -0,0 +1,84 @@
import { prisma } from "@/lib/db";
import { verschluesseln } from "@/lib/geheimnisse";
import { ABHOLFRIST_MS } from "@/lib/schluessel";
import { protokollieren, type ProtokollEintrag } from "@/server/services/protokoll";
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
import { privateKeyLaden, privateKeyVerwerfen } from "@/worker/geheimnisse";
/**
* Private Key abholen und loeschen (#014).
*
* Abholen: Der Worker oeffnet den Umschlag (nur er hat den Master-Key) und legt den Key mit
* SSHKV_APP_SECRET verschluesselt in `Vorgang.ergebnis` ab. Das Web holt ihn genau einmal ab
* und ueberschreibt das Ergebnis dabei; was nach ABHOLFRIST noch liegt, raeumt
* `abholungenAufraeumen()` weg – der Worker ruft es vor jeder Abholung und regelmaessig auf.
*/
/**
* Ueberschreibt nicht abgeholte Private Keys, deren Vorgang laenger als ABHOLFRIST fertig ist.
* Liefert die Anzahl. Ein einziges UPDATE – kein Zeitfenster zwischen Lesen und Schreiben.
*/
export async function abholungenAufraeumen(jetzt: Date = new Date()): Promise<number> {
const grenze = new Date(jetzt.getTime() - ABHOLFRIST_MS);
return prisma.$executeRaw`
UPDATE "Vorgang" SET "ergebnis" = '{"verfallen": true}'::jsonb
WHERE "art" = 'PRIVATE_KEY_ABHOLEN'
AND "ergebnis" ? 'abholung'
AND "beendetAm" < ${grenze}`;
}
async function anforderer(angefordertVonId: string | null): Promise<ProtokollEintrag["akteur"]> {
if (!angefordertVonId) return "System";
const benutzer = await prisma.benutzer.findUnique({
where: { id: angefordertVonId },
select: { id: true, name: true },
});
return benutzer ?? "System";
}
/** Nur Felder, die verraten, ob ein Key da ist – das Chiffrat liest allein geheimnisse.ts. */
async function schluesselLaden(schluesselId: string | null) {
if (!schluesselId) throw new VorgangsFehler("Der Vorgang nennt keinen Schlüssel.");
const schluessel = await prisma.schluessel.findUnique({
where: { id: schluesselId },
select: { id: true, name: true, slug: true, kategorie: true, fingerprint: true, masterKeyVersion: true },
});
if (!schluessel) throw new VorgangsFehler("Den Schlüssel gibt es nicht mehr.");
if (schluessel.masterKeyVersion === null) {
throw new VorgangsFehler("Zu diesem Schlüssel ist kein Private Key gespeichert.");
}
return schluessel;
}
export const privateKeyAbholenHandler: Handler = async ({ vorgang }) => {
await abholungenAufraeumen();
const schluessel = await schluesselLaden(vorgang.schluesselId);
const klartext = await privateKeyLaden(schluessel.id);
try {
// JS-Strings lassen sich nicht ueberschreiben; der Puffer wenigstens wird sofort genullt.
return {
ergebnis: { abholung: verschluesseln(klartext.toString("utf8")), fingerprint: schluessel.fingerprint },
};
} finally {
klartext.fill(0);
}
};
export const privateKeyLoeschenHandler: Handler = async ({ vorgang }) => {
const schluessel = await schluesselLaden(vorgang.schluesselId);
// Ohne Deployment-Key kaeme der Worker auf keinen Host mehr (Aussperrschutz).
if (schluessel.kategorie === "DEPLOYMENT") {
throw new VorgangsFehler("Den Private Key eines Deployment-Keys löscht sshkv nicht.");
}
if (!(await privateKeyVerwerfen(schluessel.id))) {
throw new VorgangsFehler("Der Private Key war bereits gelöscht.");
}
await protokollieren({
akteur: await anforderer(vorgang.angefordertVonId),
aktion: "privatekey.geloescht",
ergebnis: "ERFOLG",
fingerprint: schluessel.fingerprint,
details: { name: schluessel.name, slug: schluessel.slug },
});
return { ergebnis: { geloescht: true } };
};
+204
View File
@@ -0,0 +1,204 @@
import type { Vorgang } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { entschluesseln } from "@/lib/geheimnisse";
import {
publicKeyZeileMitKommentar,
schluesselTypVon,
vorgangEingabeSchema,
type SchluesselGeheimnis,
} from "@/lib/schluessel";
import { privateKeyLesen, SchluesselFormatFehler } from "@/lib/ssh/openssh";
import { HostFehler, istEindeutigkeitsFehler } from "@/server/services/hosts";
import { protokollieren, type ProtokollEintrag } from "@/server/services/protokoll";
import { bezuegePruefen, schluesselMitFingerprint } from "@/server/services/schluessel";
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
import { importiertenKeyVersiegeln, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
/**
* Schluessel erzeugen und ein Paar importieren (#012) – im Worker, weil nur er den Master-Key
* hat. Passphrase und importierter Private Key kommen mit SSHKV_APP_SECRET verschluesselt im
* Job (`geheim`); der Worker loescht den Job nach dem Lauf (src/worker/index.ts).
*
* Fehlermeldungen sind feste Saetze: aus `SchluesselFormatFehler` (die nie Eingaben
* zitieren) oder von hier. Nichts aus dem Private Key landet in `Vorgang.fehler`.
*/
/** Wer den Vorgang angefordert hat – fuer Protokoll und `erstelltVonId`. */
async function anforderer(vorgang: Vorgang): Promise<ProtokollEintrag["akteur"]> {
if (!vorgang.angefordertVonId) return "System";
const benutzer = await prisma.benutzer.findUnique({
where: { id: vorgang.angefordertVonId },
select: { id: true, name: true },
});
return benutzer ?? "System";
}
function geheimnisLesen(geheim: string | undefined): SchluesselGeheimnis {
if (!geheim) return {};
try {
const wert = JSON.parse(entschluesseln(geheim)) as unknown;
if (!wert || typeof wert !== "object") throw new Error("kein Objekt");
const { privateKey, passphrase } = wert as Record<string, unknown>;
return {
...(typeof privateKey === "string" ? { privateKey } : {}),
...(typeof passphrase === "string" && passphrase ? { passphrase } : {}),
};
} catch {
throw new VorgangsFehler("Die verschlüsselten Angaben des Vorgangs sind unlesbar. Bitte erneut starten.");
}
}
/** Eingabe pruefen, Bezuege nachsehen – Fachfehler werden zu Meldungen des Vorgangs. */
async function eingabeAufloesen(vorgang: Vorgang) {
const ergebnis = vorgangEingabeSchema.safeParse(vorgang.eingabe);
if (!ergebnis.success) throw new VorgangsFehler("Die Angaben zum Schlüssel sind unvollständig.");
const eingabe = ergebnis.data;
try {
const { ablaufAm } = await bezuegePruefen({
personId: eingabe.personId,
vorlageId: eingabe.vorlageId,
ablaufAm: eingabe.ablaufAm ? new Date(eingabe.ablaufAm) : null,
});
return { ...eingabe, ablaufAm };
} catch (fehler) {
if (fehler instanceof HostFehler) throw new VorgangsFehler(fehler.message);
throw fehler;
}
}
function schonVerwaltet(vorhanden: { name: string }): VorgangsFehler {
return new VorgangsFehler(`Dieser Schlüssel wird bereits verwaltet: „${vorhanden.name}“.`);
}
/** Der Vorgang soll auf „seinen“ Schluessel zeigen – fuer Verlauf und Oberflaeche. */
async function vorgangVerknuepfen(vorgangId: string, schluesselId: string): Promise<void> {
await prisma.vorgang.update({ where: { id: vorgangId }, data: { schluesselId } });
}
export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) => {
const eingabe = await eingabeAufloesen(vorgang);
const { passphrase } = geheimnisLesen(geheim);
if (eingabe.mitPassphrase && !passphrase) {
throw new VorgangsFehler("Die Passphrase fehlt – bitte erneut erzeugen.");
}
const akteur = await anforderer(vorgang);
const typ = eingabe.typ ?? "ED25519";
const kommentar = eingabe.kommentar ?? `sshkv:${eingabe.slug}`;
const neu = await schluesselpaarVersiegeltErzeugen(typ, kommentar, passphrase);
try {
await prisma.schluessel.create({
data: {
id: neu.id,
name: eingabe.name,
slug: eingabe.slug,
typ,
kategorie: "DIENST",
publicKey: neu.publicKeyZeile,
fingerprint: neu.fingerprint,
kommentar,
tags: eingabe.tags,
ablaufAm: eingabe.ablaufAm,
personId: eingabe.personId,
vorlageId: eingabe.vorlageId,
erstelltVonId: akteur === "System" ? null : akteur.id,
passphraseGeschuetzt: Boolean(passphrase),
...neu.versiegelt,
},
select: { id: true },
});
} catch (fehler) {
if (istEindeutigkeitsFehler(fehler)) {
throw new VorgangsFehler(
`Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`,
);
}
throw fehler;
}
await vorgangVerknuepfen(vorgang.id, neu.id);
await protokollieren({
akteur,
aktion: "schluessel.erzeugt",
ergebnis: "ERFOLG",
fingerprint: neu.fingerprint,
details: {
name: eingabe.name,
slug: eingabe.slug,
typ,
kategorie: "DIENST",
passphraseGeschuetzt: Boolean(passphrase),
},
});
return { ergebnis: { schluesselId: neu.id, fingerprint: neu.fingerprint } };
};
export const schluesselImportierenHandler: Handler = async ({ vorgang, geheim }) => {
const eingabe = await eingabeAufloesen(vorgang);
const { privateKey, passphrase } = geheimnisLesen(geheim);
if (!privateKey) throw new VorgangsFehler("Der Private Key fehlt – bitte den Import erneut starten.");
const akteur = await anforderer(vorgang);
let info;
try {
// Die Passphrase dient nur dem Lesen von Public Key und Fingerprint und wird danach
// verworfen; gespeichert wird der Key so, wie er kam (Freigabe Frage 6).
info = privateKeyLesen(privateKey, passphrase);
} catch (fehler) {
if (fehler instanceof SchluesselFormatFehler) throw new VorgangsFehler(fehler.message);
throw fehler;
}
const vorhanden = await schluesselMitFingerprint(info.fingerprint);
if (vorhanden) throw schonVerwaltet(vorhanden);
const algorithmus = info.typ === "ED25519" ? "ssh-ed25519" : "ssh-rsa";
const typ = schluesselTypVon(algorithmus, info.bits);
const kommentar = eingabe.kommentar ?? (info.kommentar || `sshkv:${eingabe.slug}`);
const { id, versiegelt } = await importiertenKeyVersiegeln(info.fingerprint, privateKey);
try {
await prisma.schluessel.create({
data: {
id,
name: eingabe.name,
slug: eingabe.slug,
typ,
kategorie: "DIENST",
publicKey: publicKeyZeileMitKommentar(info.publicKeyZeile, kommentar),
fingerprint: info.fingerprint,
kommentar,
tags: eingabe.tags,
ablaufAm: eingabe.ablaufAm,
personId: eingabe.personId,
vorlageId: eingabe.vorlageId,
erstelltVonId: akteur === "System" ? null : akteur.id,
passphraseGeschuetzt: info.verschluesselt,
...versiegelt,
},
select: { id: true },
});
} catch (fehler) {
if (istEindeutigkeitsFehler(fehler)) {
const jetzt = await schluesselMitFingerprint(info.fingerprint);
if (jetzt) throw schonVerwaltet(jetzt);
throw new VorgangsFehler(
`Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut importieren.`,
);
}
throw fehler;
}
await vorgangVerknuepfen(vorgang.id, id);
await protokollieren({
akteur,
aktion: "schluessel.importiert",
ergebnis: "ERFOLG",
fingerprint: info.fingerprint,
details: {
name: eingabe.name,
slug: eingabe.slug,
typ,
kategorie: "DIENST",
art: "paar",
passphraseGeschuetzt: info.verschluesselt,
},
});
return { ergebnis: { schluesselId: id, fingerprint: info.fingerprint } };
};
+34 -2
View File
@@ -126,13 +126,16 @@ export type NeuerSchluessel = {
/**
* Erzeugt ein Schluesselpaar und versiegelt den Private Key sofort. Die ID wird hier
* vergeben, weil sie in die Zusatzdaten des Umschlags eingeht.
* vergeben, weil sie in die Zusatzdaten des Umschlags eingeht. Mit `passphrase` liegt im
* Umschlag der bereits passphrasengeschuetzte OpenSSH-Key; die Passphrase selbst wird nie
* gespeichert (Freigabe Frage 6, #012).
*/
export async function schluesselpaarVersiegeltErzeugen(
typ: ErzeugbarerSchluesseltyp,
kommentar: string,
passphrase?: string,
): Promise<NeuerSchluessel> {
const paar = await schluesselpaarErzeugen(typ, { kommentar });
const paar = await schluesselpaarErzeugen(typ, { kommentar, ...(passphrase ? { passphrase } : {}) });
const id = randomUUID();
return {
id,
@@ -142,6 +145,35 @@ export async function schluesselpaarVersiegeltErzeugen(
};
}
/**
* Versiegelt einen importierten Private Key (#012) so, wie er kam – ein
* passphrasengeschuetzter Key bleibt es. Die ID wird hier vergeben (Zusatzdaten).
*/
export async function importiertenKeyVersiegeln(
fingerprint: string,
privateKeyOpenssh: string,
): Promise<{ readonly id: string; readonly versiegelt: VersiegelterKey }> {
const id = randomUUID();
return { id, versiegelt: await privateKeyVersiegeln(id, fingerprint, privateKeyOpenssh) };
}
/**
* Verwirft den gespeicherten Private Key endgueltig (#014): Chiffrat, Data-Key und Version
* auf null, Zeitpunkt festhalten. Liefert false, wenn keiner (mehr) gespeichert war.
*/
export async function privateKeyVerwerfen(schluesselId: string): Promise<boolean> {
const ergebnis = await prisma.schluessel.updateMany({
where: { id: schluesselId, privatVerschluesselt: { not: null } },
data: {
privatVerschluesselt: null,
dekVerschluesselt: null,
masterKeyVersion: null,
privatGeloeschtAm: new Date(),
},
});
return ergebnis.count === 1;
}
/**
* Stellt beim Start des Workers sicher, dass es einen Deployment-Key der Instanz gibt
* (#006). Gibt die ID zurueck. Gleichzeitige Starts scheitern am partiellen Index
+6
View File
@@ -2,6 +2,8 @@ import type { HandlerTabelle } from "@/worker/ausfuehren";
import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen";
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey";
import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
/**
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) …
@@ -12,4 +14,8 @@ export const HANDLER: HandlerTabelle = {
BOOTSTRAP: bootstrapHandler,
TROCKENLAUF: trockenlaufHandler,
AUSROLLEN: ausrollenHandler,
SCHLUESSEL_ERZEUGEN: schluesselErzeugenHandler,
SCHLUESSEL_IMPORTIEREN: schluesselImportierenHandler,
PRIVATE_KEY_ABHOLEN: privateKeyAbholenHandler,
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
};
+16
View File
@@ -3,6 +3,7 @@ import type { Job } from "pg-boss";
import { VERSION } from "@/lib/version";
import { WARTESCHLANGE, warteschlangeHolen, type VorgangsJob } from "@/server/vorgaenge/warteschlange";
import { vorgangAusfuehren } from "@/worker/ausfuehren";
import { abholungenAufraeumen } from "@/worker/ablaeufe/privatekey";
import { deploymentKeySicherstellen } from "@/worker/geheimnisse";
import { HANDLER } from "@/worker/handler";
@@ -30,10 +31,25 @@ async function starten(): Promise<void> {
},
);
// Nicht abgeholte Private Keys (#014) verfallen nach zwei Minuten. Ein Takt im Worker statt
// eines pg-boss-Zeitplans: dessen Raster ist eine Minute, und ein Fehler hier soll den
// Worker nicht anhalten. Zusaetzlich raeumt jede neue Abholung vorher auf, und das Web
// gibt eine abgelaufene Abholung ohnehin nicht mehr heraus.
const aufraeumen = () =>
abholungenAufraeumen().catch((fehler: unknown) =>
console.error(
"Aufräumen der Abholungen fehlgeschlagen:",
fehler instanceof Error ? fehler.message : fehler,
),
);
await aufraeumen();
const takt = setInterval(() => void aufraeumen(), 30_000);
console.log(`sshkv-worker ${VERSION} bereit.`);
const beenden = async (signal: string) => {
console.log(`${signal} empfangen, beende laufende Vorgänge …`);
clearInterval(takt);
await boss.stop({ graceful: true, timeout: 30_000 });
process.exit(0);
};
+7 -1
View File
@@ -73,7 +73,13 @@ describe("Rechte", () => {
it("verlangt fuer kritische Aktionen eine Bestaetigung, und die nur fuer Admin-Rechte", () => {
expect([...BESTAETIGUNG_NOETIG].sort()).toEqual(
["deploymentKeyVerwalten", "massenwiderruf", "privateKeyAnzeigen", "zielkontenVerwalten"].sort(),
[
"deploymentKeyVerwalten",
"massenwiderruf",
"privateKeyAnzeigen",
"privateKeyLoeschen",
"zielkontenVerwalten",
].sort(),
);
for (const recht of BESTAETIGUNG_NOETIG) {
expect(RECHTE[recht]).toEqual(["ADMIN"]);
+21
View File
@@ -20,6 +20,8 @@ const REGELN = [
"privateverwendung_pfad_gueltig",
"publicverwendung_host_oder_gruppe",
"benutzer_notfall_vollstaendig",
"schluessel_slug_gueltig",
"schluessel_geloescht_ohne_private_key",
].sort();
const INDIZES = [
@@ -90,6 +92,25 @@ describe("Datenbankregeln", () => {
).rejects.toThrow();
});
it("laesst nur Slugs zu, die in Dateinamen passen (#012)", async () => {
for (const slug of ["Gross", "mit leer", "../x", "a_b", "", "x".repeat(65)]) {
await expect(schluesselAnlegen({ slug })).rejects.toThrow();
}
await expect(schluesselAnlegen({ slug: "backup-srv-pve01" })).resolves.toBeDefined();
});
it("kennt „Private Key geloescht“ nur ohne Chiffrat (#014)", async () => {
await expect(
schluesselAnlegen({
privatGeloeschtAm: new Date(),
privatVerschluesselt: Buffer.from("x"),
dekVerschluesselt: Buffer.from("y"),
masterKeyVersion: 1,
}),
).rejects.toThrow();
await expect(schluesselAnlegen({ privatGeloeschtAm: new Date() })).resolves.toBeDefined();
});
it("laesst nur einen aktiven Instanz-Deployment-Key zu", async () => {
await expect(schluesselAnlegen({ kategorie: "DIENST", instanzStandard: true })).rejects.toThrow();
await schluesselAnlegen({ kategorie: "DEPLOYMENT", instanzStandard: true });
+197
View File
@@ -0,0 +1,197 @@
import { expect, test, type Page } from "@playwright/test";
import pg from "pg";
import "dotenv/config";
import { schluesselpaarErzeugen } from "../../src/lib/ssh/openssh";
/**
* Schluessel im gebauten Stand mit laufendem Worker (#012–#014). Testdaten heissen zz-e2e-…
* und bleiben liegen (Schluessel werden nicht geloescht, nur spaeter widerrufen).
*/
test.setTimeout(90_000);
async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader") {
await page.goto("/anmelden");
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
await page.locator(`button[data-rolle="${rolle}"]`).click();
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
}
async function datenbank<T>(arbeit: (client: pg.Client) => Promise<T>): Promise<T> {
const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] });
await client.connect();
try {
return await arbeit(client);
} finally {
await client.end();
}
}
function eindeutig(): string {
return `zz-e2e-${Date.now().toString(36)}`;
}
test("Operator erzeugt einen Schlüssel und landet im Detail", async ({ page }) => {
const name = `${eindeutig()} Backup`;
await oidcAnmelden(page, "operator");
await page.goto("/schluessel/neu");
await page.getByLabel("Zweck").fill(name);
await page.getByLabel("Tags").fill("e2e");
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
await expect(page.getByRole("heading", { name })).toBeVisible({ timeout: 30_000 });
await expect(page).toHaveURL(/\/schluessel\/[^/]+$/);
await expect(page.getByText("verschlüsselt gespeichert")).toBeVisible();
await expect(page.getByText("ed25519", { exact: true })).toBeVisible();
await expect(page.getByLabel("Public Key", { exact: true })).toContainText("ssh-ed25519 AAAA");
await expect(page.getByLabel("Public Key", { exact: true })).toContainText(
`sshkv:${name.toLowerCase().replace(/ /g, "-")}`,
);
await page.goto(`/schluessel?q=${encodeURIComponent(name)}`);
await expect(page.getByRole("link", { name })).toBeVisible();
});
test("Public Key importieren, Duplikat wird erkannt", async ({ page }) => {
const name = `${eindeutig()} Laptop`;
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "e2e@laptop" });
await oidcAnmelden(page, "operator");
await page.goto("/schluessel/neu");
await page.getByRole("radio", { name: "Importieren" }).click();
await page.getByLabel("Zweck").fill(name);
await page.getByLabel("Public Key").fill(paar.publicKeyZeile);
await page.locator(`input[name="kategorie"][value="PERSON"]`).check();
await page.getByRole("button", { name: "Schlüssel importieren" }).click();
await expect(page.getByRole("heading", { name })).toBeVisible();
await expect(page.getByText("nicht gespeichert (Personen-Key)")).toBeVisible();
await expect(page.getByText(paar.fingerprint)).toBeVisible();
await page.goto("/schluessel/neu");
await page.getByRole("radio", { name: "Importieren" }).click();
await page.getByLabel("Zweck").fill(`${name} nochmal`);
await page.getByLabel("Public Key").fill(paar.publicKeyZeile);
await page.getByRole("button", { name: "Schlüssel importieren" }).click();
await expect(page.getByRole("alert").first()).toContainText(`bereits verwaltet: „${name}“`);
});
test("Leser sieht Schlüssel, darf aber keine anlegen", async ({ page }) => {
await oidcAnmelden(page, "reader");
await page.goto("/schluessel");
await expect(page.getByRole("heading", { name: "Schlüssel", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "Schlüssel anlegen" })).toHaveCount(0);
await page.goto("/schluessel/neu");
await expect(page).toHaveURL(/\/kein-zugriff/);
});
test("Leser hinterlegt einen eigenen Public Key und entfernt ihn wieder", async ({ page }) => {
const name = `${eindeutig()} Laptop`;
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "reader@laptop" });
await oidcAnmelden(page, "reader");
await page.getByRole("link", { name: "Meine Schlüssel" }).first().click();
await expect(page.getByRole("heading", { name: "Meine Schlüssel" })).toBeVisible();
await expect(page.getByText(/Wirksam erst, wenn ein Operator oder Admin/).first()).toBeVisible();
await page.getByLabel("Bezeichnung").fill(name);
await page.getByLabel("Public Key").fill(paar.publicKeyZeile);
await page.getByRole("button", { name: "Hinterlegen" }).click();
await expect(page.getByText(`Hinterlegt: ${paar.fingerprint}`)).toBeVisible();
const eintrag = page.locator("li", { hasText: name });
await expect(eintrag.getByText("noch nicht zugewiesen")).toBeVisible();
await eintrag.getByRole("button", { name: `${name} entfernen` }).click();
await page.getByRole("dialog").getByRole("button", { name: "Entfernen", exact: true }).click();
await expect(page.locator("li", { hasText: name })).toHaveCount(0);
});
/**
* Erneute Anmeldung beim Mock-OIDC (#014). Der Mock uebernimmt nur die Claims aus dem
* Formular; `auth_time` muss deshalb mitgeschickt werden, sonst lehnt sshkv die Bestaetigung ab.
*/
async function oidcBestaetigen(page: Page) {
await expect(page).toHaveURL(/\/bestaetigen\?ziel=/);
await page.getByRole("link", { name: /erneut anmelden/ }).click();
await page.locator('button[data-rolle="admin"]').click();
const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record<string, unknown>;
await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
}
test("Admin zeigt den Private Key nach Bestätigung an, lädt ihn herunter und löscht ihn", async ({
page,
}) => {
const name = `${eindeutig()} Deploy`;
await oidcAnmelden(page, "admin");
// Eine Bestaetigung aus einem frueheren Lauf soll nicht mitzaehlen.
await datenbank((client) =>
client.query(`UPDATE "Benutzer" SET "bestaetigtAm" = NULL WHERE "email" = 'admin@sshkv.test'`),
);
await page.goto("/schluessel/neu");
await page.getByLabel("Zweck").fill(name);
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
await expect(page.getByRole("heading", { name })).toBeVisible({ timeout: 30_000 });
const detail = page.url();
const fingerprint =
(
await page
.getByText(/^SHA256:/)
.first()
.textContent()
)?.trim() ?? "";
// Ohne frische Bestaetigung geht es erst nach /bestaetigen und von dort zurueck in den Dialog.
await page.getByRole("button", { name: "anzeigen (Re-Auth)" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "Anzeigen" }).click();
await oidcBestaetigen(page);
await expect(page).toHaveURL(/privatekey=1/);
await dialog.getByRole("button", { name: "Anzeigen" }).click();
const block = dialog.getByLabel("Private Key", { exact: true });
await expect(block).toContainText("-----BEGIN OPENSSH PRIVATE KEY-----", { timeout: 30_000 });
await expect(dialog.getByText("Wird nicht erneut angezeigt.")).toBeVisible();
await dialog.getByRole("button", { name: "Schließen" }).click();
await expect(block).toHaveCount(0);
// Download: eigene Abholung, Datei <slug>, ohne Caching.
await page.getByRole("button", { name: "anzeigen (Re-Auth)" }).click();
const antwort = page.waitForResponse((r) => r.url().includes("/api/private-key/"));
const datei = page.waitForEvent("download");
await dialog.getByRole("button", { name: "Herunterladen" }).click();
const kopf = (await antwort).headers();
expect(kopf["cache-control"]).toContain("no-store");
expect(kopf["content-disposition"]).toMatch(/^attachment; filename="zz-e2e-[a-z0-9]+-deploy"$/);
const download = await datei;
expect(download.suggestedFilename()).toMatch(/^zz-e2e-[a-z0-9]+-deploy$/);
await expect(dialog.getByText(/Download gestartet/)).toBeVisible();
await dialog.getByRole("button", { name: "Schließen" }).click();
// Operator sieht keinen Anzeigen-Knopf und kein Loeschen.
const operator = await page.context().browser()!.newContext();
const operatorSeite = await operator.newPage();
await oidcAnmelden(operatorSeite, "operator");
await operatorSeite.goto(detail);
await expect(operatorSeite.getByText("verschlüsselt gespeichert")).toBeVisible();
await expect(operatorSeite.getByRole("button", { name: "anzeigen (Re-Auth)" })).toHaveCount(0);
await expect(operatorSeite.getByRole("button", { name: "Private Key löschen" })).toHaveCount(0);
await operator.close();
// Loeschen (Bestaetigung ist noch frisch).
await page.getByRole("button", { name: "Private Key löschen" }).click();
await dialog.getByText("Ich habe verstanden").click();
await dialog.getByRole("button", { name: "Endgültig löschen" }).click();
await expect(page.getByText(/gelöscht am/)).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "anzeigen (Re-Auth)" })).toHaveCount(0);
// Im Audit stehen Anzeige, Download und Loeschen mit Fingerprint – ohne den Key.
expect(fingerprint).toMatch(/^SHA256:/);
const { rows } = await datenbank((client) =>
client.query<{ aktion: string; zeile: string }>(
`SELECT "aktion", row_to_json(p)::text AS zeile FROM "Protokoll" p WHERE "fingerprint" = $1 ORDER BY "id"`,
[fingerprint],
),
);
expect(rows.map((r) => r.aktion)).toEqual(
expect.arrayContaining(["privatekey.angezeigt", "privatekey.heruntergeladen", "privatekey.geloescht"]),
);
expect(rows.some((r) => r.zeile.includes("PRIVATE KEY"))).toBe(false);
});
+36
View File
@@ -0,0 +1,36 @@
import { prisma } from "@/lib/db";
import { WARTESCHLANGE, warteschlangeHolen, type VorgangsJob } from "@/server/vorgaenge/warteschlange";
import { vorgangAusfuehren } from "@/worker/ausfuehren";
import { HANDLER } from "@/worker/handler";
/**
* Fuehrt einen eingereihten Vorgang so aus, wie es der Worker tut (#012): Job aus pg-boss
* holen, mit seinem `geheim` ausfuehren, danach loeschen (src/worker/index.ts).
* Liefert zusaetzlich die rohen Job-Daten, damit Tests pruefen koennen, was darin stand.
*/
export async function vorgangImWorker(vorgangId: string): Promise<{ jobDaten: VorgangsJob }> {
const boss = await warteschlangeHolen();
// Die Warteschlange ueberlebt das Leeren der Tabellen; deshalb gezielt den eigenen Job suchen.
const jobs = await boss.fetch<VorgangsJob>(WARTESCHLANGE, { batchSize: 100 });
const job = jobs.find((kandidat) => kandidat.data.vorgangId === vorgangId);
if (!job) throw new Error(`Kein Job für Vorgang ${vorgangId}`);
await vorgangAusfuehren(job.data.vorgangId, HANDLER, job.data.geheim);
await boss.complete(WARTESCHLANGE, job.id);
if (job.data.geheim) await boss.deleteJob(WARTESCHLANGE, job.id);
return { jobDaten: job.data };
}
/** Alle Zeilen aller Tabellen als ein Text – fuer „steht das Geheimnis irgendwo?“. */
export async function datenbankAlsText(): Promise<string> {
const teile = await Promise.all([
prisma.vorgang.findMany(),
prisma.protokoll.findMany(),
prisma.$queryRaw<unknown[]>`SELECT data FROM pgboss.job`,
]);
return JSON.stringify(teile, (_k, wert: unknown) => (typeof wert === "bigint" ? wert.toString() : wert));
}
export async function warteschlangeStoppen(): Promise<void> {
const boss = await warteschlangeHolen();
await boss.stop({ graceful: false });
}
+213
View File
@@ -0,0 +1,213 @@
import { readFileSync } from "node:fs";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import type { Benutzer } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { darf } from "@/server/auth/rechte";
import { kontenAuswahl, personDetail, personenListe } from "@/server/queries/personen";
import { FachFehler } from "@/server/services/fachfehler";
import {
eigenePersonSicherstellen,
eigenenPublicKeyHinterlegen,
eigenenSchluesselEntfernen,
eigeneSchluessel,
} from "@/server/services/meine-schluessel";
import { personAnlegen, personArchivieren, personBearbeiten } from "@/server/services/personen";
import { publicKeyImportieren } from "@/server/services/schluessel";
import { datenbankLeeren } from "./hilfen/db";
import { warteschlangeStoppen } from "./hilfen/vorgang";
import { ed25519Blob, zeile } from "./ssh/hilfen";
/** Personen und Self-Service „Meine Schluessel“ (#013). */
let operator: Benutzer;
let leser: Benutzer;
let andererLeser: Benutzer;
beforeEach(async () => {
await datenbankLeeren();
operator = await prisma.benutzer.create({ data: { name: "Olga Operator", rolle: "OPERATOR" } });
leser = await prisma.benutzer.create({
data: { name: "Lea Leser", rolle: "LESER", email: "lea@example.org", emailBestaetigt: true },
});
andererLeser = await prisma.benutzer.create({
data: { name: "Max Mitleser", rolle: "LESER", email: "max@example.org", emailBestaetigt: true },
});
});
afterAll(async () => {
await warteschlangeStoppen();
});
async function fachfehlerVon(versuch: Promise<unknown>): Promise<FachFehler> {
const fehler = await versuch.then(
() => undefined,
(f: unknown) => f,
);
expect(fehler).toBeInstanceOf(FachFehler);
return fehler as FachFehler;
}
const akteur = () => ({ id: operator.id, name: operator.name });
const neueZeile = (kommentar = "test") => zeile("ssh-ed25519", ed25519Blob(), kommentar);
describe("Personen verwalten", () => {
it("legt an, bearbeitet, archiviert und protokolliert", async () => {
const person = await personAnlegen(
{ name: "Anna Berger", email: "A.Berger@Example.org", organisation: "Netzwerk GmbH" },
akteur(),
);
const detail = await personDetail(person.id);
expect(detail).toMatchObject({ email: "a.berger@example.org", organisation: "Netzwerk GmbH" });
await personBearbeiten(
person.id,
{ name: "Anna Berger", email: "a.berger@example.org", notiz: "privat" },
akteur(),
);
const geaendert = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "person.geaendert" } });
// Die Notiz steht nicht im Protokoll, nur dass sie sich aenderte.
expect(JSON.stringify(geaendert.details)).not.toContain("privat");
expect(geaendert.details).toMatchObject({ notiz: "geändert", organisation: { vorher: "Netzwerk GmbH" } });
await personArchivieren(person.id, akteur());
expect((await personenListe()).map((p) => p.name)).toEqual([]);
expect((await personenListe({ archivierte: true })).map((p) => p.name)).toEqual(["Anna Berger"]);
await fachfehlerVon(personBearbeiten(person.id, { name: "X" }, akteur()));
expect(await prisma.protokoll.count({ where: { aktion: { startsWith: "person." } } })).toBe(3);
});
it("prueft E-Mail und verknuepft ein Konto nur einmal", async () => {
const fehler = await fachfehlerVon(personAnlegen({ name: "X", email: "keine-mail" }, akteur()));
expect(fehler.feldfehler["email"]).toBeDefined();
await personAnlegen({ name: "Lea", benutzerId: leser.id }, akteur());
const doppelt = await fachfehlerVon(personAnlegen({ name: "Lea 2", benutzerId: leser.id }, akteur()));
expect(doppelt.feldfehler["benutzerId"]).toBeDefined();
expect((await kontenAuswahl()).map((k) => k.name)).toEqual(["Max Mitleser", "Olga Operator"]);
});
it("loest beim Archivieren die Kontoverknuepfung", async () => {
const person = await personAnlegen({ name: "Lea", benutzerId: leser.id }, akteur());
await personArchivieren(person.id, akteur());
expect((await personDetail(person.id))?.benutzerId).toBeNull();
// Der Self-Service landet danach bei einer neuen Person, nicht bei der archivierten.
expect((await eigenePersonSicherstellen(leser)).id).not.toBe(person.id);
});
});
describe("Eigene Person", () => {
it("wird beim ersten Aufruf angelegt und danach wiederverwendet", async () => {
const [a, b] = await Promise.all([eigenePersonSicherstellen(leser), eigenePersonSicherstellen(leser)]);
expect(a.id).toBe(b.id);
expect(await prisma.person.count()).toBe(1);
expect((await personDetail(a.id))?.benutzerId).toBe(leser.id);
});
it("verknuepft per bestaetigter E-Mail mit einer vorhandenen Person", async () => {
const person = await personAnlegen({ name: "Lea Leser (IT)", email: "LEA@example.org" }, akteur());
expect((await eigenePersonSicherstellen(leser)).id).toBe(person.id);
expect(await prisma.protokoll.count({ where: { aktion: "person.verknuepft" } })).toBe(1);
});
it("verknuepft nicht ueber eine unbestaetigte Adresse", async () => {
const person = await personAnlegen({ name: "Chefin", email: "chefin@example.org" }, akteur());
const angreifer = await prisma.benutzer.create({
data: { name: "Mallory", rolle: "LESER", email: "chefin@example.org", emailBestaetigt: false },
});
expect((await eigenePersonSicherstellen(angreifer)).id).not.toBe(person.id);
expect((await personDetail(person.id))?.benutzerId).toBeNull();
});
});
describe("Meine Schluessel", () => {
it("hinterlegt eigene Public Keys als Personen-Keys", async () => {
const neu = await eigenenPublicKeyHinterlegen(leser, {
name: "Laptop",
publicKey: neueZeile("lea@laptop"),
});
const schluessel = await prisma.schluessel.findUniqueOrThrow({
where: { id: neu.id },
select: {
kategorie: true,
selbstHinterlegt: true,
personId: true,
erstelltVonId: true,
masterKeyVersion: true,
},
});
const person = await eigenePersonSicherstellen(leser);
expect(schluessel).toEqual({
kategorie: "PERSON",
selbstHinterlegt: true,
personId: person.id,
erstelltVonId: leser.id,
masterKeyVersion: null,
});
const eintrag = await prisma.protokoll.findFirstOrThrow({
where: { aktion: "schluessel.selbst_hinterlegt" },
});
expect(eintrag.akteurId).toBe(leser.id);
});
it("zeigt nur eigene Keys und laesst fremde nicht entfernen", async () => {
const eigener = await eigenenPublicKeyHinterlegen(leser, { name: "Meiner", publicKey: neueZeile() });
const fremder = await eigenenPublicKeyHinterlegen(andererLeser, {
name: "Fremd",
publicKey: neueZeile(),
});
expect((await eigeneSchluessel(leser)).schluessel.map((s) => s.name)).toEqual(["Meiner"]);
expect((await eigeneSchluessel(andererLeser)).schluessel.map((s) => s.name)).toEqual(["Fremd"]);
const fehler = await fachfehlerVon(eigenenSchluesselEntfernen(leser, fremder.id));
expect(fehler.message).toMatch(/unter Ihren Schlüsseln nicht/);
expect(await prisma.schluessel.count({ where: { id: fremder.id } })).toBe(1);
await eigenenSchluesselEntfernen(leser, eigener.id);
expect(await prisma.schluessel.count({ where: { id: eigener.id } })).toBe(0);
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.selbst_entfernt" } })).toBe(1);
});
it("entfernt keine zugewiesenen und keine von Operatoren angelegten Keys", async () => {
const zugewiesen = await eigenenPublicKeyHinterlegen(leser, {
name: "Zugewiesen",
publicKey: neueZeile(),
});
const host = await prisma.host.create({ data: { name: "srv-test01", adresse: "10.0.0.1" } });
await prisma.publicVerwendung.create({
data: { schluesselId: zugewiesen.id, hostId: host.id, zielbenutzer: "lea" },
});
await fachfehlerVon(eigenenSchluesselEntfernen(leser, zugewiesen.id));
const person = await eigenePersonSicherstellen(leser);
const vomOperator = await publicKeyImportieren(
{ name: "Vom Operator", kategorie: "PERSON", publicKey: neueZeile(), personId: person.id },
akteur(),
);
await fachfehlerVon(eigenenSchluesselEntfernen(leser, vomOperator.id));
expect(await prisma.schluessel.count()).toBe(2);
});
it("meldet Duplikate auch im Self-Service", async () => {
const z = neueZeile();
await eigenenPublicKeyHinterlegen(leser, { name: "Einmal", publicKey: z });
const fehler = await fachfehlerVon(
eigenenPublicKeyHinterlegen(andererLeser, { name: "Nochmal", publicKey: z }),
);
expect(fehler.message).toMatch(/bereits verwaltet/);
});
});
describe("Rechte (Freigabe Frage 4)", () => {
it("Leser darf eigene Keys hinterlegen, aber weder verwalten noch zuweisen", () => {
expect(darf("LESER", "eigeneSchluesselHinterlegen")).toBe(true);
expect(darf("LESER", "schluesselVerwalten")).toBe(false);
expect(darf("LESER", "publicKeyAusrollen")).toBe(false);
});
it("die Self-Service-Aktionen verlangen das Self-Service-Recht und reichen nur den eigenen Benutzer weiter", () => {
const text = readFileSync("src/server/actions/meine-schluessel.ts", "utf8");
expect(text.match(/rechtErforderlich\("eigeneSchluesselHinterlegen"\)/g)).toHaveLength(2);
expect(text).not.toMatch(/personId/);
});
});
+249
View File
@@ -0,0 +1,249 @@
import { randomBytes } from "node:crypto";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { ABHOLFRIST_MS } from "@/lib/schluessel";
import { privateKeyLesen } from "@/lib/ssh/openssh";
import { BESTAETIGUNG_NOETIG, RECHTE } from "@/server/auth/rechte";
import { schluesselDetail } from "@/server/queries/schluessel";
import { FachFehler } from "@/server/services/fachfehler";
import {
abholungAnfordern,
privateKeyAbholen,
privateKeyLoeschenAnfordern,
} from "@/server/services/privatekey";
import { erzeugenAnfordern, publicKeyImportieren } from "@/server/services/schluessel";
import { vorgangLesen } from "@/server/vorgaenge/vorgaenge";
import { abholungenAufraeumen } from "@/worker/ablaeufe/privatekey";
import { deploymentKeySicherstellen, masterKeyVergessen, privateKeyLaden } from "@/worker/geheimnisse";
import { datenbankLeeren } from "./hilfen/db";
import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang";
import { ed25519Blob, zeile } from "./ssh/hilfen";
/** Private Key anzeigen, herunterladen, loeschen (#014). */
let verzeichnis: string;
let admin: { id: string; name: string };
let zweiterAdmin: { id: string; name: string };
beforeAll(() => {
verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-"));
const datei = path.join(verzeichnis, "master.key");
writeFileSync(datei, randomBytes(32), { mode: 0o600 });
process.env["SSHKV_MASTER_KEY_FILE"] = datei;
masterKeyVergessen();
});
afterAll(async () => {
rmSync(verzeichnis, { recursive: true, force: true });
await warteschlangeStoppen();
});
beforeEach(async () => {
await datenbankLeeren();
admin = await prisma.benutzer.create({
data: { name: "Ada Admin", rolle: "ADMIN" },
select: { id: true, name: true },
});
zweiterAdmin = await prisma.benutzer.create({
data: { name: "Zora Zweitadmin", rolle: "ADMIN" },
select: { id: true, name: true },
});
});
async function fachfehlerVon(versuch: Promise<unknown>): Promise<FachFehler> {
const fehler = await versuch.then(
() => undefined,
(f: unknown) => f,
);
expect(fehler).toBeInstanceOf(FachFehler);
return fehler as FachFehler;
}
/** Ein Dienst-Key mit gespeichertem Private Key, erzeugt wie in der Oberflaeche. */
async function dienstKey(): Promise<string> {
const vorgangId = await erzeugenAnfordern({ name: "Backup" }, undefined, admin);
await vorgangImWorker(vorgangId);
const { ergebnis } = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
return (ergebnis as { schluesselId: string }).schluesselId;
}
async function bereitgelegt(
schluesselId: string,
akteur = admin,
zweck: "anzeigen" | "herunterladen" = "anzeigen",
) {
const vorgangId = await abholungAnfordern(schluesselId, zweck, akteur);
await vorgangImWorker(vorgangId);
return vorgangId;
}
describe("Abholung", () => {
it("liefert den Key genau einmal und protokolliert ohne ihn", async () => {
const schluesselId = await dienstKey();
const vorgangId = await bereitgelegt(schluesselId);
const roh = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
expect(roh.status).toBe("ERLEDIGT");
const abholung = (roh.ergebnis as { abholung: string }).abholung;
expect(abholung).toMatch(/^v1\./);
expect(abholung).not.toContain("PRIVATE KEY");
// Die Status-Schnittstelle zeigt nur „bereit“, nie das Chiffrat.
expect((await vorgangLesen(vorgangId))?.ergebnis).toEqual({ bereit: true });
const erwartet = (await privateKeyLaden(schluesselId)).toString("utf8");
const geliefert = await privateKeyAbholen(vorgangId, "anzeigen", admin);
expect(geliefert.privateKey).toBe(erwartet);
expect(privateKeyLesen(geliefert.privateKey).fingerprint).toBe(geliefert.fingerprint);
expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } })).ergebnis).toEqual({
abgeholt: true,
});
await fachfehlerVon(privateKeyAbholen(vorgangId, "anzeigen", admin));
const eintraege = await prisma.protokoll.findMany({
where: { aktion: { startsWith: "privatekey." } },
orderBy: { id: "asc" },
});
expect(eintraege.map((e) => e.aktion)).toEqual(["privatekey.angefordert", "privatekey.angezeigt"]);
expect(eintraege.every((e) => e.fingerprint === geliefert.fingerprint)).toBe(true);
const alles = await datenbankAlsText();
expect(alles).not.toContain("PRIVATE KEY");
expect(alles).not.toContain(erwartet.split("\n")[2]);
});
it("gibt bei gleichzeitigen Abholungen genau eine heraus", async () => {
const vorgangId = await bereitgelegt(await dienstKey(), admin, "herunterladen");
const ergebnisse = await Promise.allSettled(
Array.from({ length: 5 }, () => privateKeyAbholen(vorgangId, "herunterladen", admin)),
);
expect(ergebnisse.filter((e) => e.status === "fulfilled")).toHaveLength(1);
expect(await prisma.protokoll.count({ where: { aktion: "privatekey.heruntergeladen" } })).toBe(1);
});
it("gibt nur dem Anfordernden heraus", async () => {
const vorgangId = await bereitgelegt(await dienstKey());
await fachfehlerVon(privateKeyAbholen(vorgangId, "anzeigen", zweiterAdmin));
await expect(privateKeyAbholen(vorgangId, "anzeigen", admin)).resolves.toBeDefined();
});
it("verweigert nach Ablauf der Frist und ueberschreibt die Abholung trotzdem", async () => {
const vorgangId = await bereitgelegt(await dienstKey());
const { beendetAm } = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
const spaeter = new Date(beendetAm!.getTime() + ABHOLFRIST_MS + 1000);
const fehler = await fachfehlerVon(privateKeyAbholen(vorgangId, "anzeigen", admin, spaeter));
expect(fehler.message).toMatch(/verfallen/);
expect(
JSON.stringify((await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } })).ergebnis),
).not.toContain("abholung");
});
it("verlangt einen gespeicherten Private Key und gibt keinen Deployment-Key heraus", async () => {
const nurPublic = await publicKeyImportieren(
{ name: "Nur Public", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
admin,
);
expect((await fachfehlerVon(abholungAnfordern(nurPublic.id, "anzeigen", admin))).message).toMatch(
/kein Private Key/,
);
const deployment = await deploymentKeySicherstellen();
expect((await fachfehlerVon(abholungAnfordern(deployment, "anzeigen", admin))).message).toMatch(
/Deployment/,
);
await fachfehlerVon(privateKeyLoeschenAnfordern(deployment, admin));
expect(await prisma.vorgang.count({ where: { art: "PRIVATE_KEY_ABHOLEN" } })).toBe(0);
});
});
describe("Aufraeumen", () => {
it("ueberschreibt nur Abholungen, die laenger als die Frist liegen", async () => {
const jetzt = new Date();
const alt = await prisma.vorgang.create({
data: {
art: "PRIVATE_KEY_ABHOLEN",
status: "ERLEDIGT",
ergebnis: { abholung: "v1.a.b.c" },
beendetAm: new Date(jetzt.getTime() - ABHOLFRIST_MS - 5000),
},
});
const frisch = await prisma.vorgang.create({
data: {
art: "PRIVATE_KEY_ABHOLEN",
status: "ERLEDIGT",
ergebnis: { abholung: "v1.d.e.f" },
beendetAm: new Date(jetzt.getTime() - 30_000),
},
});
const fremd = await prisma.vorgang.create({
data: {
art: "ERREICHBARKEIT",
status: "ERLEDIGT",
ergebnis: { abholung: "kein Key" },
beendetAm: alt.beendetAm,
},
});
expect(await abholungenAufraeumen(jetzt)).toBe(1);
const lesen = async (id: string) => (await prisma.vorgang.findUniqueOrThrow({ where: { id } })).ergebnis;
expect(await lesen(alt.id)).toEqual({ verfallen: true });
expect(await lesen(frisch.id)).toEqual({ abholung: "v1.d.e.f" });
expect(await lesen(fremd.id)).toEqual({ abholung: "kein Key" });
// Spaeter verfaellt auch die zweite.
expect(await abholungenAufraeumen(new Date(jetzt.getTime() + ABHOLFRIST_MS))).toBe(1);
});
it("laeuft vor jeder neuen Abholung im Worker", async () => {
const schluesselId = await dienstKey();
const liegengeblieben = await bereitgelegt(schluesselId);
await prisma.vorgang.update({
where: { id: liegengeblieben },
data: { beendetAm: new Date(Date.now() - ABHOLFRIST_MS - 1000) },
});
await bereitgelegt(schluesselId);
expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: liegengeblieben } })).ergebnis).toEqual({
verfallen: true,
});
});
});
describe("Private Key loeschen", () => {
it("verwirft Chiffrat, Data-Key und Version im Worker und protokolliert", async () => {
const schluesselId = await dienstKey();
const vorgangId = await privateKeyLoeschenAnfordern(schluesselId, admin);
await vorgangImWorker(vorgangId);
expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } })).status).toBe("ERLEDIGT");
const detail = await schluesselDetail(schluesselId);
expect(detail?.privatGespeichert).toBe(false);
expect(detail?.privatGeloeschtAm).toBeInstanceOf(Date);
await expect(privateKeyLaden(schluesselId)).rejects.toThrow(/kein Private Key/);
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "privatekey.geloescht" } });
expect(eintrag).toMatchObject({ akteurId: admin.id, fingerprint: detail?.fingerprint });
await fachfehlerVon(privateKeyLoeschenAnfordern(schluesselId, admin));
await fachfehlerVon(abholungAnfordern(schluesselId, "anzeigen", admin));
});
});
describe("Rechte", () => {
it("nur Admin, immer mit frischer Bestaetigung", () => {
for (const recht of ["privateKeyAnzeigen", "privateKeyLoeschen"] as const) {
expect(RECHTE[recht]).toEqual(["ADMIN"]);
expect(BESTAETIGUNG_NOETIG.has(recht)).toBe(true);
}
});
it("Action und Route Handler pruefen selbst", () => {
const aktionen = readFileSync("src/server/actions/privatekey.ts", "utf8");
expect(aktionen.match(/rechtErforderlich\("privateKeyAnzeigen"\)/g)).toHaveLength(2);
expect(aktionen).toMatch(/rechtErforderlich\("privateKeyLoeschen"\)/);
const route = readFileSync("src/app/api/private-key/[vorgangId]/route.ts", "utf8");
expect(route).toMatch(/rechtErforderlich\("privateKeyAnzeigen"\)/);
expect(route).toMatch(/no-store/);
expect(route).toMatch(/attachment/);
expect(route).not.toMatch(/export async function GET/);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { SCHLUESSEL_DETAIL_SELECT, SCHLUESSEL_LISTE_SELECT } from "@/server/queries/schluessel";
/**
* Strukturell (#012): Jede Prisma-Abfrage auf `Schluessel` ausserhalb des Workers hat ein
* festes `select`. Ohne `select` liefert Prisma die ganze Zeile – samt Chiffrat –, ohne dass
* der Feldname im Code steht; tests/geheimnisse-grenze.test.ts saehe das nicht.
*/
function dateien(verzeichnis: string): string[] {
return readdirSync(verzeichnis).flatMap((name) => {
const pfad = path.join(verzeichnis, name);
if (statSync(pfad).isDirectory()) return name === "generated" ? [] : dateien(pfad);
return /\.(ts|tsx)$/.test(name) ? [pfad.split(path.sep).join("/")] : [];
});
}
/** Der Worker darf (und muss) die ganze Zeile sehen; die Grenze dort zieht geheimnisse.ts. */
const WEB = dateien("src").filter((d) => !d.startsWith("src/worker/"));
/** Methoden, die Zeilen zurueckgeben – nur die brauchen ein `select`. */
const ZEILEN =
/\.schluessel\.(findMany|findFirst|findFirstOrThrow|findUnique|findUniqueOrThrow|create|update|upsert|delete)\(/g;
/** Argumenttext eines Aufrufs ab der oeffnenden Klammer, per Klammerzaehlung. */
function argumente(text: string, start: number): string {
let tiefe = 0;
for (let i = start; i < text.length; i++) {
if (text[i] === "(") tiefe++;
else if (text[i] === ")" && --tiefe === 0) return text.slice(start, i + 1);
}
return text.slice(start);
}
describe("Abfragen auf Schluessel im Web", () => {
it("findet Aufrufe", () => {
const anzahl = WEB.reduce((summe, d) => summe + [...readFileSync(d, "utf8").matchAll(ZEILEN)].length, 0);
expect(anzahl).toBeGreaterThan(5);
});
it("haben alle ein select", () => {
const verstoesse: string[] = [];
for (const datei of WEB) {
const text = readFileSync(datei, "utf8");
for (const treffer of text.matchAll(ZEILEN)) {
const start = treffer.index + treffer[0].length - 1;
if (!/\bselect\s*:/.test(argumente(text, start))) {
verstoesse.push(`${datei}: ${treffer[0]}`);
}
}
}
expect(verstoesse).toEqual([]);
});
it("holen Schluessel nie als ganze Relation (include/select: { schluessel: true })", () => {
// Zeilen mit `_count` zaehlen nur und sind erlaubt.
const verstoesse = WEB.filter((d) =>
readFileSync(d, "utf8")
.split("\n")
.some(
(zeile) =>
!zeile.includes("_count") &&
/\b(schluessel|vorgaenger|nachfolger|deploymentSchluessel|erstellteSchluessel)\s*:\s*true/.test(
zeile,
),
),
);
expect(verstoesse).toEqual([]);
});
it("die festen select-Listen nennen kein Chiffrat", () => {
for (const auswahl of [SCHLUESSEL_LISTE_SELECT, SCHLUESSEL_DETAIL_SELECT]) {
expect(Object.keys(auswahl)).not.toContain("privatVerschluesselt");
expect(Object.keys(auswahl)).not.toContain("dekVerschluesselt");
}
});
});
+431
View File
@@ -0,0 +1,431 @@
import { randomBytes } from "node:crypto";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { entschluesseln } from "@/lib/geheimnisse";
import {
ablaufZustand,
fingerprintKurz,
freierSlug,
slugAus,
typAnzeige,
erzeugenSchema,
} from "@/lib/schluessel";
import { privateKeyLesen, schluesselpaarErzeugen } from "@/lib/ssh/openssh";
import { schluesselDetail, schluesselListe } from "@/server/queries/schluessel";
import { FachFehler } from "@/server/services/fachfehler";
import {
erzeugenAnfordern,
paarImportAnfordern,
publicKeyImportieren,
schluesselBearbeiten,
} from "@/server/services/schluessel";
import { masterKeyVergessen, privateKeyLaden } from "@/worker/geheimnisse";
import { datenbankLeeren } from "./hilfen/db";
import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang";
import { ed25519Blob, rsaBlob, zeile } from "./ssh/hilfen";
/** Schluessel erzeugen, importieren, bearbeiten (#012) – Service, Worker-Ablauf, Lesemodell. */
let verzeichnis: string;
let akteur: { id: string; name: string };
beforeAll(() => {
verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-"));
const datei = path.join(verzeichnis, "master.key");
writeFileSync(datei, randomBytes(32), { mode: 0o600 });
process.env["SSHKV_MASTER_KEY_FILE"] = datei;
masterKeyVergessen();
});
afterAll(async () => {
rmSync(verzeichnis, { recursive: true, force: true });
await warteschlangeStoppen();
});
beforeEach(async () => {
await datenbankLeeren();
const benutzer = await prisma.benutzer.create({ data: { name: "Olga Operator", rolle: "OPERATOR" } });
akteur = { id: benutzer.id, name: benutzer.name };
});
async function fachfehlerVon(versuch: Promise<unknown>): Promise<FachFehler> {
const fehler = await versuch.then(
() => undefined,
(f: unknown) => f,
);
expect(fehler).toBeInstanceOf(FachFehler);
return fehler as FachFehler;
}
/** Kern eines Private Keys (ohne Kopf- und Fusszeile) – das eigentliche Geheimnis. */
function kern(privateKey: string): string {
return privateKey.split("\n").slice(1, -2).join("").slice(0, 60);
}
describe("Regeln (rein)", () => {
it.each([
["Backup srv-backup01 → srv-pve01", "backup-srv-backup01-srv-pve01"],
["Größter Schlüssel für Ärzte", "groesster-schluessel-fuer-aerzte"],
[" --- ", "schluessel"],
["Café", "cafe"],
["x".repeat(80), "x".repeat(64)],
])("slugAus(%j) = %j", (name, slug) => {
expect(slugAus(name)).toBe(slug);
});
it("haengt Nummern an, bis der Slug frei ist", async () => {
const belegt = new Set(["backup", "backup-2"]);
expect(await freierSlug("backup", async (s) => !belegt.has(s))).toBe("backup-3");
const lang = "a".repeat(64);
const ergebnis = await freierSlug(lang, async (s) => s !== lang);
expect(ergebnis).toHaveLength(64);
expect(ergebnis.endsWith("-2")).toBe(true);
});
it("kuerzt Fingerprints und zeigt Typen", () => {
expect(fingerprintKurz("SHA256:q3vN8xP0b4Tz7KfL2mWcR9yHdE1sJ6uAoGi5VnBQe0k")).toBe(
"SHA256:q3vN8xP0…Qe0k",
);
expect(typAnzeige("ED25519", "ssh-ed25519 AAAA")).toBe("ed25519");
expect(typAnzeige("RSA4096", "ssh-rsa AAAA")).toBe("rsa-4096");
expect(typAnzeige("ANDERER", "ecdsa-sha2-nistp256 AAAA")).toBe("ecdsa-sha2-nistp256");
expect(typAnzeige("ANDERER", "sk-ssh-ed25519@openssh.com AAAA")).toBe("sk-ssh-ed25519");
});
it("bewertet das Ablaufdatum", () => {
const jetzt = new Date("2026-10-12T08:00:00Z");
expect(ablaufZustand(null, jetzt).art).toBe("keiner");
expect(ablaufZustand(new Date("2026-10-30T00:00:00Z"), jetzt)).toEqual({ art: "bald", tage: 18 });
expect(ablaufZustand(new Date("2026-11-12T00:00:00Z"), jetzt).art).toBe("spaeter");
expect(ablaufZustand(new Date("2026-10-11T00:00:00Z"), jetzt).art).toBe("abgelaufen");
});
it("prueft Eingaben", () => {
expect(erzeugenSchema.safeParse({ name: "" }).success).toBe(false);
expect(erzeugenSchema.safeParse({ name: "x", slug: "Mit Leer" }).success).toBe(false);
expect(erzeugenSchema.safeParse({ name: "x", kommentar: "a\nb" }).success).toBe(false);
expect(erzeugenSchema.safeParse({ name: "x", ablaufAm: "2026-02-30" }).success).toBe(false);
expect(erzeugenSchema.safeParse({ name: "x", typ: "DSA" }).success).toBe(false);
const ok = erzeugenSchema.parse({ name: " Backup ", tags: "PVE, pve backup", ablaufAm: "2030-01-31" });
expect(ok).toMatchObject({ name: "Backup", tags: ["pve", "backup"], typ: "ED25519", slug: null });
expect(ok.ablaufAm?.toISOString()).toBe("2030-01-31T00:00:00.000Z");
});
});
describe("Public Key importieren", () => {
it("legt an, setzt Slug und Kommentar und protokolliert", async () => {
const blob = ed25519Blob();
const neu = await publicKeyImportieren(
{
name: "Laptop Florian",
kategorie: "PERSON",
publicKey: zeile("ssh-ed25519", blob, "florian@laptop"),
},
akteur,
);
const s = await schluesselDetail(neu.id);
expect(s).toMatchObject({
slug: "laptop-florian",
typ: "ED25519",
kategorie: "PERSON",
kommentar: "florian@laptop",
privatGespeichert: false,
selbstHinterlegt: false,
});
expect(s?.publicKey).toBe(zeile("ssh-ed25519", blob, "florian@laptop"));
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "schluessel.importiert" } });
expect(eintrag.fingerprint).toBe(s?.fingerprint);
expect(eintrag.akteurName).toBe("Olga Operator");
});
it("meldet einen schon verwalteten Key mit Verweis", async () => {
const z = zeile("ssh-ed25519", ed25519Blob());
const erster = await publicKeyImportieren({ name: "Erster", publicKey: z }, akteur);
const fehler = await fachfehlerVon(publicKeyImportieren({ name: "Zweiter", publicKey: z }, akteur));
expect(fehler.message).toContain("„Erster“");
expect(fehler.message).toContain(`/schluessel/${erster.id}`);
expect(fehler.feldfehler["publicKey"]).toBeDefined();
});
it("nummeriert abgeleitete Slugs und lehnt vergebene Wunsch-Slugs ab", async () => {
const a = await publicKeyImportieren(
{ name: "Backup", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
akteur,
);
const b = await publicKeyImportieren(
{ name: "Backup", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
akteur,
);
expect([a.slug, b.slug]).toEqual(["backup", "backup-2"]);
const fehler = await fachfehlerVon(
publicKeyImportieren(
{ name: "X", slug: "backup", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
akteur,
),
);
expect(fehler.feldfehler["slug"]).toMatch(/vergeben/);
});
it("verlangt RSA mit mindestens 2048 Bit und erkennt RSA-4096", async () => {
const fehler = await fachfehlerVon(
publicKeyImportieren({ name: "Alt", publicKey: zeile("ssh-rsa", rsaBlob(1024)) }, akteur),
);
expect(fehler.feldfehler["publicKey"]).toMatch(/2048/);
const neu = await publicKeyImportieren(
{ name: "Neu", publicKey: zeile("ssh-rsa", rsaBlob(4096)) },
akteur,
);
expect((await schluesselDetail(neu.id))?.typ).toBe("RSA4096");
const mittel = await publicKeyImportieren(
{ name: "Mittel", publicKey: zeile("ssh-rsa", rsaBlob(3072)) },
akteur,
);
expect((await schluesselDetail(mittel.id))?.typ).toBe("ANDERER");
});
it("lehnt Unsinn, Vergangenheit und unbekannte Bezuege ab", async () => {
await fachfehlerVon(publicKeyImportieren({ name: "X", publicKey: "kein key" }, akteur));
const vergangen = await fachfehlerVon(
publicKeyImportieren(
{ name: "X", publicKey: zeile("ssh-ed25519", ed25519Blob()), ablaufAm: "2020-01-01" },
akteur,
),
);
expect(vergangen.feldfehler["ablaufAm"]).toBeDefined();
const person = await fachfehlerVon(
publicKeyImportieren(
{ name: "X", publicKey: zeile("ssh-ed25519", ed25519Blob()), personId: "gibtsnicht" },
akteur,
),
);
expect(person.feldfehler["personId"]).toBeDefined();
expect(await prisma.schluessel.count()).toBe(0);
});
it("setzt das Ablaufdatum aus der Laufzeit der Vorlage", async () => {
const vorlage = await prisma.vorlage.create({
data: { name: "Kurz", zielbenutzer: "deploy", standardLaufzeitTage: 10 },
});
const neu = await publicKeyImportieren(
{ name: "Mit Vorlage", publicKey: zeile("ssh-ed25519", ed25519Blob()), vorlageId: vorlage.id },
akteur,
);
const s = await schluesselDetail(neu.id);
expect(ablaufZustand(s!.ablaufAm)).toEqual({ art: "bald", tage: 10 });
expect(s?.vorlage?.name).toBe("Kurz");
});
});
describe("Erzeugen im Worker", () => {
it("erzeugt ed25519, versiegelt und verweist den Vorgang auf den Schluessel", async () => {
const vorgangId = await erzeugenAnfordern(
{ name: "Backup srv-backup01", tags: "backup" },
undefined,
akteur,
);
const { jobDaten } = await vorgangImWorker(vorgangId);
expect(jobDaten.geheim).toBeUndefined();
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
expect(vorgang.status).toBe("ERLEDIGT");
const { schluesselId, fingerprint } = vorgang.ergebnis as { schluesselId: string; fingerprint: string };
expect(vorgang.schluesselId).toBe(schluesselId);
const s = await schluesselDetail(schluesselId);
expect(s).toMatchObject({
slug: "backup-srv-backup01",
kommentar: "sshkv:backup-srv-backup01",
kategorie: "DIENST",
typ: "ED25519",
privatGespeichert: true,
passphraseGeschuetzt: false,
fingerprint,
tags: ["backup"],
});
expect(s?.erstelltVon?.name).toBe("Olga Operator");
const privat = await privateKeyLaden(schluesselId);
expect(privateKeyLesen(privat.toString("utf8")).fingerprint).toBe(fingerprint);
privat.fill(0);
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "schluessel.erzeugt" } });
expect(eintrag).toMatchObject({ fingerprint, akteurId: akteur.id, ergebnis: "ERFOLG" });
});
it("speichert mit Passphrase den geschuetzten Key – die Passphrase nirgends", async () => {
const passphrase = "korrekt-pferd-batterie-heftklammer";
const vorgangId = await erzeugenAnfordern({ name: "Geschuetzt", typ: "ED25519" }, passphrase, akteur);
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
expect(JSON.stringify(vorgang.eingabe)).not.toContain(passphrase);
expect(vorgang.eingabe).toMatchObject({ mitPassphrase: true });
const { jobDaten } = await vorgangImWorker(vorgangId);
expect(jobDaten.geheim).toBeDefined();
expect(jobDaten.geheim).not.toContain(passphrase);
expect(JSON.parse(entschluesseln(jobDaten.geheim!))).toEqual({ passphrase });
const { schluesselId } = (await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } }))
.ergebnis as { schluesselId: string };
expect((await schluesselDetail(schluesselId))?.passphraseGeschuetzt).toBe(true);
const privat = (await privateKeyLaden(schluesselId)).toString("utf8");
expect(() => privateKeyLesen(privat)).toThrow(/Passphrase/);
expect(privateKeyLesen(privat, passphrase).verschluesselt).toBe(true);
const alles = await datenbankAlsText();
expect(alles).not.toContain(passphrase);
expect(alles).not.toContain("PRIVATE KEY");
});
it("erzeugt RSA-4096", { timeout: 30_000 }, async () => {
const vorgangId = await erzeugenAnfordern({ name: "Alt-System", typ: "RSA4096" }, undefined, akteur);
await vorgangImWorker(vorgangId);
const { schluesselId } = (await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } }))
.ergebnis as { schluesselId: string };
const s = await schluesselDetail(schluesselId);
expect(s?.typ).toBe("RSA4096");
expect(s?.publicKey.startsWith("ssh-rsa ")).toBe(true);
});
it("scheitert verstaendlich, wenn der Slug inzwischen vergeben ist", async () => {
const vorgangId = await erzeugenAnfordern({ name: "Doppelt" }, undefined, akteur);
await publicKeyImportieren(
{ name: "Doppelt", slug: "doppelt", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
akteur,
);
await vorgangImWorker(vorgangId);
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
expect(vorgang.status).toBe("FEHLGESCHLAGEN");
expect(vorgang.fehler).toMatch(/inzwischen vergeben/);
});
});
describe("Paar importieren im Worker", () => {
it("prueft, versiegelt und laesst weder Vorgang noch Protokoll den Key sehen", async () => {
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "alt@server" });
const vorgangId = await paarImportAnfordern(
{ name: "Altlast" },
paar.privateKeyOpenssh,
undefined,
akteur,
);
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
expect(JSON.stringify(vorgang)).not.toContain("PRIVATE KEY");
const { jobDaten } = await vorgangImWorker(vorgangId);
expect(jobDaten.geheim).not.toContain(kern(paar.privateKeyOpenssh));
const fertig = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
expect(fertig.status).toBe("ERLEDIGT");
const { schluesselId } = fertig.ergebnis as { schluesselId: string };
const s = await schluesselDetail(schluesselId);
expect(s).toMatchObject({
fingerprint: paar.fingerprint,
kommentar: "alt@server",
privatGespeichert: true,
});
expect((await privateKeyLaden(schluesselId)).toString("utf8")).toBe(paar.privateKeyOpenssh);
const alles = await datenbankAlsText();
expect(alles).not.toContain("PRIVATE KEY");
expect(alles).not.toContain(kern(paar.privateKeyOpenssh));
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.importiert" } })).toBe(1);
});
it("behaelt einen passphrasengeschuetzten Key so, wie er kam", async () => {
const passphrase = "nur-zur-pruefung-1234";
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "x", passphrase });
const ohne = await paarImportAnfordern({ name: "Ohne" }, paar.privateKeyOpenssh, undefined, akteur);
await vorgangImWorker(ohne);
expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: ohne } })).fehler).toMatch(/Passphrase/);
const falsch = await paarImportAnfordern(
{ name: "Falsch" },
paar.privateKeyOpenssh,
"Zz9-unpassend",
akteur,
);
await vorgangImWorker(falsch);
const fehler = (await prisma.vorgang.findUniqueOrThrow({ where: { id: falsch } })).fehler ?? "";
expect(fehler).toMatch(/Passphrase/);
expect(fehler).not.toContain("Zz9-unpassend");
const richtig = await paarImportAnfordern(
{ name: "Richtig" },
paar.privateKeyOpenssh,
passphrase,
akteur,
);
await vorgangImWorker(richtig);
const { schluesselId } = (await prisma.vorgang.findUniqueOrThrow({ where: { id: richtig } }))
.ergebnis as {
schluesselId: string;
};
expect((await schluesselDetail(schluesselId))?.passphraseGeschuetzt).toBe(true);
expect((await privateKeyLaden(schluesselId)).toString("utf8")).toBe(paar.privateKeyOpenssh);
expect(await datenbankAlsText()).not.toContain(passphrase);
});
it("lehnt Fremdformate im Web und Duplikate im Worker ab", async () => {
const pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIE\n-----END RSA PRIVATE KEY-----";
const fehler = await fachfehlerVon(paarImportAnfordern({ name: "PEM" }, pem, undefined, akteur));
expect(fehler.message).not.toContain("MIIE");
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "x" });
await publicKeyImportieren({ name: "Schon da", publicKey: paar.publicKeyZeile }, akteur);
const id = await paarImportAnfordern({ name: "Nochmal" }, paar.privateKeyOpenssh, undefined, akteur);
await vorgangImWorker(id);
expect((await prisma.vorgang.findUniqueOrThrow({ where: { id } })).fehler).toMatch(/„Schon da“/);
});
});
describe("Bearbeiten und Lesemodell", () => {
it("aendert Metadaten, protokolliert nur Geaendertes und schuetzt den Status", async () => {
const neu = await publicKeyImportieren(
{ name: "Alt", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
akteur,
);
await schluesselBearbeiten(neu.id, { name: "Neu", tags: "a b", status: "ABGELAUFEN" }, akteur);
const s = await schluesselDetail(neu.id);
expect(s).toMatchObject({ name: "Neu", tags: ["a", "b"], status: "ABGELAUFEN", slug: "alt" });
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "schluessel.geaendert" } });
expect(Object.keys(eintrag.details as object).sort()).toEqual(["name", "status", "tags"]);
await prisma.schluessel.update({
where: { id: neu.id },
data: { status: "WIDERRUFEN" },
select: { id: true },
});
await fachfehlerVon(schluesselBearbeiten(neu.id, { name: "Neu", status: "AKTIV" }, akteur));
});
it("filtert die Liste und liefert keine Chiffrat-Felder", async () => {
await publicKeyImportieren(
{
name: "Person A",
kategorie: "PERSON",
publicKey: zeile("ssh-ed25519", ed25519Blob()),
tags: "laptop",
},
akteur,
);
const vorgangId = await erzeugenAnfordern({ name: "Dienst B" }, undefined, akteur);
await vorgangImWorker(vorgangId);
expect((await schluesselListe()).map((s) => s.name)).toEqual(["Dienst B", "Person A"]);
expect((await schluesselListe({ kategorie: "PERSON" })).map((s) => s.name)).toEqual(["Person A"]);
expect((await schluesselListe({ q: "laptop" })).map((s) => s.name)).toEqual(["Person A"]);
expect((await schluesselListe({ status: "WIDERRUFEN" })).length).toBe(0);
expect((await schluesselListe({ kategorie: "QUATSCH" })).length).toBe(2);
const liste = await schluesselListe();
const dienst = liste.find((s) => s.name === "Dienst B")!;
expect(dienst.privatGespeichert).toBe(true);
const detail = await schluesselDetail(dienst.id);
for (const antwort of [liste, detail]) {
const text = JSON.stringify(antwort);
expect(text).not.toMatch(/privatVerschluesselt|dekVerschluesselt|masterKeyVersion/);
}
});
});