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:
parent
466f1e40af
commit
c4a1941e7f
8 changed files with 142 additions and 4 deletions
|
|
@ -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**
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,15 @@
|
|||
</div>
|
||||
{{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}}
|
||||
<div class="tabelle-huelle">
|
||||
<table class="tabelle">
|
||||
|
|
@ -68,14 +77,17 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{$csrf := .CSRFToken}}{{$slug := .Project.Slug}}
|
||||
{{$csrf := .CSRFToken}}{{$slug := .Project.Slug}}{{$jetzt := .Data.Jetzt}}
|
||||
{{range .Data.Keys}}
|
||||
<tr {{if .Revoked}}class="gedaempft"{{end}}>
|
||||
<td>{{.Name}}</td>
|
||||
<td><code>{{.Role}}</code></td>
|
||||
<td>{{datum .CreatedAt}}</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">
|
||||
{{if not .Revoked}}
|
||||
<form method="POST" action="/p/{{$slug}}/keys/revoke" class="reihe"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue