munin/internal/store/workspace_admins.go

191 lines
6.4 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
// WorkspaceAdmin ist ein Zugang, der in genau einem Workspace Projekte und
// deren Keys verwalten darf (AUSBAUSTUFEN.md 4.8).
//
// Er lebt in der Kontroll-Datenbank, nicht in der Workspace-Datei — siehe
// migrations_control/002_workspace_admins.sql, warum.
type WorkspaceAdmin struct {
ID string
WorkspaceID string
Name string
Hash string
CreatedAt time.Time
LastUsedAt time.Time // Nullzeitpunkt = noch nie benutzt
RevokedAt time.Time // Nullzeitpunkt = aktiv
}
// Revoked sagt, ob der Zugang zurückgezogen wurde.
func (a WorkspaceAdmin) Revoked() bool {
return !a.RevokedAt.IsZero()
}
// Unused sagt, ob ein aktiver Zugang seit mehr als UnusedAfter nicht benutzt
// wurde — dieselbe Regel wie bei APIKey.Unused.
//
// Bei einem Workspace-Admin wiegt das schwerer als bei einem Projekt-Key: Ein
// vergessenes Token öffnet nicht ein Projekt, sondern den ganzen Workspace.
func (a WorkspaceAdmin) Unused(jetzt time.Time) bool {
return ungenutzt(a.Revoked(), a.LastUsedAt, a.CreatedAt, jetzt)
}
// workspaceAdminColumns steht in jeder Abfrage auf workspace_admins.
const workspaceAdminColumns = `id, workspace_id, name, hash, created_at, last_used_at, revoked_at`
// scanWorkspaceAdmin liest eine Zeile aus workspace_admins.
func scanWorkspaceAdmin(scan func(...any) error) (WorkspaceAdmin, error) {
var a WorkspaceAdmin
var createdAt, lastUsedAt, revokedAt string
if err := scan(&a.ID, &a.WorkspaceID, &a.Name, &a.Hash,
&createdAt, &lastUsedAt, &revokedAt); err != nil {
return WorkspaceAdmin{}, err
}
a.CreatedAt = parseTime(createdAt)
a.LastUsedAt = parseTime(lastUsedAt)
a.RevokedAt = parseTime(revokedAt)
return a, nil
}
// CreateWorkspaceAdmin legt einen Workspace-Admin-Zugang an. Übergeben wird der
// Hash, nie der Klartext — wie bei CreateAPIKey.
func (c *ControlStore) CreateWorkspaceAdmin(ctx context.Context, workspaceID, name, hash string) (WorkspaceAdmin, error) {
name = strings.TrimSpace(name)
if name == "" {
return WorkspaceAdmin{}, fmt.Errorf("der Zugang braucht einen Namen — daran erkennt man im Protokoll, wer etwas getan hat")
}
a := WorkspaceAdmin{
ID: NewID(),
WorkspaceID: workspaceID,
Name: name,
Hash: hash,
}
jetzt := NowString()
if _, err := c.schreiben.ExecContext(ctx,
`INSERT INTO workspace_admins (id, workspace_id, name, hash, created_at)
VALUES (?, ?, ?, ?, ?)`,
a.ID, a.WorkspaceID, a.Name, a.Hash, jetzt,
); err != nil {
return WorkspaceAdmin{}, fmt.Errorf("Workspace-Admin konnte nicht angelegt werden: %w", err)
}
a.CreatedAt = parseTime(jetzt)
return a, nil
}
// ResolveWorkspaceAdminHash schlägt einen aktiven Zugang anhand seines Hashes
// nach — der Weg beim Anmelden.
//
// Zurückgezogene Zugänge gelten als nicht vorhanden, wie bei ResolveKeyHash.
func (c *ControlStore) ResolveWorkspaceAdminHash(ctx context.Context, hash string) (WorkspaceAdmin, error) {
row := c.lesen.QueryRowContext(ctx,
`SELECT `+workspaceAdminColumns+` FROM workspace_admins WHERE hash = ? AND revoked_at = ''`, hash)
a, err := scanWorkspaceAdmin(row.Scan)
if errors.Is(err, sql.ErrNoRows) {
return WorkspaceAdmin{}, ErrNotFound
}
if err != nil {
return WorkspaceAdmin{}, fmt.Errorf("Workspace-Admin konnte nicht aufgelöst werden: %w", err)
}
return a, nil
}
// WorkspaceAdminByID liest einen Zugang anhand seiner ID — auch einen
// zurückgezogenen. Ob er noch gilt, entscheidet der Aufrufer über Revoked().
func (c *ControlStore) WorkspaceAdminByID(ctx context.Context, id string) (WorkspaceAdmin, error) {
row := c.lesen.QueryRowContext(ctx,
`SELECT `+workspaceAdminColumns+` FROM workspace_admins WHERE id = ?`, id)
a, err := scanWorkspaceAdmin(row.Scan)
if errors.Is(err, sql.ErrNoRows) {
return WorkspaceAdmin{}, ErrNotFound
}
if err != nil {
return WorkspaceAdmin{}, fmt.Errorf("Workspace-Admin konnte nicht gelesen werden: %w", err)
}
return a, nil
}
// ListWorkspaceAdmins gibt alle Zugänge eines Workspace zurück, neueste zuerst.
// Zurückgezogene sind dabei, damit die Verwaltung sie noch anzeigen kann.
func (c *ControlStore) ListWorkspaceAdmins(ctx context.Context, workspaceID string) ([]WorkspaceAdmin, error) {
rows, err := c.lesen.QueryContext(ctx,
`SELECT `+workspaceAdminColumns+` FROM workspace_admins
WHERE workspace_id = ? ORDER BY created_at DESC`, workspaceID)
if err != nil {
return nil, fmt.Errorf("Workspace-Admins konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var admins []WorkspaceAdmin
for rows.Next() {
a, err := scanWorkspaceAdmin(rows.Scan)
if err != nil {
return nil, err
}
admins = append(admins, a)
}
return admins, rows.Err()
}
// ListActiveWorkspaceAdmins gibt die aktiven Zugänge ALLER Workspaces zurück —
// für die Übersicht des Plattform-Admins, in einer Abfrage statt einer je
// Workspace.
func (c *ControlStore) ListActiveWorkspaceAdmins(ctx context.Context) ([]WorkspaceAdmin, error) {
rows, err := c.lesen.QueryContext(ctx,
`SELECT `+workspaceAdminColumns+` FROM workspace_admins WHERE revoked_at = ''`)
if err != nil {
return nil, fmt.Errorf("Workspace-Admins konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var admins []WorkspaceAdmin
for rows.Next() {
a, err := scanWorkspaceAdmin(rows.Scan)
if err != nil {
return nil, err
}
admins = append(admins, a)
}
return admins, rows.Err()
}
// RevokeWorkspaceAdmin zieht einen Zugang zurück.
//
// Die Workspace-ID steht mit in der Bedingung: Eine untergeschobene ID aus
// einem anderen Workspace trifft dann keine Zeile und endet als ErrNotFound.
func (c *ControlStore) RevokeWorkspaceAdmin(ctx context.Context, workspaceID, id string) error {
res, err := c.schreiben.ExecContext(ctx,
`UPDATE workspace_admins SET revoked_at = ?
WHERE id = ? AND workspace_id = ? AND revoked_at = ''`,
NowString(), id, workspaceID,
)
if err != nil {
return fmt.Errorf("Workspace-Admin konnte nicht zurückgezogen werden: %w", err)
}
return requireOneRow(res)
}
// TouchWorkspaceAdmin vermerkt, dass ein Zugang gerade benutzt wurde —
// höchstens einmal pro Stunde, aus demselben Grund wie bei TouchAPIKey.
func (c *ControlStore) TouchWorkspaceAdmin(ctx context.Context, id string, lastUsed time.Time) error {
if time.Since(lastUsed) < time.Hour {
return nil
}
_, err := c.schreiben.ExecContext(ctx,
`UPDATE workspace_admins SET last_used_at = ? WHERE id = ?`, NowString(), id)
return err
}