diff --git a/AUSBAUSTUFEN.md b/AUSBAUSTUFEN.md index cb42d18..ee6d107 100644 --- a/AUSBAUSTUFEN.md +++ b/AUSBAUSTUFEN.md @@ -648,7 +648,7 @@ unterscheiden. „keine“ als deutlichem Signal) und *Zuletzt geändert*. Die Eintragszahl kommt jetzt aus derselben Abfrage statt aus einem `CountEntries` je Projekt. -### 4.7 Ungenutzte Zugänge sichtbar machen +### 4.7 Ungenutzte Zugänge sichtbar machen — Stufe 1 ✅ umgesetzt `last_used_at` wird schon erfasst. Bei 10–40 Teams sammeln sich trotzdem Keys an, die niemand mehr benutzt, etwa von Personen, die gewechselt haben, @@ -661,6 +661,21 @@ oder von abgeschalteten Pipelines. Jeder davon ist ein offener Zugang. zurückgezogene. Braucht eine Migration (`expires_at`) und eine Prüfung in `ResolveKeyHash`. Mittel. +**✅ Stufe 1 umgesetzt 2026-09-25:** + +- **Regel:** `APIKey.Unused` in `internal/store/models.go`. Ein aktiver Key + gilt als ungenutzt, wenn er seit mehr als 90 Tagen (`UnusedAfter`) nicht + benutzt wurde. Wurde er noch nie benutzt, zählt das Anlegedatum. Sonst stünde + jeder frische Key sofort als ungenutzt da. Zurückgezogene Keys sind nie + ungenutzt. +- **Oberfläche:** Unter *Zugänge* trägt so ein Key die Marke *ungenutzt*, über + der Tabelle steht ein Hinweis mit der Anzahl. Den Knopf zum Zurückziehen gab + es schon. Keine Migration, `last_used_at` reicht. +- **Unschärfe:** `TouchAPIKey` schreibt höchstens einmal pro Stunde. Für eine + Grenze von 90 Tagen spielt das keine Rolle. +- **Tests:** `TestAPIKeyUnused` (Grenzfälle), `TestKeyListFreshKeyNotUnused`. +- **Offen:** Stufe 2 (Ablaufdatum). + ### 4.8 Admin je Workspace Heute legt nur der Plattform-Admin (`ADMIN_TOKEN`) Projekte an. Bei 10–40 @@ -874,7 +889,8 @@ Als Nächstes: rückgängig machen lässt.~~ — erledigt 2026-09-24. 5. ~~**Nutzungsübersicht** (4.6): zeigt, ob die Teams Munin wirklich benutzen, und liefert die Zahlen für Punkt 7.~~ — erledigt 2026-09-24. -6. **Ungenutzte Zugänge** (4.7), zunächst nur Stufe 1. +6. ~~**Ungenutzte Zugänge** (4.7), zunächst nur Stufe 1.~~ — erledigt + 2026-09-25. Stufe 2 (Ablaufdatum) erst, wenn Stufe 1 nicht reicht. **Bewusst entscheiden, nicht nebenbei** diff --git a/internal/hilfe/seiten/zugaenge.md b/internal/hilfe/seiten/zugaenge.md index fb06383..a35875e 100644 --- a/internal/hilfe/seiten/zugaenge.md +++ b/internal/hilfe/seiten/zugaenge.md @@ -16,6 +16,14 @@ Dann einfach einen neuen anlegen und den alten zurückziehen. Browser-Sitzungen mit diesem Key enden. Im Protokoll bleibt er mit seinem Namen erhalten, damit sich weiterhin nachvollziehen lässt, was er geschrieben hat. +**Ungenutzte Keys:** Ein aktiver Key, der seit über 90 Tagen nicht benutzt +wurde, trägt die Marke *ungenutzt*. Über der Tabelle steht dann ein Hinweis. +Wurde ein Key noch nie benutzt, zählt das Anlegedatum. Ein frisch angelegter +Key ist also nicht sofort markiert. Solche Keys stammen oft von Personen, die +das Team gewechselt haben, oder von abgeschalteten Pipelines. Jeder davon ist +ein offener Zugang. Wenn niemand mehr weiß, wofür er da ist, am besten +zurückziehen. + ## Welche Rolle für wen? | Wer | Empfohlene Rolle | diff --git a/internal/store/models.go b/internal/store/models.go index 34694fd..044835f 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -66,6 +66,33 @@ func (k APIKey) Revoked() bool { return !k.RevokedAt.IsZero() } +// UnusedAfter ist die Zeitspanne, nach der ein aktiver Key ohne Benutzung als +// „ungenutzt“ gilt (siehe AUSBAUSTUFEN.md 4.7). +const UnusedAfter = 90 * 24 * time.Hour + +// Unused sagt, ob ein aktiver Key seit mehr als UnusedAfter nicht benutzt wurde. +// +// Wurde der Key noch nie benutzt, zählt stattdessen das Anlegedatum. Sonst +// stünde jeder frisch angelegte Key sofort als „ungenutzt“ da, obwohl sein +// Besitzer ihn vielleicht erst morgen einrichtet. Nach 90 Tagen ohne einen +// einzigen Aufruf ist dagegen klar: Den braucht niemand. +// +// Zurückgezogene Keys sind nie „ungenutzt“, denn sie sind ohnehin kein offener +// Zugang mehr. +// +// jetzt wird hereingereicht statt time.Now() zu rufen, damit Tests einen festen +// Zeitpunkt vorgeben können. +func (k APIKey) Unused(jetzt time.Time) bool { + if k.Revoked() { + return false + } + zuletzt := k.LastUsedAt + if zuletzt.IsZero() { + zuletzt = k.CreatedAt + } + return jetzt.Sub(zuletzt) > UnusedAfter +} + // Entry ist die einzige Wissenseinheit — von Menschen wie von Agenten geschrieben. type Entry struct { ID string diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 2de3562..86b4a47 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -418,6 +418,33 @@ func TestRevokedKeyIsGone(t *testing.T) { } } +// TestAPIKeyUnused prüft, wann ein Key als „ungenutzt“ gilt (AUSBAUSTUFEN.md 4.7). +func TestAPIKeyUnused(t *testing.T) { + jetzt := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC) + tage := func(n int) time.Time { return jetzt.Add(-time.Duration(n) * 24 * time.Hour) } + + faelle := []struct { + name string + key APIKey + erwartet bool + }{ + {"gestern benutzt", APIKey{CreatedAt: tage(400), LastUsedAt: tage(1)}, false}, + {"vor 91 Tagen benutzt", APIKey{CreatedAt: tage(400), LastUsedAt: tage(91)}, true}, + {"genau 90 Tage", APIKey{CreatedAt: tage(400), LastUsedAt: tage(90)}, false}, + {"nie benutzt, frisch angelegt", APIKey{CreatedAt: tage(3)}, false}, + {"nie benutzt, lange angelegt", APIKey{CreatedAt: tage(120)}, true}, + {"zurückgezogen", APIKey{CreatedAt: tage(400), LastUsedAt: tage(200), RevokedAt: tage(100)}, false}, + } + + for _, f := range faelle { + t.Run(f.name, func(t *testing.T) { + if got := f.key.Unused(jetzt); got != f.erwartet { + t.Errorf("Unused = %v, erwartet %v", got, f.erwartet) + } + }) + } +} + func TestCreateAPIKeyRequiresName(t *testing.T) { s := testStore(t) p := testProject(t, s) diff --git a/internal/web/handlers_admin.go b/internal/web/handlers_admin.go index bbd659c..3a41f28 100644 --- a/internal/web/handlers_admin.go +++ b/internal/web/handlers_admin.go @@ -344,6 +344,14 @@ type keyListData struct { // weil der Key nur dieses eine Mal zu sehen ist und der Block ohne // Nacharbeit kopierbar sein soll. ServerURL string + + // Jetzt ist der Bezugszeitpunkt für APIKey.Unused im Template. Einmal pro + // Seitenaufruf festgehalten, damit alle Zeilen mit demselben Wert rechnen. + Jetzt time.Time + + // Ungenutzt zählt die aktiven Keys, die seit über 90 Tagen niemand benutzt + // hat. Ist die Zahl größer als null, steht über der Tabelle ein Hinweis. + Ungenutzt int } // handleKeyList zeigt die Keys eines Projekts. @@ -442,6 +450,12 @@ func (s *Server) renderKeyList(w http.ResponseWriter, r *http.Request, status in } daten.Keys = keys daten.ServerURL = serverURL(r) + daten.Jetzt = time.Now() + for _, k := range keys { + if k.Unused(daten.Jetzt) { + daten.Ungenutzt++ + } + } s.render(w, r, "keys.html", status, pageData{ Title: "Zugänge – " + project.Name, diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 15b9923..11b5a61 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -720,6 +720,34 @@ func TestWriteRoleCannotManageKeys(t *testing.T) { } } +// TestKeyListFreshKeyNotUnused prüft, dass die Zugänge-Seite rendert und ein +// frisch angelegter, noch nie benutzter Key nicht als „ungenutzt“ erscheint. +// Die Grenzfälle der Regel selbst prüft TestAPIKeyUnused im Store. +func TestKeyListFreshKeyNotUnused(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + + admin := erzeugeKey(t, srv, db, projekt.ID, "verwalterin", auth.RoleAdmin) + erzeugeKey(t, srv, db, projekt.ID, "neue-pipeline", auth.RoleRead) + cookie := anmelden(t, h, admin) + + r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/keys", nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("Status = %d, erwartet %d", w.Code, http.StatusOK) + } + body := w.Body.String() + if !strings.Contains(body, "neue-pipeline") { + t.Error("der neue Key fehlt in der Liste") + } + if strings.Contains(body, "marke warnung") || strings.Contains(body, "seit über 90 Tagen nicht benutzt") { + t.Error("ein frisch angelegter Key wurde als ungenutzt markiert") + } +} + // ── Sicherheits-Header und Limits ──────────────────────────────────────────── func TestSecurityHeaders(t *testing.T) { diff --git a/static/style.css b/static/style.css index c2a0eb3..04b81b8 100644 --- a/static/style.css +++ b/static/style.css @@ -387,6 +387,12 @@ a.marke:hover { border-color: transparent; color: var(--akzent-text); } +/* Etwas, um das sich jemand kümmern sollte, etwa ein seit Langem ungenutzter Key. */ +.marke.warnung { + background: var(--warn-flach); + border-color: transparent; + color: var(--warn); +} /* Metazeile unter einem Titel: Marken und Kleintext in einer Reihe. */ .meta { diff --git a/templates/keys.html b/templates/keys.html index e725a96..d7f2f8d 100644 --- a/templates/keys.html +++ b/templates/keys.html @@ -54,6 +54,15 @@ {{end}} +{{if .Data.Ungenutzt}} +
| {{.Name}} | {{.Role}} |
{{datum .CreatedAt}} | {{if .LastUsedAt.IsZero}}nie{{else}}{{datum .LastUsedAt}}{{end}} | -{{if .Revoked}}zurückgezogen{{else}}aktiv{{end}} | ++ {{if .Revoked}}zurückgezogen{{else}}aktiv{{end}} + {{if .Unused $jetzt}}ungenutzt{{end}} + | {{if not .Revoked}} |