Files
paliad/internal/services/termin_service.go
m 3e20806aee fix(security): verify JWT signatures + plug 4 other critical gaps (t-paliad-016)
C-1. Session JWT signature verification (authZ bypass fix)
- Add SUPABASE_JWT_SECRET env var; fail-fast at startup if unset.
- auth.Client.VerifyToken uses github.com/golang-jwt/jwt/v5 to verify
  HS256 signatures, reject alg=none, enforce exp/nbf/iat.
- Middleware stores verified claims in request context; WithUserID
  reads only verified claims (no more raw-cookie sub decoding).
- API requests get 401 on missing/invalid token (was 302 redirect).
- Refresh flow only runs on expiry; other signature failures reject
  outright and clear cookies.

C-2. Dashboard Termine cross-user privacy leak
- dashboard_service.loadUpcomingAppointments now mirrors
  TerminService.canSee: personal Termine (akte_id IS NULL) are
  creator-only; admins do NOT see other users' personal Termine.

C-3. Role gate on Parteien + Termine mutations
- ParteienService.Delete now partner/admin only (matches FristService).
- TerminService.Update / Delete on Akte-linked Termine now require
  partner/admin (or the original creator). Personal Termine stay
  creator-only.

C-4. Email gate → ALLOWED_EMAIL_DOMAINS whitelist
- isHoganLovellsEmail → isAllowedEmailDomain reading the env var
  (default: hoganlovells.com,hlc.com,hlc.de). Case-insensitive,
  whitespace-tolerant.
- login.tsx placeholder: name@hoganlovells.comname@hlc.com
- Error strings + login.hint (de/en) rewritten for HLC branding.

C-5. Docker compose env wiring
- docker-compose.yml gains SUPABASE_JWT_SECRET, CALDAV_ENCRYPTION_KEY,
  and ALLOWED_EMAIL_DOMAINS passthrough; commented-out
  ANTHROPIC_API_KEY line for Phase H readiness.

Tests
- auth_test.go: valid/wrong-secret/expired/alg-none/missing-sub/garbage
  token cases for VerifyToken.
- handlers/auth_test.go: default + env-override cases for the email
  whitelist.
- go build ./..., go vet ./..., go test ./... all clean.
2026-04-18 02:23:50 +02:00

647 lines
20 KiB
Go

package services
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/patholo/internal/models"
)
// TerminService reads and writes paliad.termine.
//
// Visibility:
// - akte_id IS NULL → personal Termin, visible/editable only to created_by
// - akte_id IS NOT NULL → follows AkteService.GetByID office-scoped rules
//
// Audit: Akte-attached mutations append akten_events rows (matching the
// FristService pattern). Personal Termine never touch akten_events.
//
// CalDAV: optional hook (TerminCalDAVPusher) is called best-effort after
// each mutation. Sync failures don't fail the user-facing request.
type TerminService struct {
db *sqlx.DB
akten *AkteService
// caldav is an optional best-effort push hook. nil during tests or when
// the encryption key isn't configured. Set via SetCalDAVPusher after
// construction to break the import cycle (CalDAVService depends on
// TerminService).
caldav TerminCalDAVPusher
}
// TerminCalDAVPusher is the contract the CalDAV service implements so the
// TerminService can push individual termin changes without importing the
// caldav package directly.
type TerminCalDAVPusher interface {
OnTerminCreated(ctx context.Context, userID uuid.UUID, t *models.Termin)
OnTerminUpdated(ctx context.Context, userID uuid.UUID, t *models.Termin)
OnTerminDeleted(ctx context.Context, userID uuid.UUID, t *models.Termin)
}
func NewTerminService(db *sqlx.DB, akten *AkteService) *TerminService {
return &TerminService{db: db, akten: akten}
}
// SetCalDAVPusher wires an optional CalDAV push hook. Called from main()
// once both services are constructed.
func (s *TerminService) SetCalDAVPusher(p TerminCalDAVPusher) {
s.caldav = p
}
const terminColumns = `id, akte_id, title, description, start_at, end_at,
location, termin_type, caldav_uid, caldav_etag, created_by,
created_at, updated_at`
// CreateTerminInput is the payload for POST /api/termine.
type CreateTerminInput struct {
AkteID *uuid.UUID `json:"akte_id,omitempty"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
StartAt time.Time `json:"start_at"`
EndAt *time.Time `json:"end_at,omitempty"`
Location *string `json:"location,omitempty"`
TerminType *string `json:"termin_type,omitempty"`
}
// UpdateTerminInput is the partial-update payload for PATCH /api/termine/{id}.
type UpdateTerminInput struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
StartAt *time.Time `json:"start_at,omitempty"`
EndAt *time.Time `json:"end_at,omitempty"`
Location *string `json:"location,omitempty"`
TerminType *string `json:"termin_type,omitempty"`
}
// TerminListFilter narrows ListVisibleForUser results.
type TerminListFilter struct {
AkteID *uuid.UUID
From *time.Time
To *time.Time
Type *string
}
// ListVisibleForUser returns all Termine the user can see (personal +
// Akten-attached they have visibility for), ordered by start_at ascending.
func (s *TerminService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, filter TerminListFilter) ([]models.TerminWithAkte, error) {
user, err := s.akten.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return []models.TerminWithAkte{}, nil
}
visibility := `(
(t.akte_id IS NULL AND t.created_by = :user_id)
OR (t.akte_id IS NOT NULL AND (
a.firm_wide_visible = true
OR a.owning_office = :office
OR :user_id = ANY (a.collaborators)
OR :role = 'admin'
))
)`
conds := []string{visibility}
args := map[string]any{
"user_id": userID,
"office": user.Office,
"role": user.Role,
}
if filter.AkteID != nil {
conds = append(conds, `t.akte_id = :akte_id`)
args["akte_id"] = *filter.AkteID
}
if filter.From != nil {
conds = append(conds, `t.start_at >= :from`)
args["from"] = *filter.From
}
if filter.To != nil {
conds = append(conds, `t.start_at <= :to`)
args["to"] = *filter.To
}
if filter.Type != nil {
if !isValidTerminType(*filter.Type) {
return nil, fmt.Errorf("%w: invalid termin_type %q", ErrInvalidInput, *filter.Type)
}
conds = append(conds, `t.termin_type = :type`)
args["type"] = *filter.Type
}
query := `
SELECT t.id, t.akte_id, t.title, t.description, t.start_at, t.end_at,
t.location, t.termin_type, t.caldav_uid, t.caldav_etag,
t.created_by, t.created_at, t.updated_at,
a.aktenzeichen AS akte_aktenzeichen,
a.title AS akte_title,
a.owning_office AS akte_office
FROM paliad.termine t
LEFT JOIN paliad.akten a ON a.id = t.akte_id
WHERE ` + strings.Join(conds, " AND ") + `
ORDER BY t.start_at ASC, t.created_at DESC`
stmt, err := s.db.PrepareNamedContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("prepare list termine: %w", err)
}
defer stmt.Close()
var rows []models.TerminWithAkte
if err := stmt.SelectContext(ctx, &rows, args); err != nil {
return nil, fmt.Errorf("list termine: %w", err)
}
return rows, nil
}
// ListForAkte returns Termine for a specific Akte, visibility-checked.
func (s *TerminService) ListForAkte(ctx context.Context, userID, akteID uuid.UUID) ([]models.Termin, error) {
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
return nil, err
}
var rows []models.Termin
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+terminColumns+`
FROM paliad.termine
WHERE akte_id = $1
ORDER BY start_at ASC, created_at DESC`, akteID); err != nil {
return nil, fmt.Errorf("list termine for akte: %w", err)
}
return rows, nil
}
// GetByID returns a single Termin if the user has visibility.
func (s *TerminService) GetByID(ctx context.Context, userID, terminID uuid.UUID) (*models.Termin, error) {
var t models.Termin
err := s.db.GetContext(ctx, &t,
`SELECT `+terminColumns+` FROM paliad.termine WHERE id = $1`, terminID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
}
if err != nil {
return nil, fmt.Errorf("fetch termin: %w", err)
}
if !s.canSee(ctx, userID, &t) {
return nil, ErrNotVisible
}
return &t, nil
}
// requireMutationRole enforces the partner/admin gate on Akte-linked
// Termin mutations (Update, Delete). The Termin's own creator is also
// allowed — they're the one who added it.
func (s *TerminService) requireMutationRole(ctx context.Context, userID uuid.UUID, t *models.Termin) error {
if t.CreatedBy != nil && *t.CreatedBy == userID {
return nil
}
user, err := s.akten.users.GetByID(ctx, userID)
if err != nil {
return err
}
if user == nil {
return ErrNotVisible
}
if user.Role != "partner" && user.Role != "admin" {
return fmt.Errorf("%w: only partners/admins can modify Termine on an Akte", ErrForbidden)
}
return nil
}
// canSee mirrors the SELECT visibility predicate for one in-memory Termin.
func (s *TerminService) canSee(ctx context.Context, userID uuid.UUID, t *models.Termin) bool {
if t.AkteID == nil {
return t.CreatedBy != nil && *t.CreatedBy == userID
}
_, err := s.akten.GetByID(ctx, userID, *t.AkteID)
return err == nil
}
// Create inserts a Termin. If akte_id is set, AkteService visibility is
// enforced and the Akte's audit trail records the new appointment.
func (s *TerminService) Create(ctx context.Context, userID uuid.UUID, input CreateTerminInput) (*models.Termin, error) {
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
}
if input.StartAt.IsZero() {
return nil, fmt.Errorf("%w: start_at is required", ErrInvalidInput)
}
if input.EndAt != nil && input.EndAt.Before(input.StartAt) {
return nil, fmt.Errorf("%w: end_at must be after start_at", ErrInvalidInput)
}
if input.TerminType != nil && *input.TerminType != "" && !isValidTerminType(*input.TerminType) {
return nil, fmt.Errorf("%w: invalid termin_type %q", ErrInvalidInput, *input.TerminType)
}
if input.AkteID != nil {
if _, err := s.akten.GetByID(ctx, userID, *input.AkteID); err != nil {
return nil, err
}
}
id := uuid.New()
now := time.Now().UTC()
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.termine
(id, akte_id, title, description, start_at, end_at, location,
termin_type, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)`,
id, input.AkteID, title, input.Description, input.StartAt.UTC(),
nullableUTC(input.EndAt), input.Location, input.TerminType, userID, now,
); err != nil {
return nil, fmt.Errorf("insert termin: %w", err)
}
if input.AkteID != nil {
desc := fmt.Sprintf("Termin \u201E%s\u201C angelegt", title)
descPtr := &desc
if err := insertAkteEvent(ctx, tx, *input.AkteID, userID, "termin_created", "Termin angelegt", descPtr); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit insert termin: %w", err)
}
t, err := s.GetByID(ctx, userID, id)
if err != nil {
return nil, err
}
if s.caldav != nil {
s.caldav.OnTerminCreated(ctx, userID, t)
}
return t, nil
}
// Update applies a partial update.
//
// Policy:
// - Personal Termin (akte_id IS NULL) → only the creator may edit.
// - Akte-linked Termin → partner/admin only (or the Termin's creator).
// Mirrors the FristService delete policy so an associate in another
// office can't mutate a hearing on a firm-wide Akte.
func (s *TerminService) Update(ctx context.Context, userID, terminID uuid.UUID, input UpdateTerminInput) (*models.Termin, error) {
current, err := s.GetByID(ctx, userID, terminID)
if err != nil {
return nil, err
}
if current.AkteID == nil {
if current.CreatedBy == nil || *current.CreatedBy != userID {
return nil, fmt.Errorf("%w: only the creator can edit a personal Termin", ErrForbidden)
}
} else if err := s.requireMutationRole(ctx, userID, current); err != nil {
return nil, err
}
sets := []string{}
args := []any{}
next := 1
appendSet := func(col string, val any) {
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
args = append(args, val)
next++
}
if input.Title != nil {
title := strings.TrimSpace(*input.Title)
if title == "" {
return nil, fmt.Errorf("%w: title cannot be empty", ErrInvalidInput)
}
appendSet("title", title)
}
if input.Description != nil {
appendSet("description", *input.Description)
}
if input.StartAt != nil {
appendSet("start_at", input.StartAt.UTC())
}
if input.EndAt != nil {
appendSet("end_at", input.EndAt.UTC())
}
if input.Location != nil {
appendSet("location", *input.Location)
}
if input.TerminType != nil {
if *input.TerminType != "" && !isValidTerminType(*input.TerminType) {
return nil, fmt.Errorf("%w: invalid termin_type %q", ErrInvalidInput, *input.TerminType)
}
appendSet("termin_type", *input.TerminType)
}
if len(sets) == 0 {
return current, nil
}
appendSet("updated_at", time.Now().UTC())
args = append(args, terminID)
query := fmt.Sprintf("UPDATE paliad.termine SET %s WHERE id = $%d",
strings.Join(sets, ", "), next)
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return nil, fmt.Errorf("update termin: %w", err)
}
if current.AkteID != nil {
desc := fmt.Sprintf("Termin \u201E%s\u201C ge\u00e4ndert", current.Title)
descPtr := &desc
if err := insertAkteEvent(ctx, tx, *current.AkteID, userID, "termin_updated", "Termin ge\u00e4ndert", descPtr); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update termin: %w", err)
}
t, err := s.GetByID(ctx, userID, terminID)
if err != nil {
return nil, err
}
if s.caldav != nil {
s.caldav.OnTerminUpdated(ctx, userID, t)
}
return t, nil
}
// Delete removes a Termin.
//
// Policy:
// - Personal Termin (akte_id IS NULL) → only the creator may delete.
// - Akte-linked Termin → partner/admin only (or the Termin's creator).
// Matches the FristService delete gate so an associate viewing a
// firm-wide Akte can't erase hearings from the calendar.
func (s *TerminService) Delete(ctx context.Context, userID, terminID uuid.UUID) error {
current, err := s.GetByID(ctx, userID, terminID)
if err != nil {
return err
}
if current.AkteID == nil {
if current.CreatedBy == nil || *current.CreatedBy != userID {
return fmt.Errorf("%w: only the creator can delete a personal Termin", ErrForbidden)
}
} else if err := s.requireMutationRole(ctx, userID, current); err != nil {
return err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.termine WHERE id = $1`, terminID); err != nil {
return fmt.Errorf("delete termin: %w", err)
}
if current.AkteID != nil {
desc := fmt.Sprintf("Termin \u201E%s\u201C gel\u00f6scht", current.Title)
descPtr := &desc
if err := insertAkteEvent(ctx, tx, *current.AkteID, userID, "termin_deleted", "Termin gel\u00f6scht", descPtr); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit delete termin: %w", err)
}
if s.caldav != nil {
s.caldav.OnTerminDeleted(ctx, userID, current)
}
return nil
}
// SummaryCounts returns Heute / Diese Woche / Sp\u00e4ter counts for the
// user's visible Termine.
type TerminSummaryCounts struct {
Today int `json:"today"`
ThisWeek int `json:"this_week"`
Later int `json:"later"`
Total int `json:"total"`
}
func (s *TerminService) SummaryCounts(ctx context.Context, userID uuid.UUID) (*TerminSummaryCounts, error) {
user, err := s.akten.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return &TerminSummaryCounts{}, nil
}
now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
tomorrow := today.AddDate(0, 0, 1)
endOfWeek := today.AddDate(0, 0, 7)
query := `
SELECT
COUNT(*) FILTER (WHERE t.start_at >= :today AND t.start_at < :tomorrow) AS today,
COUNT(*) FILTER (WHERE t.start_at >= :tomorrow AND t.start_at < :endweek) AS this_week,
COUNT(*) FILTER (WHERE t.start_at >= :endweek) AS later,
COUNT(*) FILTER (WHERE t.start_at >= :today) AS total
FROM paliad.termine t
LEFT JOIN paliad.akten a ON a.id = t.akte_id
WHERE
((t.akte_id IS NULL AND t.created_by = :user_id)
OR (t.akte_id IS NOT NULL AND (
a.firm_wide_visible = true
OR a.owning_office = :office
OR :user_id = ANY (a.collaborators)
OR :role = 'admin'
)))`
stmt, err := s.db.PrepareNamedContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("prepare termin summary: %w", err)
}
defer stmt.Close()
var c TerminSummaryCounts
if err := stmt.GetContext(ctx, &c, map[string]any{
"today": today,
"tomorrow": tomorrow,
"endweek": endOfWeek,
"user_id": userID,
"office": user.Office,
"role": user.Role,
}); err != nil {
return nil, fmt.Errorf("termin summary: %w", err)
}
return &c, nil
}
// SetCalDAVMeta is called by the CalDAV service after a successful push so
// future pulls can use the etag for change detection. Bypasses visibility
// (system-internal call).
func (s *TerminService) SetCalDAVMeta(ctx context.Context, terminID uuid.UUID, uid, etag string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE paliad.termine
SET caldav_uid = $1, caldav_etag = $2, updated_at = NOW()
WHERE id = $3`, uid, etag, terminID)
if err != nil {
return fmt.Errorf("update termin caldav meta: %w", err)
}
return nil
}
// AllForUser returns every Termin (personal + visible Akten-attached) the
// user owns. Used by the CalDAV push loop, which needs the full set to
// reconcile.
func (s *TerminService) AllForUser(ctx context.Context, userID uuid.UUID) ([]models.Termin, error) {
user, err := s.akten.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, nil
}
rows := []models.Termin{}
query := `
SELECT ` + terminColumns + `
FROM paliad.termine t
LEFT JOIN paliad.akten a ON a.id = t.akte_id
WHERE
(t.akte_id IS NULL AND t.created_by = $1)
OR (t.akte_id IS NOT NULL AND (
a.firm_wide_visible = true
OR a.owning_office = $2
OR $1 = ANY (a.collaborators)
OR $3 = 'admin'
))`
if err := s.db.SelectContext(ctx, &rows, query, userID, user.Office, user.Role); err != nil {
return nil, fmt.Errorf("all termine for user: %w", err)
}
return rows, nil
}
// FindByCalDAVUID resolves a Termin from its external UID (used during pull
// reconciliation). Returns ErrNotVisible if not found.
func (s *TerminService) FindByCalDAVUID(ctx context.Context, uid string) (*models.Termin, error) {
var t models.Termin
err := s.db.GetContext(ctx, &t,
`SELECT `+terminColumns+` FROM paliad.termine WHERE caldav_uid = $1`, uid)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
}
if err != nil {
return nil, fmt.Errorf("find termin by caldav uid: %w", err)
}
return &t, nil
}
// ApplyRemoteUpdate is the inverse of SetCalDAVMeta: writes pulled changes
// into the local row. Caller is the CalDAV service. Returns true if any
// columns actually changed, used by the conflict-logger.
func (s *TerminService) ApplyRemoteUpdate(ctx context.Context, terminID uuid.UUID, title, description, location *string, startAt, endAt *time.Time, etag string) (bool, error) {
sets := []string{"caldav_etag = $1", "updated_at = NOW()"}
args := []any{etag}
next := 2
changed := false
if title != nil {
sets = append(sets, fmt.Sprintf("title = $%d", next))
args = append(args, *title)
next++
changed = true
}
if description != nil {
sets = append(sets, fmt.Sprintf("description = $%d", next))
args = append(args, *description)
next++
changed = true
}
if location != nil {
sets = append(sets, fmt.Sprintf("location = $%d", next))
args = append(args, *location)
next++
changed = true
}
if startAt != nil {
sets = append(sets, fmt.Sprintf("start_at = $%d", next))
args = append(args, startAt.UTC())
next++
changed = true
}
if endAt != nil {
sets = append(sets, fmt.Sprintf("end_at = $%d", next))
args = append(args, endAt.UTC())
next++
changed = true
}
args = append(args, terminID)
query := fmt.Sprintf("UPDATE paliad.termine SET %s WHERE id = $%d",
strings.Join(sets, ", "), next)
if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
return false, fmt.Errorf("apply remote termin update: %w", err)
}
return changed, nil
}
// DeleteByCalDAVUID removes a Termin pulled-deleted from the remote calendar.
// Bypasses visibility; the caller (CalDAV service) is system-internal.
func (s *TerminService) DeleteByCalDAVUID(ctx context.Context, uid string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.termine WHERE caldav_uid = $1`, uid)
if err != nil {
return fmt.Errorf("delete termin by caldav uid: %w", err)
}
return nil
}
// LogConflict appends a conflict event to the parent Akte's audit trail.
// No-op for personal Termine. Attribution falls back to the Termin's
// created_by when the caller is system-internal (CalDAV sync loop).
func (s *TerminService) LogConflict(ctx context.Context, terminID uuid.UUID, msg string) error {
var row struct {
AkteID *uuid.UUID `db:"akte_id"`
CreatedBy *uuid.UUID `db:"created_by"`
}
err := s.db.GetContext(ctx, &row,
`SELECT akte_id, created_by FROM paliad.termine WHERE id = $1`, terminID)
if err != nil || row.AkteID == nil {
return nil //nolint:nilerr // intentional: skip if missing or personal
}
now := time.Now().UTC()
desc := msg
_, err = s.db.ExecContext(ctx,
`INSERT INTO paliad.akten_events
(id, akte_id, event_type, title, description, event_date,
created_by, metadata, created_at, updated_at)
VALUES ($1, $2, 'caldav_conflict', 'CalDAV conflict', $3, $4, $5, '{}', $4, $4)`,
uuid.New(), *row.AkteID, desc, now, row.CreatedBy)
if err != nil {
return fmt.Errorf("insert caldav conflict event: %w", err)
}
return nil
}
func nullableUTC(t *time.Time) any {
if t == nil {
return nil
}
u := t.UTC()
return u
}
func isValidTerminType(t string) bool {
switch t {
case "hearing", "meeting", "consultation", "deadline_hearing":
return true
}
return false
}