Ship the appointments feature with bidirectional CalDAV synchronisation.
Closes KanzlAI audit §1.3 by encrypting CalDAV passwords at rest with
AES-256-GCM; plaintext credentials never touch the DB or API responses.
Backend
- `internal/services/termin_service.go`: CRUD with per-row visibility.
Personal Termine (akte_id NULL) visible only to created_by; Akte-attached
Termine follow AkteService.GetByID. Every Akte-attached mutation appends
an akten_events row for the audit trail.
- `internal/services/caldav_service.go` (+ caldav_client.go, caldav_ical.go,
caldav_crypto.go): per-user goroutine, 60s tick, push VEVENT + pull with
UID/ETag reconciliation. Last-write-wins on conflict; conflicts on
Akte-attached Termine append to akten_events.
- CALDAV_ENCRYPTION_KEY env var (32-byte AES-256, base64). Server refuses
to start with malformed key; unset key leaves CalDAV disabled and all
/api/caldav-config* endpoints return 501.
- Migration 013: paliad.user_caldav_config (password_encrypted bytea) +
paliad.caldav_sync_log (last-5 per user). RLS: user owns their row only.
- HTTP handlers: GET/POST/PATCH/DELETE /api/termine, GET
/api/akten/{id}/termine, /api/caldav-config CRUD + /test + /log.
Frontend
- Termine list / detail / new / kalender pages (Bun TSX + per-page client
TS), calendar month grid with type-coloured dots and click-popup.
- Einstellungen/CalDAV settings page: URL/user/password (write-only),
test-connection button, status card, sync log table, delete button that
purges credentials.
- Akten detail "Termine" tab replaces the Phase D placeholder — inline
add-termin form + list.
- Sidebar: Termine entry activated; new "Einstellungen" group with CalDAV.
- DE/EN i18n complete for every new surface.
Security posture
- AES-GCM with 12-byte random nonce prepended to ciphertext
- Password field has `json:"-"` on the model; API never returns it
- Frontend always sends password via write-only <input type=password>
- DeleteConfig purges the encrypted blob from the primary row
- TestConnection without stored creds requires explicit password
t-paliad-010
67 lines
3.0 KiB
SQL
67 lines
3.0 KiB
SQL
-- Phase F: paliad.user_caldav_config — per-user CalDAV credentials and sync state.
|
|
--
|
|
-- Each user can configure ONE external CalDAV calendar (one row per user).
|
|
-- Credentials are stored encrypted at rest using AES-GCM with a 12-byte
|
|
-- random nonce prepended to the ciphertext. The encryption key comes from
|
|
-- the CALDAV_ENCRYPTION_KEY env var (32-byte AES-256, base64-encoded).
|
|
--
|
|
-- Audit §1.3 fix: this is the migration that closes the KanzlAI plaintext
|
|
-- credential issue. Application code refuses to read/write encrypted blobs
|
|
-- when CALDAV_ENCRYPTION_KEY is unset.
|
|
--
|
|
-- Sync model: a per-user goroutine, spawned at server startup for every row
|
|
-- with enabled = true, pushes/pulls every 60 seconds. Conflict resolution is
|
|
-- last-write-wins on the modified timestamp; conflicts on Akte-attached
|
|
-- termine append a row to paliad.akten_events.
|
|
|
|
CREATE TABLE paliad.user_caldav_config (
|
|
user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
url text NOT NULL,
|
|
username text NOT NULL,
|
|
password_encrypted bytea NOT NULL,
|
|
calendar_path text NOT NULL DEFAULT '',
|
|
enabled boolean NOT NULL DEFAULT true,
|
|
last_sync_at timestamptz,
|
|
last_sync_error text,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- Recent sync errors per user (last 5 retained). Separate table so the
|
|
-- main row stays small and we can show an audit trail in the UI.
|
|
CREATE TABLE paliad.caldav_sync_log (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
occurred_at timestamptz NOT NULL DEFAULT now(),
|
|
direction text NOT NULL CHECK (direction IN ('push', 'pull', 'both')),
|
|
items_pushed int NOT NULL DEFAULT 0,
|
|
items_pulled int NOT NULL DEFAULT 0,
|
|
error text,
|
|
duration_ms int
|
|
);
|
|
CREATE INDEX caldav_sync_log_user_time_idx
|
|
ON paliad.caldav_sync_log (user_id, occurred_at DESC);
|
|
|
|
-- RLS: a user can only see and manage their own row. Encrypted password
|
|
-- never leaves the database except through the service layer (and even
|
|
-- there, the API never returns it).
|
|
ALTER TABLE paliad.user_caldav_config ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY user_caldav_self_select ON paliad.user_caldav_config
|
|
FOR SELECT TO authenticated
|
|
USING (user_id = auth.uid());
|
|
CREATE POLICY user_caldav_self_insert ON paliad.user_caldav_config
|
|
FOR INSERT TO authenticated
|
|
WITH CHECK (user_id = auth.uid());
|
|
CREATE POLICY user_caldav_self_update ON paliad.user_caldav_config
|
|
FOR UPDATE TO authenticated
|
|
USING (user_id = auth.uid())
|
|
WITH CHECK (user_id = auth.uid());
|
|
CREATE POLICY user_caldav_self_delete ON paliad.user_caldav_config
|
|
FOR DELETE TO authenticated
|
|
USING (user_id = auth.uid());
|
|
|
|
ALTER TABLE paliad.caldav_sync_log ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY caldav_sync_log_self_select ON paliad.caldav_sync_log
|
|
FOR SELECT TO authenticated
|
|
USING (user_id = auth.uid());
|