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 }