chore(t-paliad-081): doc + dead-code batch (F-5/F-10/F-11/F-15/F-16/F-17/F-18)

Bundle of small audit findings, all doc-only or dead-code:

- F-5: refresh stale escalation-contact comment in models.User —
  Settings UI dropdown shipped 2026-04-29 (t-paliad-066).
- F-10: add "OBSOLETED by migration 018" note to migrations 004/005/006
  so readers stop hunting for the live shape in obsolete files.
- F-11: document the data-loss semantics of dropping
  paliad.partner_unit_events on the 027 down — audit rows are
  append-only telemetry, accepted loss on rollback.
- F-15: drop the patholo_session / patholo_refresh cookie fallback
  added during the 2026-04-16 rebrand. Active users have long since
  been re-authed through the upgrade path; inactive users hit the
  normal /login flow.
- F-16: refresh stale /api/departments comment in team_pages.go to
  /api/partner-units (renamed in t-paliad-070).
- F-17: move internal/db/migrations/_dev/mock_supabase_auth.sql to
  internal/db/devtools/ so a future loosening of the //go:embed
  pattern can't accidentally ship the dev-only fixture.
- F-18: update docs/project-status.md "Audit polish-2" entry — the
  batch shipped via t-paliad-067 / 068 / 073, follow-ups are now
  tracked under the 2026-04-30 re-audit + t-paliad-074.

go build / vet / test clean.
This commit is contained in:
m
2026-04-30 03:42:25 +02:00
parent 17aa840977
commit 3da11bd798
12 changed files with 44 additions and 142 deletions

View File

@@ -58,7 +58,7 @@ To run migrations against a local Postgres:
docker run -d --name paliad-pg -e POSTGRES_PASSWORD=test -p 5432:5432 postgres:16-alpine
# bootstrap a mock auth schema (auth.users + auth.uid()) — required because
# the migrations reference Supabase-provided objects:
psql postgres://postgres:test@localhost:5432/postgres -f internal/db/migrations/_dev/mock_supabase_auth.sql
psql postgres://postgres:test@localhost:5432/postgres -f internal/db/devtools/mock_supabase_auth.sql
DATABASE_URL='postgres://postgres:test@localhost:5432/postgres?sslmode=disable' \
SUPABASE_URL=stub SUPABASE_ANON_KEY=stub \
go run ./cmd/server

View File

@@ -17,7 +17,7 @@ Phases AG shipped (April 2026): schema + RLS, services, Fristenrechner→DB,
## Open follow-ups
- **Settings → Notifications: escalation contact dropdown** — migration 025 ships `paliad.users.escalation_contact_id` (FK to `paliad.users`, nullable, ON DELETE SET NULL). NULL means "fall back to global_admins for the escalation channel"; setting it lets a user designate a specific colleague as their escalation contact. UI shipped t-paliad-066 on 2026-04-29.
- **Audit polish-2** — ~25 BATCH-level findings from `docs/audit-polish-2026-04-27.md` not yet shipped (PR-A..PR-E covered the rest). Triage in flight as t-paliad-067.
- **Audit polish-2** — shipped 2026-04-30 across t-paliad-067 / t-paliad-068 / t-paliad-073 (BATCH-level findings + DEFER list). Follow-ups from the 2026-04-30 re-audit (`docs/improvement-audit-2026-04-30.md`) are tracked under t-paliad-074 and downstream task IDs.
- **KanzlAI infra retirement** — Dokploy shutdown, `kanzlai` schema drop, Gitea archive. Pending m + head coordination.
## Historical naming

View File

@@ -16,17 +16,13 @@ import (
const (
// SessionCookieName + RefreshCookieName are the canonical cookie names
// issued after the patholo → paliad rename (2026-04-18). New logins and
// refreshes always write these; old patholo_* cookies are still read via
// the legacy fallback below so existing users stay logged in through the
// deploy. Remove the legacy names after 2026-05-18 (30-day cookie max age).
// issued after the patholo → paliad rename (2026-04-16). The legacy
// patholo_* fallback was removed in t-paliad-081 (2026-04-30) — any
// user who held a legacy cookie has long since been re-authed through
// the upgrade path and now carries paliad_* names.
SessionCookieName = "paliad_session"
RefreshCookieName = "paliad_refresh"
// Legacy cookie names — read-only fallback during the rename grace period.
LegacySessionCookieName = "patholo_session"
LegacyRefreshCookieName = "patholo_refresh"
CookieMaxAge = 30 * 24 * 60 * 60 // 30 days
)
@@ -205,26 +201,20 @@ func (c *Client) VerifyToken(token string) (*VerifiedClaims, error) {
return &VerifiedClaims{Sub: sub, Email: email, Exp: exp}, nil
}
// readSessionCookie returns the value of the session cookie and whether the
// value came from the legacy patholo_session fallback. Empty string if
// neither cookie is present.
func readSessionCookie(r *http.Request) (value string, fromLegacy bool) {
// readSessionCookie returns the value of the session cookie, or "" if the
// caller did not present one.
func readSessionCookie(r *http.Request) string {
if c, err := r.Cookie(SessionCookieName); err == nil && c.Value != "" {
return c.Value, false
return c.Value
}
if c, err := r.Cookie(LegacySessionCookieName); err == nil && c.Value != "" {
return c.Value, true
}
return "", false
return ""
}
// readRefreshCookie mirrors readSessionCookie for the refresh token.
func readRefreshCookie(r *http.Request) string {
for _, name := range []string{RefreshCookieName, LegacyRefreshCookieName} {
if c, err := r.Cookie(name); err == nil && c.Value != "" {
if c, err := r.Cookie(RefreshCookieName); err == nil && c.Value != "" {
return c.Value
}
}
return ""
}
@@ -232,7 +222,7 @@ func readRefreshCookie(r *http.Request) string {
// Browser requests get a 302 to /login; API requests get a 401 JSON response.
func (c *Client) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sessionValue, fromLegacy := readSessionCookie(r)
sessionValue := readSessionCookie(r)
if sessionValue == "" {
rejectUnauthenticated(w, r)
return
@@ -240,16 +230,6 @@ func (c *Client) Middleware(next http.Handler) http.Handler {
claims, err := c.VerifyToken(sessionValue)
if err == nil {
// If the request authenticated via the legacy patholo_session
// cookie, upgrade the browser to paliad_session so it stops
// depending on the legacy fallback.
if fromLegacy {
refresh := ""
if rc, rerr := r.Cookie(LegacyRefreshCookieName); rerr == nil {
refresh = rc.Value
}
SetAuthCookies(w, r, &TokenResponse{AccessToken: sessionValue, RefreshToken: refresh})
}
ctx := withVerifiedClaims(r.Context(), claims)
next.ServeHTTP(w, r.WithContext(ctx))
return
@@ -306,9 +286,7 @@ func isAPIRequest(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, "/api/")
}
// SetAuthCookies writes session and refresh token cookies under the current
// paliad_* names and expires any legacy patholo_* cookies from the rename
// grace period in the same response.
// SetAuthCookies writes session and refresh token cookies.
func SetAuthCookies(w http.ResponseWriter, r *http.Request, tokens *TokenResponse) {
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
for _, c := range []*http.Cookie{
@@ -325,23 +303,11 @@ func SetAuthCookies(w http.ResponseWriter, r *http.Request, tokens *TokenRespons
Secure: secure,
})
}
// Expire legacy cookies now that the browser has the paliad_* pair.
for _, name := range []string{LegacySessionCookieName, LegacyRefreshCookieName} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
}
}
// ClearAuthCookies removes session and refresh token cookies under both the
// current and legacy names so a stale patholo_* cookie can't resurrect a
// logged-out session.
// ClearAuthCookies removes session and refresh token cookies.
func ClearAuthCookies(w http.ResponseWriter) {
for _, name := range []string{SessionCookieName, RefreshCookieName, LegacySessionCookieName, LegacyRefreshCookieName} {
for _, name := range []string{SessionCookieName, RefreshCookieName} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",

View File

@@ -97,83 +97,6 @@ func TestVerifyToken_Garbage(t *testing.T) {
}
}
// TestMiddleware_LegacyCookieAccepted covers the patholo_session → paliad_session
// rename grace period: a user whose browser still holds the legacy cookie must
// stay authenticated and receive the new cookie in the response.
func TestMiddleware_LegacyCookieAccepted(t *testing.T) {
c := &Client{JWTSecret: testSecret}
token := sign(t, testSecret, jwt.MapClaims{
"sub": "11111111-1111-1111-1111-111111111111",
"exp": time.Now().Add(time.Hour).Unix(),
})
var nextHit bool
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextHit = true
if _, ok := verifiedClaimsFromContext(r.Context()); !ok {
t.Error("handler reached without verified claims in ctx")
}
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("GET", "/api/anything", nil)
req.AddCookie(&http.Cookie{Name: LegacySessionCookieName, Value: token})
rec := httptest.NewRecorder()
c.Middleware(next).ServeHTTP(rec, req)
if !nextHit {
t.Fatalf("legacy cookie should authenticate; got status %d", rec.Code)
}
// Response should upgrade the caller to paliad_session and expire the legacy one.
var sawNew, sawLegacyExpiry bool
for _, cookie := range rec.Result().Cookies() {
if cookie.Name == SessionCookieName && cookie.Value == token && cookie.MaxAge > 0 {
sawNew = true
}
if cookie.Name == LegacySessionCookieName && cookie.MaxAge < 0 {
sawLegacyExpiry = true
}
}
if !sawNew {
t.Error("expected paliad_session to be set on upgrade path")
}
if !sawLegacyExpiry {
t.Error("expected patholo_session to be expired on upgrade path")
}
}
func TestMiddleware_NewCookiePreferredOverLegacy(t *testing.T) {
c := &Client{JWTSecret: testSecret}
good := sign(t, testSecret, jwt.MapClaims{
"sub": "aaaa",
"exp": time.Now().Add(time.Hour).Unix(),
})
bad := "garbage"
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("GET", "/api/anything", nil)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: good})
req.AddCookie(&http.Cookie{Name: LegacySessionCookieName, Value: bad})
rec := httptest.NewRecorder()
c.Middleware(next).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 using paliad_session; got %d", rec.Code)
}
// When the new cookie authenticated the request, the legacy cookie must
// not be re-copied on top of it (would clobber a valid session with a
// stale one).
for _, cookie := range rec.Result().Cookies() {
if cookie.Name == SessionCookieName && cookie.Value == bad {
t.Fatal("legacy cookie value was copied over the valid paliad_session — rename upgrade is unsafe")
}
}
}
func TestMiddleware_NoCookieRejected(t *testing.T) {
c := &Client{JWTSecret: testSecret}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@@ -1,5 +1,9 @@
-- Phase A: paliad.akten — the central Akte (matter) entity.
--
-- OBSOLETED by migration 018 (data model v2): paliad.akten is dropped and
-- replaced by paliad.projects. The effective shape lives in 018; this file
-- is kept only so a fresh database can replay the migration history.
--
-- Office-scoped visibility columns (per design §2):
-- owning_office — the office the Akte belongs to
-- collaborators — uuid[] of users with explicit access (cross-office)

View File

@@ -1,5 +1,10 @@
-- Phase A: child tables of paliad.akten.
-- All inherit visibility from their parent Akte via RLS policies in migration 007.
--
-- OBSOLETED by migration 018 (data model v2): these tables are renamed/rewired
-- to hang off paliad.projects (parteien→parties, etc.) and later renamed to
-- English in migration 020. The effective shape lives in 018+020; this file
-- is kept only so a fresh database can replay the migration history.
-- ============================================================================
-- parteien (parties to an Akte)

View File

@@ -1,6 +1,11 @@
-- Phase A: paliad.can_see_akte(akte_id) — single source of truth for
-- office-scoped Akten visibility (design §2).
--
-- OBSOLETED by migration 018 (data model v2): can_see_akte() is dropped and
-- replaced by paliad.can_see_project(project_id) with team-based visibility
-- (direct + inherited up the project tree). The effective shape lives in 018;
-- this file is kept only so a fresh database can replay the migration history.
--
-- A user can see an Akte iff ANY of:
-- - the Akte is flagged firm_wide_visible
-- - the Akte's owning_office matches the user's office

View File

@@ -16,6 +16,12 @@
-- data restorations.
-- 1. Drop the audit table.
--
-- DATA LOSS: paliad.partner_unit_events has no pre-027 equivalent, so its
-- rows cannot be migrated forward on a re-up. Any audit history accumulated
-- since 027 was applied will be permanently lost on rollback. This is
-- accepted because audits are append-only telemetry, not authoritative
-- state — losing them does not corrupt the rest of the schema.
DROP TABLE IF EXISTS paliad.partner_unit_events;
-- 2. Rename RLS policies back.

View File

@@ -301,19 +301,12 @@ func handleSuggestionCount(w http.ResponseWriter, r *http.Request) {
}
// extractEmailFromCookie decodes the user's email from the session JWT.
// Checks the current paliad_session cookie, falling back to the legacy
// patholo_session during the rename grace period (see internal/auth).
func extractEmailFromCookie(r *http.Request) string {
var value string
for _, name := range []string{auth.SessionCookieName, auth.LegacySessionCookieName} {
if c, err := r.Cookie(name); err == nil && c.Value != "" {
value = c.Value
break
}
}
if value == "" {
c, err := r.Cookie(auth.SessionCookieName)
if err != nil || c.Value == "" {
return ""
}
value := c.Value
parts := strings.Split(value, ".")
if len(parts) != 3 {
return ""

View File

@@ -2,9 +2,9 @@ package handlers
import "net/http"
// GET /team — directory of all Paliad users grouped by office or department.
// Server-rendered shell; the client (assets/team.js) hydrates from /api/users
// and /api/departments?include=members.
// GET /team — directory of all Paliad users grouped by office or partner unit.
// Server-rendered shell; the client bundle hydrates from /api/users and
// /api/partner-units?include=members.
func handleTeamPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/team.html")
}

View File

@@ -46,7 +46,7 @@ type User struct {
ReminderWarningOffsetDays int `db:"reminder_warning_offset_days" json:"reminder_warning_offset_days"`
// EscalationContactID is an optional override of the escalation channel
// for overdue / DRINGEND mail. NULL means "fall back to global_admins".
// The Settings UI dropdown is deferred (see CLAUDE.md); set via SQL today.
// Settings UI dropdown shipped 2026-04-29 (t-paliad-066).
EscalationContactID *uuid.UUID `db:"escalation_contact_id" json:"escalation_contact_id,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`