191 lines
6.4 KiB
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(now time.Time) bool {
|
|
return unused(a.Revoked(), a.LastUsedAt, a.CreatedAt, now)
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
now := NowString()
|
|
|
|
if _, err := c.write.ExecContext(ctx,
|
|
`INSERT INTO workspace_admins (id, workspace_id, name, hash, created_at)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
a.ID, a.WorkspaceID, a.Name, a.Hash, now,
|
|
); err != nil {
|
|
return WorkspaceAdmin{}, fmt.Errorf("Workspace-Admin konnte nicht angelegt werden: %w", err)
|
|
}
|
|
|
|
a.CreatedAt = parseTime(now)
|
|
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.read.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.read.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.read.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.read.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.write.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.write.ExecContext(ctx,
|
|
`UPDATE workspace_admins SET last_used_at = ? WHERE id = ?`, NowString(), id)
|
|
return err
|
|
}
|