Ungenutzte Zugänge markieren (Ausbaustufe 4.7, Stufe 1)

Aktive Keys, die seit über 90 Tagen nicht benutzt wurden (nie benutzt:
gemessen ab Anlegen), tragen unter Zugänge die Marke „ungenutzt“; über der
Tabelle steht ein Hinweis mit der Anzahl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Adamek 2026-09-25 07:46:35 +02:00
parent 466f1e40af
commit c4a1941e7f
8 changed files with 142 additions and 4 deletions

View file

@ -648,7 +648,7 @@ unterscheiden.
„keine“ als deutlichem Signal) und *Zuletzt geändert*. Die Eintragszahl „keine“ als deutlichem Signal) und *Zuletzt geändert*. Die Eintragszahl
kommt jetzt aus derselben Abfrage statt aus einem `CountEntries` je Projekt. 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 `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, 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 zurückgezogene. Braucht eine Migration (`expires_at`) und eine Prüfung in
`ResolveKeyHash`. Mittel. `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 ### 4.8 Admin je Workspace
Heute legt nur der Plattform-Admin (`ADMIN_TOKEN`) Projekte an. Bei 10–40 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. rückgängig machen lässt.~~ — erledigt 2026-09-24.
5. ~~**Nutzungsübersicht** (4.6): zeigt, ob die Teams Munin wirklich benutzen, 5. ~~**Nutzungsübersicht** (4.6): zeigt, ob die Teams Munin wirklich benutzen,
und liefert die Zahlen für Punkt 7.~~ — erledigt 2026-09-24. 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** **Bewusst entscheiden, nicht nebenbei**

View file

@ -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 Browser-Sitzungen mit diesem Key enden. Im Protokoll bleibt er mit seinem Namen
erhalten, damit sich weiterhin nachvollziehen lässt, was er geschrieben hat. 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? ## Welche Rolle für wen?
| Wer | Empfohlene Rolle | | Wer | Empfohlene Rolle |

View file

@ -66,6 +66,33 @@ func (k APIKey) Revoked() bool {
return !k.RevokedAt.IsZero() 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. // Entry ist die einzige Wissenseinheit — von Menschen wie von Agenten geschrieben.
type Entry struct { type Entry struct {
ID string ID string

View file

@ -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) { func TestCreateAPIKeyRequiresName(t *testing.T) {
s := testStore(t) s := testStore(t)
p := testProject(t, s) p := testProject(t, s)

View file

@ -344,6 +344,14 @@ type keyListData struct {
// weil der Key nur dieses eine Mal zu sehen ist und der Block ohne // weil der Key nur dieses eine Mal zu sehen ist und der Block ohne
// Nacharbeit kopierbar sein soll. // Nacharbeit kopierbar sein soll.
ServerURL string 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. // 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.Keys = keys
daten.ServerURL = serverURL(r) 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{ s.render(w, r, "keys.html", status, pageData{
Title: "Zugänge – " + project.Name, Title: "Zugänge – " + project.Name,

View file

@ -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 ──────────────────────────────────────────── // ── Sicherheits-Header und Limits ────────────────────────────────────────────
func TestSecurityHeaders(t *testing.T) { func TestSecurityHeaders(t *testing.T) {

View file

@ -387,6 +387,12 @@ a.marke:hover {
border-color: transparent; border-color: transparent;
color: var(--akzent-text); 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. */ /* Metazeile unter einem Titel: Marken und Kleintext in einer Reihe. */
.meta { .meta {

View file

@ -54,6 +54,15 @@
</div> </div>
{{end}} {{end}}
{{if .Data.Ungenutzt}}
<div class="meldung fehler">
{{if eq .Data.Ungenutzt 1}}Ein aktiver Zugang wurde{{else}}{{.Data.Ungenutzt}} aktive Zugänge wurden{{end}}
seit über 90 Tagen nicht benutzt. Jeder davon ist ein offener Zugang zu diesem
Projekt. Wenn niemand mehr weiß, wofür ein Key da ist, ist Zurückziehen meist
die richtige Wahl — ein neuer ist schnell angelegt.
</div>
{{end}}
{{if .Data.Keys}} {{if .Data.Keys}}
<div class="tabelle-huelle"> <div class="tabelle-huelle">
<table class="tabelle"> <table class="tabelle">
@ -68,14 +77,17 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{$csrf := .CSRFToken}}{{$slug := .Project.Slug}} {{$csrf := .CSRFToken}}{{$slug := .Project.Slug}}{{$jetzt := .Data.Jetzt}}
{{range .Data.Keys}} {{range .Data.Keys}}
<tr {{if .Revoked}}class="gedaempft"{{end}}> <tr {{if .Revoked}}class="gedaempft"{{end}}>
<td>{{.Name}}</td> <td>{{.Name}}</td>
<td><code>{{.Role}}</code></td> <td><code>{{.Role}}</code></td>
<td>{{datum .CreatedAt}}</td> <td>{{datum .CreatedAt}}</td>
<td>{{if .LastUsedAt.IsZero}}<span class="hilfe">nie</span>{{else}}{{datum .LastUsedAt}}{{end}}</td> <td>{{if .LastUsedAt.IsZero}}<span class="hilfe">nie</span>{{else}}{{datum .LastUsedAt}}{{end}}</td>
<td>{{if .Revoked}}zurückgezogen{{else}}aktiv{{end}}</td> <td>
{{if .Revoked}}zurückgezogen{{else}}aktiv{{end}}
{{if .Unused $jetzt}}<span class="marke warnung" title="Seit über 90 Tagen nicht benutzt">ungenutzt</span>{{end}}
</td>
<td class="schmal rechts"> <td class="schmal rechts">
{{if not .Revoked}} {{if not .Revoked}}
<form method="POST" action="/p/{{$slug}}/keys/revoke" class="reihe" <form method="POST" action="/p/{{$slug}}/keys/revoke" class="reihe"