morz-infoboard/docs/superpowers/plans/2026-03-27-restricted-rolle.md
Jesko Anschütz e0ea7f0bde docs: Implementierungsplan für restricted-Rolle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 21:26:54 +01:00

22 KiB
Raw Blame History

Restricted-Rolle Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Neue Rolle "restricted" einführen — Nutzer dürfen Medien hochladen und Playlist bearbeiten, aber keine Anzeige-Steuerung (An/Aus, Zeitplan, Override) vornehmen.

Architecture: Neuer Rollenwert im bestehenden users.role-String-Feld. Neue Middleware RequireNotRestricted blockt restricted-User mit 403 auf Steuerungs-Endpunkten. Templates erhalten UserRole string und blenden Steuerungs-UI per {{if ne .UserRole "restricted"}} aus.

Tech Stack: Go 1.25, net/http, html/template, pgx v5, Bulma CSS


Geänderte Dateien

Datei Änderung
server/backend/internal/httpapi/middleware.go RequireNotRestricted hinzufügen
server/backend/internal/httpapi/middleware_test.go NEU — Tests für RequireNotRestricted
server/backend/internal/store/auth.go CreateScreenUser bekommt role-Parameter; ListScreenUsers schließt restricted ein
server/backend/internal/httpapi/manage/ui.go HandleCreateScreenUser liest role aus Form; beide Handler übergeben UserRole ans Template
server/backend/internal/httpapi/router.go Neue Middleware-Kombinatoren authScreenControl + authOnlyControl; Steuerungs-Routen umverdrahten
server/backend/internal/httpapi/manage/templates.go Admin-Formular: Rolle-Dropdown; User-Liste: Rollen-Badge; Übersicht + Detailseite: UI-Guards

Task 1: RequireNotRestricted Middleware + Tests

Files:

  • Modify: server/backend/internal/httpapi/middleware.go

  • Create: server/backend/internal/httpapi/middleware_test.go

  • Step 1: Failing-Test schreiben

Erstelle server/backend/internal/httpapi/middleware_test.go:

package httpapi_test

import (
	"context"
	"net/http"
	"net/http/httptest"
	"testing"

	"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi"
	"git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext"
	"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)

func userCtx(role string) context.Context {
	return reqcontext.WithUser(context.Background(), &store.User{Role: role})
}

func TestRequireNotRestricted_blocks_restricted(t *testing.T) {
	req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(userCtx("restricted"))
	rr := httptest.NewRecorder()
	httpapi.RequireNotRestricted(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		t.Fatal("should not be called")
	})).ServeHTTP(rr, req)
	if rr.Code != http.StatusForbidden {
		t.Fatalf("expected 403, got %d", rr.Code)
	}
}

func TestRequireNotRestricted_allows_screen_user(t *testing.T) {
	req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(userCtx("screen_user"))
	rr := httptest.NewRecorder()
	called := false
	httpapi.RequireNotRestricted(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		called = true
	})).ServeHTTP(rr, req)
	if !called {
		t.Fatal("expected next to be called")
	}
}

func TestRequireNotRestricted_allows_admin(t *testing.T) {
	req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(userCtx("admin"))
	rr := httptest.NewRecorder()
	called := false
	httpapi.RequireNotRestricted(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		called = true
	})).ServeHTTP(rr, req)
	if !called {
		t.Fatal("expected next to be called")
	}
}

func TestRequireNotRestricted_allows_no_user(t *testing.T) {
	req := httptest.NewRequest(http.MethodPost, "/", nil)
	rr := httptest.NewRecorder()
	called := false
	httpapi.RequireNotRestricted(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		called = true
	})).ServeHTTP(rr, req)
	if !called {
		t.Fatal("no user in context — RequireAuth handles that, this middleware passes through")
	}
}
  • Step 2: Test laufen lassen — muss fehlschlagen
cd server/backend && go test ./internal/httpapi/ -run TestRequireNotRestricted -v

Erwartetes Ergebnis: Compile-Fehler undefined: httpapi.RequireNotRestricted

  • Step 3: Implementation in middleware.go hinzufügen

In server/backend/internal/httpapi/middleware.go direkt nach RequireAdmin (nach Zeile 56) einfügen:

// RequireNotRestricted is middleware that blocks users with role "restricted".
// It must be chained after RequireAuth (so a user is present in context).
// On failure it responds with 403 Forbidden.
func RequireNotRestricted(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		user := UserFromContext(r.Context())
		if user != nil && user.Role == "restricted" {
			http.Error(w, "Forbidden", http.StatusForbidden)
			return
		}
		next.ServeHTTP(w, r)
	})
}
  • Step 4: Tests laufen lassen — müssen bestehen
cd server/backend && go test ./internal/httpapi/ -run TestRequireNotRestricted -v

Erwartetes Ergebnis: 4 Tests PASS

  • Step 5: Commit
git add server/backend/internal/httpapi/middleware.go \
        server/backend/internal/httpapi/middleware_test.go
git commit -m "feat(auth): RequireNotRestricted middleware"

Task 2: Store — CreateScreenUser role-Parameter + ListScreenUsers Fix

Files:

  • Modify: server/backend/internal/store/auth.go

  • Step 1: Failing-Test schreiben

Es gibt noch keine Unit-Tests für den Store (Integration-Tests laufen gegen echte DB, die im CI nicht verfügbar ist). Stattdessen sichern wir die Signatur über den Compiler ab — wir schreiben zunächst den Test in einem separaten Compile-Check-Paket NICHT, sondern prüfen direkt via go build.

Ersetze CreateScreenUser in server/backend/internal/store/auth.go:

Aktuell (Zeile 162187):

func (s *AuthStore) CreateScreenUser(ctx context.Context, tenantSlug, username, password string) (*User, error) {
    ...
    row := s.pool.QueryRow(ctx,
        `insert into users(tenant_id, username, password_hash, role)
         values($1, $2, $3, 'screen_user')
         returning id, tenant_id, $4::text, username, password_hash, role, created_at`,
        tenantID, username, string(hash), tenantSlug)

Ersetze durch:

// CreateScreenUser creates a new user with the given role for the tenant
// identified by tenantSlug. role must be "screen_user" or "restricted".
// The password is hashed with bcrypt (cost 12).
// Returns pgx.ErrNoRows if the tenant does not exist, or a wrapped error if
// the username is already taken (unique constraint violation).
func (s *AuthStore) CreateScreenUser(ctx context.Context, tenantSlug, username, password, role string) (*User, error) {
	if role != "screen_user" && role != "restricted" {
		return nil, fmt.Errorf("auth: invalid role: %s", role)
	}
	var tenantID string
	err := s.pool.QueryRow(ctx, `select id from tenants where slug = $1`, tenantSlug).Scan(&tenantID)
	if err != nil {
		if err == pgx.ErrNoRows {
			return nil, fmt.Errorf("auth: tenant not found: %s", tenantSlug)
		}
		return nil, fmt.Errorf("auth: resolve tenant: %w", err)
	}

	hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
	if err != nil {
		return nil, fmt.Errorf("auth: hash password: %w", err)
	}

	row := s.pool.QueryRow(ctx,
		`insert into users(tenant_id, username, password_hash, role)
		 values($1, $2, $3, $4)
		 returning id, tenant_id, $5::text, username, password_hash, role, created_at`,
		tenantID, username, string(hash), role, tenantSlug)
	u, err := scanUserWithSlug(row)
	if err != nil {
		return nil, fmt.Errorf("auth: create screen user: %w", err)
	}
	return u, nil
}
  • Step 2: ListScreenUsers erweitern — restricted-User einschließen

Aktuell (Zeile 191210) filtert where t.slug = $1 and u.role = 'screen_user'. Ändere auf:

func (s *AuthStore) ListScreenUsers(ctx context.Context, tenantSlug string) ([]*User, error) {
	rows, err := s.pool.Query(ctx,
		`select u.id, u.tenant_id, coalesce(t.slug, ''), u.username, u.password_hash, u.role, u.created_at
		   from users u
		   left join tenants t on t.id = u.tenant_id
		  where t.slug = $1 and u.role IN ('screen_user', 'restricted')
		  order by u.username`, tenantSlug)
	if err != nil {
		return nil, fmt.Errorf("auth: list screen users: %w", err)
	}
	defer rows.Close()
	var out []*User
	for rows.Next() {
		u, err := scanUserWithSlug(rows)
		if err != nil {
			return nil, err
		}
		out = append(out, u)
	}
	return out, rows.Err()
}
  • Step 3: Kompilierung prüfen
cd server/backend && go build ./...

Erwartetes Ergebnis: Compiler-Fehler in manage/ui.goCreateScreenUser bekommt zu wenig Argumente. Das beheben wir in Task 4.

  • Step 4: Commit (noch nicht buildbar — wird in Task 4 fixiert)
git add server/backend/internal/store/auth.go
git commit -m "feat(store): CreateScreenUser nimmt role-Parameter; ListScreenUsers schließt restricted ein"

Task 3: Router — Steuerungs-Routen mit RequireNotRestricted absichern

Files:

  • Modify: server/backend/internal/httpapi/router.go

  • Step 1: Neue Middleware-Kombinatoren in registerManageRoutes einfügen

In router.go, nach dem Block authScreen := func(h http.Handler) http.Handler { ... } (nach Zeile 143), einfügen:

// authScreenControl: wie authScreen, aber zusätzlich restricted-User blockiert.
// Für Endpunkte, die restricted-User nicht nutzen dürfen (Display, Schedule, Override).
authScreenControl := func(h http.Handler) http.Handler {
    return chain(h, RequireAuth(d.AuthStore), RequireScreenAccess(d.ScreenStore), RequireNotRestricted, setCSRF, csrf)
}
// authOnlyControl: wie authOnly, aber zusätzlich restricted-User blockiert.
// Für globalen Override (kein spezifischer Screen).
authOnlyControl := func(h http.Handler) http.Handler {
    return chain(h, RequireAuth(d.AuthStore), RequireNotRestricted, setCSRF, csrf)
}
  • Step 2: Steuerungs-Routen auf neue Kombinatoren umstellen

Ändere folgende Routen (in router.go):

// ── Display control ───────────────────────────────────────────────────────────
mux.Handle("POST /api/v1/screens/{screenSlug}/display",
    authScreenControl(http.HandlerFunc(manage.HandleDisplayCommand(notifier))))

// ── Schedule control ──────────────────────────────────────────────────────────
mux.Handle("POST /api/v1/screens/{screenSlug}/schedule",
    authScreenControl(http.HandlerFunc(manage.HandleUpdateSchedule(d.ScreenStore, d.ScheduleStore))))

// ── Globaler Override ────────────────────────────────────────────────────────
mux.Handle("GET /api/v1/global-override",
    authOnly(http.HandlerFunc(manage.HandleGetGlobalOverride(d.GlobalOverrideStore))))
mux.Handle("POST /api/v1/global-override",
    authOnlyControl(http.HandlerFunc(manage.HandleSetGlobalOverride(d.GlobalOverrideStore, d.ScreenStore, notifier))))
mux.Handle("DELETE /api/v1/global-override",
    authOnlyControl(http.HandlerFunc(manage.HandleDeleteGlobalOverride(d.GlobalOverrideStore))))

// ── Per-Screen Override ───────────────────────────────────────────────────────
mux.Handle("POST /api/v1/screens/{screenSlug}/override",
    authScreenControl(http.HandlerFunc(manage.HandleSetScreenOverride(d.ScreenStore, d.ScheduleStore))))
  • Step 3: Kompilierung prüfen
cd server/backend && go build ./...

Erwartetes Ergebnis: Fehler bleibt in manage/ui.go (Task 4 nicht fertig). Keine neuen Fehler.

  • Step 4: Commit
git add server/backend/internal/httpapi/router.go
git commit -m "feat(router): Steuerungs-Endpunkte blocken restricted-User (403)"

Task 4: Handler — HandleCreateScreenUser + UserRole ans Template übergeben

Files:

  • Modify: server/backend/internal/httpapi/manage/ui.go

  • Step 1: HandleCreateScreenUserrole aus Formular lesen

Ersetze den Inhalt von HandleCreateScreenUser (Zeile 204232):

// HandleCreateScreenUser creates a new screen user (role: screen_user or restricted) for the default tenant.
func HandleCreateScreenUser(auth *store.AuthStore) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if err := r.ParseForm(); err != nil {
			http.Error(w, "bad form", http.StatusBadRequest)
			return
		}
		username := strings.TrimSpace(r.FormValue("username"))
		password := r.FormValue("password")
		if username == "" || password == "" {
			http.Redirect(w, r, "/admin?tab=users&msg=error_empty", http.StatusSeeOther)
			return
		}

		role := r.FormValue("role")
		if role != "screen_user" && role != "restricted" {
			role = "screen_user"
		}

		tenantSlug := "morz"
		if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
			tenantSlug = u.TenantSlug
		}

		_, err := auth.CreateScreenUser(r.Context(), tenantSlug, username, password, role)
		if err != nil {
			slog.Error("create screen user failed", "event", "create_screen_user_failed",
				"tenant_slug", tenantSlug, "username", username, "err", err)
			http.Redirect(w, r, "/admin?tab=users&msg=error_exists", http.StatusSeeOther)
			return
		}
		http.Redirect(w, r, "/admin?tab=users&msg=user_added", http.StatusSeeOther)
	}
}
  • Step 2: HandleScreenOverviewUserRole ans Template übergeben

In HandleScreenOverview den renderTemplate-Aufruf (ab Zeile 336) erweitern:

renderTemplate(w, t, map[string]any{
    "Cards":          cards,
    "CSRFToken":      csrfToken,
    "GlobalOverride": activeOverride,
    "UserRole":       u.Role,
})

(u ist bereits am Anfang des Handlers mit u := reqcontext.UserFromContext(r.Context()) deklariert.)

  • Step 3: HandleManageUIUserRole ans Template übergeben

In HandleManageUI, vor dem renderTemplate-Aufruf (Zeile 453), userRole bestimmen:

userRole := ""
if u := reqcontext.UserFromContext(r.Context()); u != nil {
    userRole = u.Role
}

Dann im renderTemplate-Aufruf ergänzen:

renderTemplate(w, t, map[string]any{
    "Screen":            screen,
    "Tenant":            tenant,
    "Playlist":          playlist,
    "Items":             items,
    "Assets":            assets,
    "AddedAssets":       addedAssets,
    "BackLink":          backLink,
    "BackLabel":         backLabel,
    "IsAdmin":           isAdmin,
    "AccessibleScreens": accessibleScreens,
    "ServerTimezone":    serverTimezone,
    "CSRFToken":         csrfToken,
    "DisplayState":      displayState,
    "Schedule":          schedule,
    "UserRole":          userRole,
})
  • Step 4: Kompilierung prüfen
cd server/backend && go build ./...

Erwartetes Ergebnis: go build ./... — PASS, kein Fehler

  • Step 5: Tests laufen lassen
cd server/backend && go test ./...

Erwartetes Ergebnis: alle Tests PASS (inkl. TestRequireNotRestricted_*)

  • Step 6: Commit
git add server/backend/internal/httpapi/manage/ui.go
git commit -m "feat(handler): HandleCreateScreenUser liest role; UserRole ans Template übergeben"

Task 5: Template — Admin-Formular: Rolle-Dropdown + Rollen-Badge in User-Liste

Files:

  • Modify: server/backend/internal/httpapi/manage/templates.go

  • Step 1: Rolle-Dropdown ins User-Anlegen-Formular einfügen

Im Admin-Template (adminTmpl), das User-Anlegen-Formular (um Zeile 521548). Aktuell hat es 3 Spalten (is-4 je Spalte). Füge eine vierte Spalte für die Rolle ein und passe alle auf is-3 an:

Ersetze den <div class="columns is-vcentered"> Block (Zeile 522 bis 547):

<div class="columns is-vcentered">
  <div class="column is-3">
    <div class="field">
      <label class="label is-small">Benutzername</label>
      <input class="input is-small" type="text" name="username" placeholder="z.B. alice" required autocomplete="off">
    </div>
  </div>
  <div class="column is-3">
    <div class="field">
      <label class="label is-small">Passwort</label>
      <div class="control has-icons-right">
        <input class="input is-small" type="password" id="admin-new-password" name="password" placeholder="Mindestens 8 Zeichen" required autocomplete="new-password" minlength="8">
        <span class="icon is-small is-right" style="pointer-events:all;cursor:pointer" onclick="var i=document.getElementById('admin-new-password');i.type=i.type==='password'?'text':'password'">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
        </span>
      </div>
      <p class="help">Mind. 8 Zeichen</p>
    </div>
  </div>
  <div class="column is-3">
    <div class="field">
      <label class="label is-small">Rolle</label>
      <div class="control">
        <div class="select is-small is-fullwidth">
          <select name="role">
            <option value="screen_user">Vollzugriff</option>
            <option value="restricted">Eingeschränkt (nur Medien/Playlist)</option>
          </select>
        </div>
      </div>
    </div>
  </div>
  <div class="column is-3">
    <div class="field">
      <label class="label is-small">&nbsp;</label>
      <button class="button is-primary is-small" type="submit">Benutzer anlegen</button>
    </div>
  </div>
</div>
  • Step 2: Rollen-Badge in der User-Liste hinzufügen

In der User-Tabelle (um Zeile 502510), in der <td><strong>{{.Username}}</strong></td>-Zelle die Rolle als Badge ergänzen:

<td>
  <strong>{{.Username}}</strong>
  {{if eq .Role "restricted"}}
  <span class="tag is-warning is-light is-small ml-2">Eingeschränkt</span>
  {{end}}
</td>
  • Step 3: Kompilierung prüfen
cd server/backend && go build ./...

Erwartetes Ergebnis: PASS

  • Step 4: Commit
git add server/backend/internal/httpapi/manage/templates.go
git commit -m "feat(ui): Admin-Formular: Rolle-Dropdown + Badge in User-Liste"

Task 6: Template — UI-Guards für restricted-User

Files:

  • Modify: server/backend/internal/httpapi/manage/templates.go

  • Step 1: Globalen Override-Banner und Bulk-Leiste in screenOverviewTmpl ausblenden

Der screenOverviewTmpl hat zwei Blöcke, die für restricted ausgeblendet werden müssen:

a) Globaler Override-Banner — Suche den Block der mit {{if .GlobalOverride}} beginnt (und davor den "Alle ausschalten"-Button-Bereich). Wrap den gesamten Override-Banner-Bereich:

{{if ne .UserRole "restricted"}}
... gesamter globaler Override-Bereich (Banner + Buttons) ...
{{end}}

b) Bulk-Steuerleiste — Suche den Block {{if gt (len .Cards) 1}} der die "Alle an/Alle aus"-Buttons enthält. Wrap ihn:

{{if and (gt (len .Cards) 1) (ne .UserRole "restricted")}}
... Bulk-Leiste ...
{{end}}
  • Step 2: An/Aus-Buttons und per-Screen-Override in Karten ausblenden

Innerhalb von {{range .Cards}} (Zugriff auf Top-Level-Variable mit $):

Suche den Display-State-Badge-Bereich mit den On/Off-Buttons. Wrap die Buttons (nicht den Badge selbst):

{{if ne $.UserRole "restricted"}}
<button class="button is-small is-success" onclick="sendDisplayCmd('{{.Screen.Slug}}','on')">Ein</button>
<button class="button is-small is-danger is-outlined" onclick="sendDisplayCmd('{{.Screen.Slug}}','off')">Aus</button>
{{end}}

Wrap den gesamten per-Screen Override Block ({{if not_expired .OverrideOnUntil}}...{{else}}<details>...{{end}}):

{{if ne $.UserRole "restricted"}}
{{if not_expired .OverrideOnUntil}}
  ... override badge + clear button ...
{{else}}
  <details>...</details>
{{end}}
{{end}}
  • Step 3: Zeitplan-Box und Override-Box in manageTmpl ausblenden

In manageTmpl:

Zeitplan-Box — Suche den Kasten mit dem Heading "Zeitplan" (enthält <input type="time"> Felder). Wrap den gesamten <div class="box"> um die Zeitplan-Box:

{{if ne .UserRole "restricted"}}
<div class="box">
  ... Zeitplan-Inhalt ...
</div>
{{end}}

Override-Box — Suche den Kasten mit "Einschalten bis (Override)". Wrap analog:

{{if ne .UserRole "restricted"}}
<div class="box">
  ... Override-Inhalt ...
</div>
{{end}}
  • Step 4: Kompilierung prüfen
cd server/backend && go build ./...

Erwartetes Ergebnis: PASS

  • Step 5: Tests laufen lassen
cd server/backend && go test ./...

Erwartetes Ergebnis: alle Tests PASS

  • Step 6: Commit
git add server/backend/internal/httpapi/manage/templates.go
git commit -m "feat(ui): restricted-User sehen keine Steuerungs-UI (An/Aus, Zeitplan, Override)"

Task 7: Dokumentation

Files:

  • Modify: docs/API-ENDPOINTS.md

  • Modify: server/backend/README.md

  • Step 1: docs/API-ENDPOINTS.md aktualisieren

Ergänze bei den Endpunkten für Display-Steuerung, Zeitplan und Override jeweils eine Zeile:

restricted-User erhalten 403 Forbidden.

Abschnitt "Auth / Rollen" (falls vorhanden) um den neuen Rollenwert restricted ergänzen.

  • Step 2: server/backend/README.md aktualisieren

Ergänze in der Rollenbeschreibung:

- `restricted` — Darf Medien hochladen und Playlist bearbeiten.
  Keine Display-Steuerung (An/Aus, Zeitplan, Override).
  • Step 3: Kompilierung + Tests finaler Check
cd server/backend && go build ./... && go test ./...

Erwartetes Ergebnis: PASS

  • Step 4: Commit
git add docs/API-ENDPOINTS.md server/backend/README.md
git commit -m "docs: restricted-Rolle in API-Endpoints und README dokumentiert"

Self-Review

Spec coverage:

  • Neuer Rollenwert "restricted" — Task 2 (CreateScreenUser role-Param)
  • Middleware RequireNotRestricted — Task 1
  • Endpunkte display/schedule/override blockiert — Task 3
  • Template UserRole + Guards — Tasks 4, 5, 6
  • Admin-UI Rolle-Dropdown — Task 5
  • Bestehende User unberührt (ListScreenUsers erweitert, kein Default-Change) — Task 2

Placeholder scan: keine TBDs, alle Code-Blöcke vollständig.

Type consistency: CreateScreenUser(ctx, tenantSlug, username, password, role string) — konsistent in Tasks 2 + 4.