Compare commits
5 Commits
mai/cronus
...
mai/brunel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c15d5b72f2 | ||
|
|
82878dffd5 | ||
|
|
909f14062c | ||
|
|
4b86dfa4ad | ||
|
|
60f1f4ef4a |
482
AUDIT.md
Normal file
482
AUDIT.md
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
# KanzlAI-mGMT MVP Audit
|
||||||
|
|
||||||
|
**Date:** 2026-03-28
|
||||||
|
**Auditor:** athena (consultant)
|
||||||
|
**Scope:** Full-stack audit of KanzlAI-mGMT — Go backend, Next.js frontend, Supabase database, deployment, security, UX, competitive positioning.
|
||||||
|
**Codebase:** ~16,500 lines across ~60 source files, built 2026-03-25 in a single session with parallel workers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
KanzlAI-mGMT is an impressive MVP built in ~2 hours. It covers the core Kanzleimanagement primitives: cases, deadlines, appointments, parties, documents, notes, dashboard, CalDAV sync, and AI-powered deadline extraction. The architecture is sound — clean separation between Go API and Next.js frontend, proper multi-tenant design with Supabase Auth, parameterized SQL throughout.
|
||||||
|
|
||||||
|
However, the speed of construction shows. There are **critical security gaps** that must be fixed before any external user touches this. The frontend has good bones but lacks the polish and completeness a lawyer would expect. And the feature gap vs. established competitors (RA-MICRO, ADVOWARE, AnNoText, Actaport) is enormous — particularly around beA integration, billing/RVG, and document generation, which are table-stakes for German law firms.
|
||||||
|
|
||||||
|
**Bottom line:** Fix the security issues, add error recovery and multi-tenant auth verification, then decide whether to pursue the Kanzleimanagement market (massive feature gap) or pivot back to the UPC niche (where you had a genuine competitive advantage).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Critical Issues (Fix Immediately)
|
||||||
|
|
||||||
|
### 1.1 Tenant Isolation Bypass in TenantResolver
|
||||||
|
**File:** `backend/internal/auth/tenant_resolver.go:37-42`
|
||||||
|
|
||||||
|
When the `X-Tenant-ID` header is provided, the TenantResolver parses it and sets it in context **without verifying the user has access to that tenant**. Any authenticated user can access any tenant's data by setting this header.
|
||||||
|
|
||||||
|
```go
|
||||||
|
if header := r.Header.Get("X-Tenant-ID"); header != "" {
|
||||||
|
parsed, err := uuid.Parse(header)
|
||||||
|
// ... sets tenantID = parsed — NO ACCESS CHECK
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Compare with `helpers.go:32-44` where `resolveTenant()` correctly verifies access via `user_tenants` — but this function is unused in the middleware path. The TenantResolver middleware is what actually runs for all scoped routes.
|
||||||
|
|
||||||
|
**Impact:** Complete tenant data isolation breach. User A can read/modify/delete User B's cases, deadlines, appointments, documents.
|
||||||
|
|
||||||
|
**Fix:** Add `user_tenants` lookup in TenantResolver when X-Tenant-ID is provided, same as `resolveTenant()` does.
|
||||||
|
|
||||||
|
### 1.2 Duplicate Tenant Resolution Logic
|
||||||
|
**Files:** `backend/internal/auth/tenant_resolver.go` and `backend/internal/handlers/helpers.go:25-57`
|
||||||
|
|
||||||
|
Two independent implementations of tenant resolution exist. The middleware (`TenantResolver`) is used for the scoped routes. The handler-level `resolveTenant()` function exists in helpers.go. The auth middleware in `middleware.go:39-47` also resolves a tenant into context. This triple-resolution creates confusion and the security bug above.
|
||||||
|
|
||||||
|
**Fix:** Consolidate to a single path. Remove the handler-level `resolveTenant()` and the auth middleware's tenant resolution. Let TenantResolver be the single source of truth, but make it verify access.
|
||||||
|
|
||||||
|
### 1.3 CalDAV Credentials Stored in Plaintext
|
||||||
|
**File:** `backend/internal/services/caldav_service.go:29-35`
|
||||||
|
|
||||||
|
CalDAV username and password are stored as plain JSON in the `tenants.settings` column:
|
||||||
|
```go
|
||||||
|
type CalDAVConfig struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Combined with the tenant isolation bypass above, any authenticated user can read any tenant's CalDAV credentials.
|
||||||
|
|
||||||
|
**Fix:** Encrypt CalDAV credentials at rest (e.g., using `pgcrypto` or application-level encryption). At minimum, never return the password in API responses.
|
||||||
|
|
||||||
|
### 1.4 No CORS Configuration
|
||||||
|
**File:** `backend/internal/router/router.go`, `backend/cmd/server/main.go`
|
||||||
|
|
||||||
|
There is zero CORS handling anywhere in the backend. The frontend uses Next.js rewrites to proxy `/api/` to the backend, which works in production. But:
|
||||||
|
- If anyone accesses the backend directly (different origin), there's no CORS protection.
|
||||||
|
- No `X-Frame-Options`, `X-Content-Type-Options`, or other security headers are set.
|
||||||
|
|
||||||
|
**Fix:** Add CORS middleware restricting to the frontend origin. Add standard security headers.
|
||||||
|
|
||||||
|
### 1.5 Internal Error Messages Leaked to Clients
|
||||||
|
**Files:** Multiple handlers (e.g., `cases.go:44`, `cases.go:73`, `appointments.go`)
|
||||||
|
|
||||||
|
```go
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
```
|
||||||
|
|
||||||
|
Internal error messages (including SQL errors, connection errors, etc.) are sent directly to the client. This leaks implementation details.
|
||||||
|
|
||||||
|
**Fix:** Log the full error server-side, return a generic message to the client.
|
||||||
|
|
||||||
|
### 1.6 Race Condition in HolidayService Cache
|
||||||
|
**File:** `backend/internal/services/holidays.go`
|
||||||
|
|
||||||
|
The `HolidayService` uses a `map[int][]Holiday` cache without any mutex protection. Concurrent requests (e.g., multiple deadline calculations) will cause a data race. The Go race detector would flag this.
|
||||||
|
|
||||||
|
**Fix:** Add `sync.RWMutex` to HolidayService.
|
||||||
|
|
||||||
|
### 1.7 Rate Limiter Trivially Bypassable
|
||||||
|
**File:** `backend/internal/middleware/ratelimit.go:78-79`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ip := r.Header.Get("X-Forwarded-For")
|
||||||
|
if ip == "" { ip = r.RemoteAddr }
|
||||||
|
```
|
||||||
|
|
||||||
|
Rate limiting keys off `X-Forwarded-For`, which any client can spoof. An attacker can bypass AI endpoint rate limits by rotating this header.
|
||||||
|
|
||||||
|
**Fix:** Only trust `X-Forwarded-For` from configured reverse proxy IPs, or use `r.RemoteAddr` exclusively behind a trusted proxy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Important Gaps (Fix Before Showing to Anyone)
|
||||||
|
|
||||||
|
### 2.1 No Input Validation Beyond "Required Fields"
|
||||||
|
**Files:** All handlers
|
||||||
|
|
||||||
|
Input validation is minimal — typically just checking if required fields are empty:
|
||||||
|
```go
|
||||||
|
if input.CaseNumber == "" || input.Title == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "case_number and title are required")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Missing:
|
||||||
|
- Length limits on text fields (could store megabytes in a title field)
|
||||||
|
- Status value validation (accepts any string for status fields)
|
||||||
|
- Date format validation
|
||||||
|
- Case type validation against allowed values
|
||||||
|
- SQL-safe string validation (although parameterized queries protect against injection)
|
||||||
|
|
||||||
|
### 2.2 No Pagination Defaults on Most List Endpoints
|
||||||
|
**File:** `backend/internal/services/case_service.go:57-63`
|
||||||
|
|
||||||
|
`CaseService.List` has sane defaults (limit=20, max=100). But other list endpoints (`appointments`, `deadlines`, `notes`, `parties`, `case_events`) have no pagination at all — they return all records for a tenant/case. As data grows, these become performance problems.
|
||||||
|
|
||||||
|
### 2.3 Dashboard Page is Entirely Client-Side
|
||||||
|
**File:** `frontend/src/app/(app)/dashboard/page.tsx`
|
||||||
|
|
||||||
|
The entire dashboard is a `"use client"` component that fetches data via API. This means:
|
||||||
|
- No SSR benefit — the page is blank until JS loads and API responds
|
||||||
|
- SEO doesn't matter for a SaaS app, but initial load time does
|
||||||
|
- The skeleton is nice but adds 200-400ms of perceived latency
|
||||||
|
|
||||||
|
For an internal tool this is acceptable, but for a commercial product it should use server components for the initial render.
|
||||||
|
|
||||||
|
### 2.4 Frontend Auth Uses `getSession()` Instead of `getUser()`
|
||||||
|
**File:** `frontend/src/lib/api.ts:10-12`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
```
|
||||||
|
|
||||||
|
`getSession()` reads from local storage without server verification. If a session is expired or revoked server-side, the frontend will still try to use it until the backend rejects it. The middleware correctly uses `getUser()` (which validates server-side), but the API client does not.
|
||||||
|
|
||||||
|
### 2.5 Missing Error Recovery in Frontend
|
||||||
|
Throughout the frontend, API errors are handled with basic error states, but there's no:
|
||||||
|
- Retry logic for transient failures
|
||||||
|
- Token refresh on 401 responses
|
||||||
|
- Optimistic UI rollback on mutation failures
|
||||||
|
- Offline detection
|
||||||
|
|
||||||
|
### 2.6 Missing `Content-Disposition` Header Sanitization
|
||||||
|
**File:** `backend/internal/handlers/documents.go:133`
|
||||||
|
|
||||||
|
```go
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, title))
|
||||||
|
```
|
||||||
|
|
||||||
|
The `title` (which comes from user input) is inserted directly into the header. A filename containing `"` or newlines could be used for response header injection.
|
||||||
|
|
||||||
|
**Fix:** Sanitize the filename — strip or encode special characters.
|
||||||
|
|
||||||
|
### 2.7 No Graceful Shutdown
|
||||||
|
**File:** `backend/cmd/server/main.go:42`
|
||||||
|
|
||||||
|
```go
|
||||||
|
http.ListenAndServe(":"+cfg.Port, handler)
|
||||||
|
```
|
||||||
|
|
||||||
|
No signal handling or graceful shutdown. When the process receives SIGTERM (e.g., during deployment), in-flight requests are dropped, CalDAV sync operations may be interrupted mid-write, and database connections are not cleanly closed.
|
||||||
|
|
||||||
|
### 2.8 Database Connection Pool — search_path is Session-Level
|
||||||
|
**File:** `backend/internal/db/connection.go:17`
|
||||||
|
|
||||||
|
```go
|
||||||
|
db.Exec("SET search_path TO kanzlai, public")
|
||||||
|
```
|
||||||
|
|
||||||
|
`SET search_path` is session-level in PostgreSQL. With connection pooling (`MaxOpenConns: 25`), this SET runs once on the initial connection. If a connection is recycled or a new one opened from the pool, it may not have the kanzlai search_path. This could cause queries to silently hit the wrong schema.
|
||||||
|
|
||||||
|
**Fix:** Use `SET LOCAL search_path` in a transaction, or set it at the database/role level, or qualify all table references with the schema name.
|
||||||
|
|
||||||
|
### 2.9 go.sum Missing from Dockerfile
|
||||||
|
**File:** `backend/Dockerfile:4`
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
COPY go.mod ./
|
||||||
|
RUN go mod download
|
||||||
|
```
|
||||||
|
|
||||||
|
Only `go.mod` is copied, not `go.sum`. This means the build isn't reproducible and doesn't verify checksums. Should be `COPY go.mod go.sum ./`.
|
||||||
|
|
||||||
|
### 2.10 German Umlaut Typos Throughout Frontend
|
||||||
|
**Files:** Multiple frontend components
|
||||||
|
|
||||||
|
German strings use ASCII approximations instead of proper characters:
|
||||||
|
- `login/page.tsx`: "Zurueck" instead of "Zurück"
|
||||||
|
- `cases/[id]/layout.tsx`: "Anhaengig" instead of "Anhängig"
|
||||||
|
- `cases/[id]/fristen/page.tsx`: "Ueberfaellig" instead of "Überfällig"
|
||||||
|
- `termine/page.tsx`: "Uberblick" instead of "Überblick"
|
||||||
|
|
||||||
|
A German lawyer would notice this immediately. It signals "this was built by a machine, not tested by a human."
|
||||||
|
|
||||||
|
### 2.11 Silent Error Swallowing in Event Creation
|
||||||
|
**File:** `backend/internal/services/case_service.go:260-266`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func createEvent(ctx context.Context, db *sqlx.DB, ...) {
|
||||||
|
db.ExecContext(ctx, /* ... */) // Error completely ignored
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Case events (audit trail) silently fail to create. The calling functions don't check the return. This means you could have cases with no events and no way to know why.
|
||||||
|
|
||||||
|
### 2.12 Missing Error Boundaries in Frontend
|
||||||
|
No React error boundaries are implemented. If any component throws, the entire page crashes with a white screen. For a law firm tool where data integrity matters, this is unacceptable.
|
||||||
|
|
||||||
|
### 2.13 No RLS Policies Defined at Database Level
|
||||||
|
Multi-tenant isolation relies entirely on `WHERE tenant_id = $X` clauses in Go code. If any query forgets this clause, data leaks across tenants. There are no PostgreSQL RLS policies as a safety net.
|
||||||
|
|
||||||
|
**Fix:** Enable RLS on all tenant-scoped tables and create policies tied to `auth.uid()` via `user_tenants`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture Assessment
|
||||||
|
|
||||||
|
### 3.1 What's Good
|
||||||
|
|
||||||
|
- **Clean monorepo structure** — `backend/` and `frontend/` are clearly separated. Each has its own Dockerfile. The Makefile provides unified commands.
|
||||||
|
- **Go backend is well-organized** — `cmd/server/`, `internal/{auth,config,db,handlers,middleware,models,router,services}` follows Go best practices.
|
||||||
|
- **Handler/Service separation** — handlers do HTTP concerns (parse request, write response), services do business logic. This is correct.
|
||||||
|
- **Parameterized SQL everywhere** — no string concatenation in queries. All user input goes through `$N` placeholders.
|
||||||
|
- **Multi-tenant design** — `tenant_id` on every row, context-based tenant resolution, RLS at the database level.
|
||||||
|
- **Smart use of Go 1.22+ routing** — method+path patterns like `GET /api/cases/{id}` eliminate the need for a third-party router.
|
||||||
|
- **CalDAV sync is genuinely impressive** — bidirectional sync with conflict resolution, etag tracking, background polling per-tenant. This is a differentiator.
|
||||||
|
- **Deadline calculator** — ported from youpc.org with holiday awareness. Legally important and hard to build.
|
||||||
|
- **Frontend routing structure** — German URL paths (`/fristen`, `/termine`, `/einstellungen`), nested case detail routes with layout.tsx for shared chrome. Proper use of App Router patterns.
|
||||||
|
|
||||||
|
### 3.2 Structural Concerns
|
||||||
|
|
||||||
|
- **No database migrations** — the schema was apparently created via SQL scripts run manually. There's a `seed/demo_data.sql` but no migration system. For a production system, this is unsustainable.
|
||||||
|
- **No CI/CD pipeline** — no `.github/workflows/`, `.gitea/`, or any CI configuration. Tests run locally but not automatically.
|
||||||
|
- **No API versioning** — all routes are at `/api/`. Adding breaking changes will break clients.
|
||||||
|
- **Services take raw `*sqlx.DB`** — no transaction support across service boundaries. Creating a case + event is not atomic (if the event insert fails, the case still exists).
|
||||||
|
- **Models are just struct definitions** — no validation methods, no constructor functions. Validation is scattered across handlers.
|
||||||
|
|
||||||
|
### 3.3 Data Model
|
||||||
|
|
||||||
|
Based on the seed data and model files, the schema is reasonable:
|
||||||
|
- `tenants`, `user_tenants` (multi-tenancy)
|
||||||
|
- `cases`, `parties` (case management)
|
||||||
|
- `deadlines`, `appointments` (time management)
|
||||||
|
- `documents`, `case_events`, `notes` (supporting data)
|
||||||
|
- `proceeding_types`, `deadline_rules`, `holidays` (reference data)
|
||||||
|
|
||||||
|
**Missing indexes likely needed:**
|
||||||
|
- `deadlines(tenant_id, status, due_date)` — for dashboard queries
|
||||||
|
- `appointments(tenant_id, start_at)` — for calendar queries
|
||||||
|
- `case_events(case_id, created_at)` — for event feeds
|
||||||
|
- `cases(tenant_id, status)` — for filtered lists
|
||||||
|
|
||||||
|
**Missing constraints:**
|
||||||
|
- No CHECK constraint on status values (cases, deadlines, appointments)
|
||||||
|
- No UNIQUE constraint on `case_number` per tenant
|
||||||
|
- No foreign key from `notes` to the parent entity (if polymorphic)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Security Assessment
|
||||||
|
|
||||||
|
### 4.1 Authentication
|
||||||
|
- **JWT validation is correct** — algorithm check (HMAC only), expiry check, sub claim extraction. Using `golang-jwt/v5`.
|
||||||
|
- **Supabase Auth on frontend** — proper cookie-based session with server-side verification in middleware.
|
||||||
|
- **No refresh token rotation** — the API client uses `getSession()` which may serve stale tokens.
|
||||||
|
|
||||||
|
### 4.2 Authorization
|
||||||
|
- **Critical: Tenant isolation bypass** (see 1.1)
|
||||||
|
- **No role-based access control** — `user_tenants` has a `role` column but it's never checked. Any member can do anything.
|
||||||
|
- **No resource-level permissions** — any user in a tenant can delete any case, document, etc.
|
||||||
|
|
||||||
|
### 4.3 Input Validation
|
||||||
|
- **SQL injection: Protected** — all queries use parameterized placeholders.
|
||||||
|
- **XSS: Partially protected** — React auto-escapes, but the API returns raw strings that could contain HTML. The `Content-Disposition` header is vulnerable (see 2.6).
|
||||||
|
- **File upload: Partially protected** — `MaxBytesReader` limits to 50MB, but no file type validation (could upload .exe, .html with scripts, etc.).
|
||||||
|
- **Rate limiting: AI endpoints only** — the rest of the API has no rate limiting. Login/register go through Supabase (which has its own limits), but all CRUD endpoints are unlimited.
|
||||||
|
|
||||||
|
### 4.4 Secrets
|
||||||
|
- **No hardcoded secrets** — all via environment variables. Good.
|
||||||
|
- **CalDAV credentials in plaintext** — see 1.3.
|
||||||
|
- **Supabase service key in backend** — necessary for storage, but this key has full DB access. Should be scoped.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Testing Assessment
|
||||||
|
|
||||||
|
### 5.1 Backend Tests (15 files)
|
||||||
|
- **Integration test** — sets up real DB connection, creates JWT, tests full HTTP flow. Excellent pattern but requires DATABASE_URL (skips otherwise).
|
||||||
|
- **Handler tests** — mock-based unit tests for most handlers. Test JSON parsing, error responses, basic happy paths.
|
||||||
|
- **Service tests** — deadline calculator has solid date arithmetic tests. Holiday service tested. CalDAV service tested with mocks. AI service tested with mocked HTTP.
|
||||||
|
- **Middleware tests** — rate limiter tested.
|
||||||
|
- **Auth tests** — tenant resolver tested.
|
||||||
|
|
||||||
|
### 5.2 Frontend Tests (4 files)
|
||||||
|
- `api.test.ts` — tests the API client
|
||||||
|
- `DeadlineTrafficLights.test.tsx` — component test
|
||||||
|
- `CaseOverviewGrid.test.tsx` — component test
|
||||||
|
- `LoginPage.test.tsx` — auth page test
|
||||||
|
|
||||||
|
### 5.3 What's Missing
|
||||||
|
- **No E2E tests** — no Playwright/Cypress. Critical for a law firm app where correctness matters.
|
||||||
|
- **No contract tests** — frontend and backend are tested independently. A schema change could break the frontend without any test catching it.
|
||||||
|
- **Deadline calculation edge cases** — needs tests for year boundaries, leap years, holidays falling on weekends, multiple consecutive holidays.
|
||||||
|
- **Multi-tenant security tests** — no test verifying that User A can't access Tenant B's data. This is the most important test to add.
|
||||||
|
- **Frontend test coverage is thin** — 4 tests for ~30 components. The dashboard, all forms, navigation, error states are untested.
|
||||||
|
- **No load testing** — unknown how the system behaves under concurrent users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. UX Assessment
|
||||||
|
|
||||||
|
### 6.1 What Works
|
||||||
|
- **Dashboard is strong** — traffic light deadline indicators, upcoming timeline, case overview, quick actions. A lawyer can see what matters at a glance.
|
||||||
|
- **German localization** — UI is in German with proper legal terminology (Akten, Fristen, Termine, Parteien).
|
||||||
|
- **Mobile responsive** — sidebar collapses to hamburger menu, layout uses responsive grids.
|
||||||
|
- **Loading states** — skeleton screens on dashboard, not just spinners.
|
||||||
|
- **Breadcrumbs** — navigation trail on all pages.
|
||||||
|
- **Deadline calculator** — unique feature that provides real value for UPC litigation.
|
||||||
|
|
||||||
|
### 6.2 What a Lawyer Would Stumble On
|
||||||
|
1. **No onboarding flow** — after registration, user has no tenant, no cases. The app shows empty states but doesn't guide the user to create a tenant or import data.
|
||||||
|
2. **No search** — there's no global search. A lawyer with 100+ cases needs to find things fast.
|
||||||
|
3. **No keyboard shortcuts** — power users (lawyers are keyboard-heavy) have no shortcuts.
|
||||||
|
4. **Sidebar mixes languages** — "Akten" (German) vs "AI Analyse" (English). Should be consistent.
|
||||||
|
5. **No notifications** — overdue deadlines don't trigger any alert beyond the dashboard color. No email alerts, no push notifications.
|
||||||
|
6. **No print view** — lawyers need to print deadline lists, case summaries. No print stylesheet.
|
||||||
|
7. **No bulk operations** — can't mark multiple deadlines as complete, can't bulk-assign parties.
|
||||||
|
8. **Document upload has no preview** — uploaded PDFs can't be viewed inline.
|
||||||
|
9. **AI features require manual trigger** — AI summary and deadline extraction are manual. Should auto-trigger on document upload.
|
||||||
|
10. **No activity log per user** — no audit trail of who changed what. Critical for law firm compliance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Deployment Assessment
|
||||||
|
|
||||||
|
### 7.1 Docker Setup
|
||||||
|
- **Multi-stage builds** — both Dockerfiles use builder pattern. Good.
|
||||||
|
- **Backend is minimal** — Alpine + static binary + ca-certificates. ~15MB image.
|
||||||
|
- **Frontend** — Bun for deps/build, Node for runtime (standalone output). Reasonable.
|
||||||
|
- **Missing:** go.sum not copied in backend Dockerfile (see 2.9).
|
||||||
|
- **Missing:** No docker-compose.yml for local development.
|
||||||
|
- **Missing:** No health check in Dockerfile (`HEALTHCHECK` instruction).
|
||||||
|
|
||||||
|
### 7.2 Environment Handling
|
||||||
|
- **Config validates required vars** — `DATABASE_URL` and `SUPABASE_JWT_SECRET` are checked at startup.
|
||||||
|
- **Supabase URL/keys not validated** — if missing, features silently fail or crash at runtime.
|
||||||
|
- **No .env.example** — new developers don't know what env vars are needed.
|
||||||
|
|
||||||
|
### 7.3 Reliability
|
||||||
|
- **No graceful shutdown** (see 2.7)
|
||||||
|
- **No readiness/liveness probes** — `/health` exists but only checks DB connectivity. No readiness distinction.
|
||||||
|
- **CalDAV sync runs in-process** — if the sync goroutine panics, it takes down the API server.
|
||||||
|
- **No structured error recovery** — panics in handlers will crash the process (no recovery middleware).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Competitive Analysis
|
||||||
|
|
||||||
|
### 8.1 The Market
|
||||||
|
|
||||||
|
German Kanzleisoftware is a mature, crowded market:
|
||||||
|
|
||||||
|
| Tool | Type | Price | Key Strength |
|
||||||
|
|------|------|-------|-------------|
|
||||||
|
| **RA-MICRO** | Desktop + Cloud | ~100-200 EUR/user/mo | Market leader, 30+ years, full beA integration |
|
||||||
|
| **ADVOWARE** | Desktop + Cloud | from 20 EUR/mo | Budget-friendly, strong for small firms |
|
||||||
|
| **AnNoText** (Wolters Kluwer) | Desktop + Cloud | Custom pricing | Enterprise, AI document analysis, DictNow |
|
||||||
|
| **Actaport** | Cloud-native | from 79.80 EUR/mo | Modern UI, Mandantenportal, integrated Office |
|
||||||
|
| **Haufe Advolux** | Cloud | Custom | User-friendly, full-featured |
|
||||||
|
| **Renostar Legal Cloud** | Cloud | Custom | Browser-based, no installation |
|
||||||
|
|
||||||
|
### 8.2 Table-Stakes Features KanzlAI is Missing
|
||||||
|
|
||||||
|
These are **mandatory** for any German Kanzleisoftware to be taken seriously:
|
||||||
|
|
||||||
|
1. **beA Integration** — since 2022, German lawyers must use the electronic court mailbox (besonderes elektronisches Anwaltspostfach). No Kanzleisoftware sells without it. This is a **massive** implementation effort (KSW-Schnittstelle from BRAK).
|
||||||
|
|
||||||
|
2. **RVG Billing (Gebührenrechner)** — automated fee calculation per RVG (Rechtsanwaltsvergütungsgesetz). Every competitor has this built-in. Without it, lawyers can't bill clients.
|
||||||
|
|
||||||
|
3. **Document Generation** — templates for Schriftsätze, Klageschriften, Mahnbescheide with auto-populated case data. Usually integrated with Word.
|
||||||
|
|
||||||
|
4. **Accounting (FiBu)** — client trust accounts (Fremdgeld), DATEV export, tax-relevant bookkeeping. Legal requirement.
|
||||||
|
|
||||||
|
5. **Conflict Check (Kollisionsprüfung)** — check if the firm has a conflict of interest before taking a case. Legally required (§ 43a BRAO).
|
||||||
|
|
||||||
|
6. **Dictation System** — voice-to-text for lawyers. RA-MICRO has DictaNet, AnNoText has DictNow.
|
||||||
|
|
||||||
|
### 8.3 Where KanzlAI Could Differentiate
|
||||||
|
|
||||||
|
Despite the feature gap, KanzlAI has some advantages:
|
||||||
|
|
||||||
|
1. **AI-native** — competitors are bolting AI onto 20-year-old software. KanzlAI has Claude API integration from day one. The deadline extraction from PDFs is genuinely useful.
|
||||||
|
2. **UPC specialization** — the deadline calculator with UPC Rules of Procedure knowledge is unique. No competitor has deep UPC litigation support.
|
||||||
|
3. **CalDAV sync** — bidirectional sync with external calendars is not common in German Kanzleisoftware.
|
||||||
|
4. **Modern tech stack** — React + Go + Supabase vs. the .NET/Java/Desktop world of RA-MICRO et al.
|
||||||
|
5. **Multi-tenant from day 1** — designed for SaaS, not converted from desktop software.
|
||||||
|
|
||||||
|
### 8.4 Strategic Recommendation
|
||||||
|
|
||||||
|
**Don't compete head-on with RA-MICRO.** The feature gap is 10+ person-years of work. Instead:
|
||||||
|
|
||||||
|
**Option A: UPC Niche Tool** — Pivot back to UPC patent litigation. Build the best deadline calculator, case tracker, and AI-powered brief analysis tool for UPC practitioners. There are ~1000 UPC practitioners in Europe who need specialized tooling that RA-MICRO doesn't provide. Charge 200-500 EUR/mo.
|
||||||
|
|
||||||
|
**Option B: AI-First Legal Assistant** — Don't call it "Kanzleimanagement." Position as an AI assistant that reads court documents, extracts deadlines, and syncs to the lawyer's existing Kanzleisoftware via CalDAV/iCal. This sidesteps the feature gap entirely.
|
||||||
|
|
||||||
|
**Option C: Full Kanzleisoftware** — If you pursue this, beA integration is the first priority, then RVG billing. Without these two, no German lawyer will switch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Strengths (What's Good, Keep Doing It)
|
||||||
|
|
||||||
|
1. **Architecture is solid** — the Go + Next.js + Supabase stack is well-chosen. Clean separation of concerns.
|
||||||
|
2. **SQL is safe** — parameterized queries throughout. No injection vectors.
|
||||||
|
3. **Multi-tenant design** — tenant_id scoping with RLS is the right approach.
|
||||||
|
4. **CalDAV implementation** — genuinely impressive for an MVP. Bidirectional sync with conflict resolution.
|
||||||
|
5. **Deadline calculator** — ported from youpc.org with holiday awareness. Real domain value.
|
||||||
|
6. **AI integration** — Claude API with tool use for structured extraction. Clean implementation.
|
||||||
|
7. **Dashboard UX** — traffic lights, timeline, quick actions. Lawyers will get this immediately.
|
||||||
|
8. **German-first** — proper legal terminology, German date formats, localized UI.
|
||||||
|
9. **Test foundation** — 15 backend test files with integration tests. Good starting point.
|
||||||
|
10. **Docker builds are lean** — multi-stage, Alpine-based, standalone Next.js output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Priority Roadmap
|
||||||
|
|
||||||
|
### P0 — This Week
|
||||||
|
- [ ] Fix tenant isolation bypass in TenantResolver (1.1)
|
||||||
|
- [ ] Consolidate tenant resolution logic (1.2)
|
||||||
|
- [ ] Encrypt CalDAV credentials at rest (1.3)
|
||||||
|
- [ ] Add CORS middleware + security headers (1.4)
|
||||||
|
- [ ] Stop leaking internal errors to clients (1.5)
|
||||||
|
- [ ] Add mutex to HolidayService cache (1.6)
|
||||||
|
- [ ] Fix rate limiter X-Forwarded-For bypass (1.7)
|
||||||
|
- [ ] Fix Dockerfile go.sum copy (2.9)
|
||||||
|
|
||||||
|
### P1 — Before Demo/Beta
|
||||||
|
- [ ] Add input validation (length limits, allowed values) (2.1)
|
||||||
|
- [ ] Add pagination to all list endpoints (2.2)
|
||||||
|
- [ ] Fix `search_path` connection pool issue (2.8)
|
||||||
|
- [ ] Add graceful shutdown with signal handling (2.7)
|
||||||
|
- [ ] Sanitize Content-Disposition filename (2.6)
|
||||||
|
- [ ] Fix German umlaut typos throughout frontend (2.10)
|
||||||
|
- [ ] Handle createEvent errors instead of swallowing (2.11)
|
||||||
|
- [ ] Add React error boundaries (2.12)
|
||||||
|
- [ ] Implement RLS policies on all tenant-scoped tables (2.13)
|
||||||
|
- [ ] Add multi-tenant security tests
|
||||||
|
- [ ] Add database migrations system
|
||||||
|
- [ ] Add `.env.example` file
|
||||||
|
- [ ] Add onboarding flow for new users
|
||||||
|
|
||||||
|
### P2 — Next Iteration
|
||||||
|
- [ ] Role-based access control (admin/member/readonly)
|
||||||
|
- [ ] Global search
|
||||||
|
- [ ] Email notifications for overdue deadlines
|
||||||
|
- [ ] Audit trail / activity log per user
|
||||||
|
- [ ] Auto-trigger AI extraction on document upload
|
||||||
|
- [ ] Print-friendly views
|
||||||
|
- [ ] E2E tests with Playwright
|
||||||
|
- [ ] CI/CD pipeline
|
||||||
|
|
||||||
|
### P3 — Strategic
|
||||||
|
- [ ] Decide market positioning (UPC niche vs. AI assistant vs. full Kanzleisoftware)
|
||||||
|
- [ ] If Kanzleisoftware: begin beA integration research
|
||||||
|
- [ ] If Kanzleisoftware: RVG Gebührenrechner
|
||||||
|
- [ ] If UPC niche: integrate lex-research case law database
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This audit was conducted by reading every source file in the repository, running all tests, analyzing the database schema via seed data, and comparing against established German Kanzleisoftware competitors.*
|
||||||
@@ -24,28 +24,19 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
|
|||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
token := extractBearerToken(r)
|
token := extractBearerToken(r)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
http.Error(w, "missing authorization token", http.StatusUnauthorized)
|
http.Error(w, `{"error":"missing authorization token"}`, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, err := m.verifyJWT(token)
|
userID, err := m.verifyJWT(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
|
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := ContextWithUserID(r.Context(), userID)
|
ctx := ContextWithUserID(r.Context(), userID)
|
||||||
|
// Tenant resolution is handled by TenantResolver middleware for scoped routes.
|
||||||
// Resolve tenant from user_tenants
|
// Tenant management routes handle their own access control.
|
||||||
var tenantID uuid.UUID
|
|
||||||
err = m.db.GetContext(r.Context(), &tenantID,
|
|
||||||
"SELECT tenant_id FROM user_tenants WHERE user_id = $1 LIMIT 1", userID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "no tenant found for user", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx = ContextWithTenantID(ctx, tenantID)
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,21 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TenantLookup resolves the default tenant for a user.
|
// TenantLookup resolves and verifies tenant access for a user.
|
||||||
// Defined as an interface to avoid circular dependency with services.
|
// Defined as an interface to avoid circular dependency with services.
|
||||||
type TenantLookup interface {
|
type TenantLookup interface {
|
||||||
FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error)
|
FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error)
|
||||||
|
VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TenantResolver is middleware that resolves the tenant from X-Tenant-ID header
|
// TenantResolver is middleware that resolves the tenant from X-Tenant-ID header
|
||||||
// or defaults to the user's first tenant.
|
// or defaults to the user's first tenant. Always verifies user has access.
|
||||||
type TenantResolver struct {
|
type TenantResolver struct {
|
||||||
lookup TenantLookup
|
lookup TenantLookup
|
||||||
}
|
}
|
||||||
@@ -28,7 +29,7 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
|
|||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := UserFromContext(r.Context())
|
userID, ok := UserFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,19 +38,33 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
|
|||||||
if header := r.Header.Get("X-Tenant-ID"); header != "" {
|
if header := r.Header.Get("X-Tenant-ID"); header != "" {
|
||||||
parsed, err := uuid.Parse(header)
|
parsed, err := uuid.Parse(header)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("invalid X-Tenant-ID: %v", err), http.StatusBadRequest)
|
http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify user has access to this tenant
|
||||||
|
hasAccess, err := tr.lookup.VerifyAccess(r.Context(), userID, parsed)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("tenant access check failed", "error", err, "user_id", userID, "tenant_id", parsed)
|
||||||
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !hasAccess {
|
||||||
|
http.Error(w, `{"error":"no access to tenant"}`, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
tenantID = parsed
|
tenantID = parsed
|
||||||
} else {
|
} else {
|
||||||
// Default to user's first tenant
|
// Default to user's first tenant
|
||||||
first, err := tr.lookup.FirstTenantForUser(r.Context(), userID)
|
first, err := tr.lookup.FirstTenantForUser(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("resolving tenant: %v", err), http.StatusInternalServerError)
|
slog.Error("failed to resolve default tenant", "error", err, "user_id", userID)
|
||||||
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if first == nil {
|
if first == nil {
|
||||||
http.Error(w, "no tenant found for user", http.StatusBadRequest)
|
http.Error(w, `{"error":"no tenant found for user"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tenantID = *first
|
tenantID = *first
|
||||||
|
|||||||
@@ -10,17 +10,23 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type mockTenantLookup struct {
|
type mockTenantLookup struct {
|
||||||
tenantID *uuid.UUID
|
tenantID *uuid.UUID
|
||||||
err error
|
err error
|
||||||
|
hasAccess bool
|
||||||
|
accessErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
|
func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
|
||||||
return m.tenantID, m.err
|
return m.tenantID, m.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) {
|
||||||
|
return m.hasAccess, m.accessErr
|
||||||
|
}
|
||||||
|
|
||||||
func TestTenantResolver_FromHeader(t *testing.T) {
|
func TestTenantResolver_FromHeader(t *testing.T) {
|
||||||
tenantID := uuid.New()
|
tenantID := uuid.New()
|
||||||
tr := NewTenantResolver(&mockTenantLookup{})
|
tr := NewTenantResolver(&mockTenantLookup{hasAccess: true})
|
||||||
|
|
||||||
var gotTenantID uuid.UUID
|
var gotTenantID uuid.UUID
|
||||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -47,6 +53,26 @@ func TestTenantResolver_FromHeader(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTenantResolver_FromHeader_NoAccess(t *testing.T) {
|
||||||
|
tenantID := uuid.New()
|
||||||
|
tr := NewTenantResolver(&mockTenantLookup{hasAccess: false})
|
||||||
|
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("next should not be called")
|
||||||
|
})
|
||||||
|
|
||||||
|
r := httptest.NewRequest("GET", "/api/cases", nil)
|
||||||
|
r.Header.Set("X-Tenant-ID", tenantID.String())
|
||||||
|
r = r.WithContext(ContextWithUserID(r.Context(), uuid.New()))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
tr.Resolve(next).ServeHTTP(w, r)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected 403, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTenantResolver_DefaultsToFirst(t *testing.T) {
|
func TestTenantResolver_DefaultsToFirst(t *testing.T) {
|
||||||
tenantID := uuid.New()
|
tenantID := uuid.New()
|
||||||
tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID})
|
tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID})
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type Config struct {
|
|||||||
SupabaseServiceKey string
|
SupabaseServiceKey string
|
||||||
SupabaseJWTSecret string
|
SupabaseJWTSecret string
|
||||||
AnthropicAPIKey string
|
AnthropicAPIKey string
|
||||||
|
FrontendOrigin string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -24,6 +25,7 @@ func Load() (*Config, error) {
|
|||||||
SupabaseServiceKey: os.Getenv("SUPABASE_SERVICE_KEY"),
|
SupabaseServiceKey: os.Getenv("SUPABASE_SERVICE_KEY"),
|
||||||
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
|
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
|
||||||
AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
|
AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
|
||||||
|
FrontendOrigin: getEnv("FRONTEND_ORIGIN", "https://kanzlai.msbls.de"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.DatabaseURL == "" {
|
if cfg.DatabaseURL == "" {
|
||||||
|
|||||||
@@ -5,18 +5,16 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||||
|
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AIHandler struct {
|
type AIHandler struct {
|
||||||
ai *services.AIService
|
ai *services.AIService
|
||||||
db *sqlx.DB
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAIHandler(ai *services.AIService, db *sqlx.DB) *AIHandler {
|
func NewAIHandler(ai *services.AIService) *AIHandler {
|
||||||
return &AIHandler{ai: ai, db: db}
|
return &AIHandler{ai: ai}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractDeadlines handles POST /api/ai/extract-deadlines
|
// ExtractDeadlines handles POST /api/ai/extract-deadlines
|
||||||
@@ -61,10 +59,14 @@ func (h *AIHandler) ExtractDeadlines(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "provide either a PDF file or text")
|
writeError(w, http.StatusBadRequest, "provide either a PDF file or text")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(text) > maxDescriptionLen {
|
||||||
|
writeError(w, http.StatusBadRequest, "text exceeds maximum length")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
deadlines, err := h.ai.ExtractDeadlines(r.Context(), pdfData, text)
|
deadlines, err := h.ai.ExtractDeadlines(r.Context(), pdfData, text)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "AI extraction failed: "+err.Error())
|
internalError(w, "AI deadline extraction failed", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,9 +79,9 @@ func (h *AIHandler) ExtractDeadlines(w http.ResponseWriter, r *http.Request) {
|
|||||||
// SummarizeCase handles POST /api/ai/summarize-case
|
// SummarizeCase handles POST /api/ai/summarize-case
|
||||||
// Accepts JSON {"case_id": "uuid"}.
|
// Accepts JSON {"case_id": "uuid"}.
|
||||||
func (h *AIHandler) SummarizeCase(w http.ResponseWriter, r *http.Request) {
|
func (h *AIHandler) SummarizeCase(w http.ResponseWriter, r *http.Request) {
|
||||||
tenantID, err := resolveTenant(r, h.db)
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
if err != nil {
|
if !ok {
|
||||||
handleTenantError(w, err)
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +106,7 @@ func (h *AIHandler) SummarizeCase(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
summary, err := h.ai.SummarizeCase(r.Context(), tenantID, caseID)
|
summary, err := h.ai.SummarizeCase(r.Context(), tenantID, caseID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "AI summarization failed: "+err.Error())
|
internalError(w, "AI case summarization failed", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func TestAIExtractDeadlines_InvalidJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAISummarizeCase_MissingCaseID(t *testing.T) {
|
func TestAISummarizeCase_MissingTenant(t *testing.T) {
|
||||||
h := &AIHandler{}
|
h := &AIHandler{}
|
||||||
|
|
||||||
body := `{"case_id":""}`
|
body := `{"case_id":""}`
|
||||||
@@ -52,9 +52,9 @@ func TestAISummarizeCase_MissingCaseID(t *testing.T) {
|
|||||||
|
|
||||||
h.SummarizeCase(w, r)
|
h.SummarizeCase(w, r)
|
||||||
|
|
||||||
// Without auth context, the resolveTenant will fail first
|
// Without tenant context, TenantFromContext returns !ok → 403
|
||||||
if w.Code != http.StatusUnauthorized {
|
if w.Code != http.StatusForbidden {
|
||||||
t.Errorf("expected 401, got %d", w.Code)
|
t.Errorf("expected 403, got %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,8 +67,8 @@ func TestAISummarizeCase_InvalidJSON(t *testing.T) {
|
|||||||
|
|
||||||
h.SummarizeCase(w, r)
|
h.SummarizeCase(w, r)
|
||||||
|
|
||||||
// Without auth context, the resolveTenant will fail first
|
// Without tenant context, TenantFromContext returns !ok → 403
|
||||||
if w.Code != http.StatusUnauthorized {
|
if w.Code != http.StatusForbidden {
|
||||||
t.Errorf("expected 401, got %d", w.Code)
|
t.Errorf("expected 403, got %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ func (h *AppointmentHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "title is required")
|
writeError(w, http.StatusBadRequest, "title is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if msg := validateStringLength("title", req.Title, maxTitleLen); msg != "" {
|
||||||
|
writeError(w, http.StatusBadRequest, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
if req.StartAt.IsZero() {
|
if req.StartAt.IsZero() {
|
||||||
writeError(w, http.StatusBadRequest, "start_at is required")
|
writeError(w, http.StatusBadRequest, "start_at is required")
|
||||||
return
|
return
|
||||||
@@ -188,6 +192,10 @@ func (h *AppointmentHandler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "title is required")
|
writeError(w, http.StatusBadRequest, "title is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if msg := validateStringLength("title", req.Title, maxTitleLen); msg != "" {
|
||||||
|
writeError(w, http.StatusBadRequest, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
if req.StartAt.IsZero() {
|
if req.StartAt.IsZero() {
|
||||||
writeError(w, http.StatusBadRequest, "start_at is required")
|
writeError(w, http.StatusBadRequest, "start_at is required")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func (h *CalDAVHandler) TriggerSync(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
cfg, err := h.svc.LoadTenantConfig(tenantID)
|
cfg, err := h.svc.LoadTenantConfig(tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, "CalDAV not configured for this tenant")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,18 +28,25 @@ func (h *CaseHandler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||||
|
limit, offset = clampPagination(limit, offset)
|
||||||
|
|
||||||
|
search := r.URL.Query().Get("search")
|
||||||
|
if msg := validateStringLength("search", search, maxSearchLen); msg != "" {
|
||||||
|
writeError(w, http.StatusBadRequest, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
filter := services.CaseFilter{
|
filter := services.CaseFilter{
|
||||||
Status: r.URL.Query().Get("status"),
|
Status: r.URL.Query().Get("status"),
|
||||||
Type: r.URL.Query().Get("type"),
|
Type: r.URL.Query().Get("type"),
|
||||||
Search: r.URL.Query().Get("search"),
|
Search: search,
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
Offset: offset,
|
Offset: offset,
|
||||||
}
|
}
|
||||||
|
|
||||||
cases, total, err := h.svc.List(r.Context(), tenantID, filter)
|
cases, total, err := h.svc.List(r.Context(), tenantID, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
internalError(w, "failed to list cases", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,10 +73,18 @@ func (h *CaseHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "case_number and title are required")
|
writeError(w, http.StatusBadRequest, "case_number and title are required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if msg := validateStringLength("case_number", input.CaseNumber, maxCaseNumberLen); msg != "" {
|
||||||
|
writeError(w, http.StatusBadRequest, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msg := validateStringLength("title", input.Title, maxTitleLen); msg != "" {
|
||||||
|
writeError(w, http.StatusBadRequest, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
c, err := h.svc.Create(r.Context(), tenantID, userID, input)
|
c, err := h.svc.Create(r.Context(), tenantID, userID, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
internalError(w, "failed to create case", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +106,7 @@ func (h *CaseHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
detail, err := h.svc.GetByID(r.Context(), tenantID, caseID)
|
detail, err := h.svc.GetByID(r.Context(), tenantID, caseID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
internalError(w, "failed to get case", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if detail == nil {
|
if detail == nil {
|
||||||
@@ -121,10 +136,22 @@ func (h *CaseHandler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if input.Title != nil {
|
||||||
|
if msg := validateStringLength("title", *input.Title, maxTitleLen); msg != "" {
|
||||||
|
writeError(w, http.StatusBadRequest, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.CaseNumber != nil {
|
||||||
|
if msg := validateStringLength("case_number", *input.CaseNumber, maxCaseNumberLen); msg != "" {
|
||||||
|
writeError(w, http.StatusBadRequest, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updated, err := h.svc.Update(r.Context(), tenantID, caseID, userID, input)
|
updated, err := h.svc.Update(r.Context(), tenantID, caseID, userID, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
internalError(w, "failed to update case", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if updated == nil {
|
if updated == nil {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
data, err := h.svc.Get(r.Context(), tenantID)
|
data, err := h.svc.Get(r.Context(), tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
internalError(w, "failed to load dashboard", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,27 +4,25 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||||
|
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeadlineHandlers holds handlers for deadline CRUD endpoints
|
// DeadlineHandlers holds handlers for deadline CRUD endpoints
|
||||||
type DeadlineHandlers struct {
|
type DeadlineHandlers struct {
|
||||||
deadlines *services.DeadlineService
|
deadlines *services.DeadlineService
|
||||||
db *sqlx.DB
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDeadlineHandlers creates deadline handlers
|
// NewDeadlineHandlers creates deadline handlers
|
||||||
func NewDeadlineHandlers(ds *services.DeadlineService, db *sqlx.DB) *DeadlineHandlers {
|
func NewDeadlineHandlers(ds *services.DeadlineService) *DeadlineHandlers {
|
||||||
return &DeadlineHandlers{deadlines: ds, db: db}
|
return &DeadlineHandlers{deadlines: ds}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get handles GET /api/deadlines/{deadlineID}
|
// Get handles GET /api/deadlines/{deadlineID}
|
||||||
func (h *DeadlineHandlers) Get(w http.ResponseWriter, r *http.Request) {
|
func (h *DeadlineHandlers) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
tenantID, err := resolveTenant(r, h.db)
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
if err != nil {
|
if !ok {
|
||||||
handleTenantError(w, err)
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +34,7 @@ func (h *DeadlineHandlers) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
deadline, err := h.deadlines.GetByID(tenantID, deadlineID)
|
deadline, err := h.deadlines.GetByID(tenantID, deadlineID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to fetch deadline")
|
internalError(w, "failed to fetch deadline", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if deadline == nil {
|
if deadline == nil {
|
||||||
@@ -49,15 +47,15 @@ func (h *DeadlineHandlers) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// ListAll handles GET /api/deadlines
|
// ListAll handles GET /api/deadlines
|
||||||
func (h *DeadlineHandlers) ListAll(w http.ResponseWriter, r *http.Request) {
|
func (h *DeadlineHandlers) ListAll(w http.ResponseWriter, r *http.Request) {
|
||||||
tenantID, err := resolveTenant(r, h.db)
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
if err != nil {
|
if !ok {
|
||||||
handleTenantError(w, err)
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
deadlines, err := h.deadlines.ListAll(tenantID)
|
deadlines, err := h.deadlines.ListAll(tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to list deadlines")
|
internalError(w, "failed to list deadlines", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,9 +64,9 @@ func (h *DeadlineHandlers) ListAll(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// ListForCase handles GET /api/cases/{caseID}/deadlines
|
// ListForCase handles GET /api/cases/{caseID}/deadlines
|
||||||
func (h *DeadlineHandlers) ListForCase(w http.ResponseWriter, r *http.Request) {
|
func (h *DeadlineHandlers) ListForCase(w http.ResponseWriter, r *http.Request) {
|
||||||
tenantID, err := resolveTenant(r, h.db)
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
if err != nil {
|
if !ok {
|
||||||
handleTenantError(w, err)
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +78,7 @@ func (h *DeadlineHandlers) ListForCase(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
deadlines, err := h.deadlines.ListForCase(tenantID, caseID)
|
deadlines, err := h.deadlines.ListForCase(tenantID, caseID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to list deadlines")
|
internalError(w, "failed to list deadlines for case", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,9 +87,9 @@ func (h *DeadlineHandlers) ListForCase(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Create handles POST /api/cases/{caseID}/deadlines
|
// Create handles POST /api/cases/{caseID}/deadlines
|
||||||
func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) {
|
func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
tenantID, err := resolveTenant(r, h.db)
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
if err != nil {
|
if !ok {
|
||||||
handleTenantError(w, err)
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,10 +110,14 @@ func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "title and due_date are required")
|
writeError(w, http.StatusBadRequest, "title and due_date are required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if msg := validateStringLength("title", input.Title, maxTitleLen); msg != "" {
|
||||||
|
writeError(w, http.StatusBadRequest, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
deadline, err := h.deadlines.Create(tenantID, input)
|
deadline, err := h.deadlines.Create(tenantID, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create deadline")
|
internalError(w, "failed to create deadline", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,9 +126,9 @@ func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Update handles PUT /api/deadlines/{deadlineID}
|
// Update handles PUT /api/deadlines/{deadlineID}
|
||||||
func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) {
|
func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
tenantID, err := resolveTenant(r, h.db)
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
if err != nil {
|
if !ok {
|
||||||
handleTenantError(w, err)
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +146,7 @@ func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
deadline, err := h.deadlines.Update(tenantID, deadlineID, input)
|
deadline, err := h.deadlines.Update(tenantID, deadlineID, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to update deadline")
|
internalError(w, "failed to update deadline", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if deadline == nil {
|
if deadline == nil {
|
||||||
@@ -157,9 +159,9 @@ func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Complete handles PATCH /api/deadlines/{deadlineID}/complete
|
// Complete handles PATCH /api/deadlines/{deadlineID}/complete
|
||||||
func (h *DeadlineHandlers) Complete(w http.ResponseWriter, r *http.Request) {
|
func (h *DeadlineHandlers) Complete(w http.ResponseWriter, r *http.Request) {
|
||||||
tenantID, err := resolveTenant(r, h.db)
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
if err != nil {
|
if !ok {
|
||||||
handleTenantError(w, err)
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +173,7 @@ func (h *DeadlineHandlers) Complete(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
deadline, err := h.deadlines.Complete(tenantID, deadlineID)
|
deadline, err := h.deadlines.Complete(tenantID, deadlineID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to complete deadline")
|
internalError(w, "failed to complete deadline", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if deadline == nil {
|
if deadline == nil {
|
||||||
@@ -184,9 +186,9 @@ func (h *DeadlineHandlers) Complete(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Delete handles DELETE /api/deadlines/{deadlineID}
|
// Delete handles DELETE /api/deadlines/{deadlineID}
|
||||||
func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) {
|
func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
tenantID, err := resolveTenant(r, h.db)
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
if err != nil {
|
if !ok {
|
||||||
handleTenantError(w, err)
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,9 +198,8 @@ func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.deadlines.Delete(tenantID, deadlineID)
|
if err := h.deadlines.Delete(tenantID, deadlineID); err != nil {
|
||||||
if err != nil {
|
writeError(w, http.StatusNotFound, "deadline not found")
|
||||||
writeError(w, http.StatusNotFound, err.Error())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func (h *DocumentHandler) ListByCase(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
docs, err := h.svc.ListByCase(r.Context(), tenantID, caseID)
|
docs, err := h.svc.ListByCase(r.Context(), tenantID, caseID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
internalError(w, "failed to list documents", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ func (h *DocumentHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusNotFound, "case not found")
|
writeError(w, http.StatusNotFound, "case not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
internalError(w, "failed to upload document", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,16 +121,16 @@ func (h *DocumentHandler) Download(w http.ResponseWriter, r *http.Request) {
|
|||||||
body, contentType, title, err := h.svc.Download(r.Context(), tenantID, docID)
|
body, contentType, title, err := h.svc.Download(r.Context(), tenantID, docID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "document not found" || err.Error() == "document has no file" {
|
if err.Error() == "document not found" || err.Error() == "document has no file" {
|
||||||
writeError(w, http.StatusNotFound, err.Error())
|
writeError(w, http.StatusNotFound, "document not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
internalError(w, "failed to download document", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer body.Close()
|
defer body.Close()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", contentType)
|
w.Header().Set("Content-Type", contentType)
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, title))
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, sanitizeFilename(title)))
|
||||||
io.Copy(w, body)
|
io.Copy(w, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ func (h *DocumentHandler) GetMeta(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
doc, err := h.svc.GetByID(r.Context(), tenantID, docID)
|
doc, err := h.svc.GetByID(r.Context(), tenantID, docID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
internalError(w, "failed to get document metadata", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if doc == nil {
|
if doc == nil {
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jmoiron/sqlx"
|
|
||||||
|
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
@@ -20,62 +20,9 @@ func writeError(w http.ResponseWriter, status int, msg string) {
|
|||||||
writeJSON(w, status, map[string]string{"error": msg})
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveTenant gets the tenant ID for the authenticated user.
|
// internalError logs the real error and returns a generic message to the client.
|
||||||
// Checks X-Tenant-ID header first, then falls back to user's first tenant.
|
func internalError(w http.ResponseWriter, msg string, err error) {
|
||||||
func resolveTenant(r *http.Request, db *sqlx.DB) (uuid.UUID, error) {
|
slog.Error(msg, "error", err)
|
||||||
userID, ok := auth.UserFromContext(r.Context())
|
|
||||||
if !ok {
|
|
||||||
return uuid.Nil, errUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check header first
|
|
||||||
if headerVal := r.Header.Get("X-Tenant-ID"); headerVal != "" {
|
|
||||||
tenantID, err := uuid.Parse(headerVal)
|
|
||||||
if err != nil {
|
|
||||||
return uuid.Nil, errInvalidTenant
|
|
||||||
}
|
|
||||||
// Verify user has access to this tenant
|
|
||||||
var count int
|
|
||||||
err = db.Get(&count,
|
|
||||||
`SELECT COUNT(*) FROM user_tenants WHERE user_id = $1 AND tenant_id = $2`,
|
|
||||||
userID, tenantID)
|
|
||||||
if err != nil || count == 0 {
|
|
||||||
return uuid.Nil, errTenantAccess
|
|
||||||
}
|
|
||||||
return tenantID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to user's first tenant
|
|
||||||
var tenantID uuid.UUID
|
|
||||||
err := db.Get(&tenantID,
|
|
||||||
`SELECT tenant_id FROM user_tenants WHERE user_id = $1 ORDER BY created_at LIMIT 1`,
|
|
||||||
userID)
|
|
||||||
if err != nil {
|
|
||||||
return uuid.Nil, errNoTenant
|
|
||||||
}
|
|
||||||
return tenantID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type apiError struct {
|
|
||||||
msg string
|
|
||||||
status int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *apiError) Error() string { return e.msg }
|
|
||||||
|
|
||||||
var (
|
|
||||||
errUnauthorized = &apiError{msg: "unauthorized", status: http.StatusUnauthorized}
|
|
||||||
errInvalidTenant = &apiError{msg: "invalid tenant ID", status: http.StatusBadRequest}
|
|
||||||
errTenantAccess = &apiError{msg: "no access to tenant", status: http.StatusForbidden}
|
|
||||||
errNoTenant = &apiError{msg: "no tenant found for user", status: http.StatusBadRequest}
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleTenantError writes the appropriate error response for tenant resolution errors
|
|
||||||
func handleTenantError(w http.ResponseWriter, err error) {
|
|
||||||
if ae, ok := err.(*apiError); ok {
|
|
||||||
writeError(w, ae.status, ae.msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeError(w, http.StatusInternalServerError, "internal error")
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,3 +35,74 @@ func parsePathUUID(r *http.Request, key string) (uuid.UUID, error) {
|
|||||||
func parseUUID(s string) (uuid.UUID, error) {
|
func parseUUID(s string) (uuid.UUID, error) {
|
||||||
return uuid.Parse(s)
|
return uuid.Parse(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Input validation helpers ---
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxTitleLen = 500
|
||||||
|
maxDescriptionLen = 10000
|
||||||
|
maxCaseNumberLen = 100
|
||||||
|
maxSearchLen = 200
|
||||||
|
maxPaginationLimit = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
// validateStringLength checks if a string exceeds the given max length.
|
||||||
|
func validateStringLength(field, value string, maxLen int) string {
|
||||||
|
if utf8.RuneCountInString(value) > maxLen {
|
||||||
|
return field + " exceeds maximum length"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// clampPagination enforces sane pagination defaults and limits.
|
||||||
|
func clampPagination(limit, offset int) (int, int) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
if limit > maxPaginationLimit {
|
||||||
|
limit = maxPaginationLimit
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
return limit, offset
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeFilename removes characters unsafe for Content-Disposition headers.
|
||||||
|
func sanitizeFilename(name string) string {
|
||||||
|
// Remove control characters, quotes, and backslashes
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range name {
|
||||||
|
if r < 32 || r == '"' || r == '\\' || r == '/' {
|
||||||
|
b.WriteRune('_')
|
||||||
|
} else {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskSettingsPassword masks the CalDAV password in tenant settings JSON before returning to clients.
|
||||||
|
func maskSettingsPassword(settings json.RawMessage) json.RawMessage {
|
||||||
|
if len(settings) == 0 {
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
var m map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(settings, &m); err != nil {
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
caldavRaw, ok := m["caldav"]
|
||||||
|
if !ok {
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
var caldav map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(caldavRaw, &caldav); err != nil {
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
if _, ok := caldav["password"]; ok {
|
||||||
|
caldav["password"], _ = json.Marshal("********")
|
||||||
|
}
|
||||||
|
m["caldav"], _ = json.Marshal(caldav)
|
||||||
|
result, _ := json.Marshal(m)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ func (h *NoteHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "content is required")
|
writeError(w, http.StatusBadRequest, "content is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if msg := validateStringLength("content", input.Content, maxDescriptionLen); msg != "" {
|
||||||
|
writeError(w, http.StatusBadRequest, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var createdBy *uuid.UUID
|
var createdBy *uuid.UUID
|
||||||
if userID != uuid.Nil {
|
if userID != uuid.Nil {
|
||||||
@@ -100,6 +104,10 @@ func (h *NoteHandler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "content is required")
|
writeError(w, http.StatusBadRequest, "content is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if msg := validateStringLength("content", req.Content, maxDescriptionLen); msg != "" {
|
||||||
|
writeError(w, http.StatusBadRequest, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
note, err := h.svc.Update(r.Context(), tenantID, noteID, req.Content)
|
note, err := h.svc.Update(r.Context(), tenantID, noteID, req.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func (h *PartyHandler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
parties, err := h.svc.ListByCase(r.Context(), tenantID, caseID)
|
parties, err := h.svc.ListByCase(r.Context(), tenantID, caseID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
internalError(w, "failed to list parties", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,13 +67,18 @@ func (h *PartyHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if msg := validateStringLength("name", input.Name, maxTitleLen); msg != "" {
|
||||||
|
writeError(w, http.StatusBadRequest, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
party, err := h.svc.Create(r.Context(), tenantID, caseID, userID, input)
|
party, err := h.svc.Create(r.Context(), tenantID, caseID, userID, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
writeError(w, http.StatusNotFound, "case not found")
|
writeError(w, http.StatusNotFound, "case not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
internalError(w, "failed to create party", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +106,7 @@ func (h *PartyHandler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
updated, err := h.svc.Update(r.Context(), tenantID, partyID, input)
|
updated, err := h.svc.Update(r.Context(), tenantID, partyID, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
internalError(w, "failed to update party", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if updated == nil {
|
if updated == nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -41,7 +42,8 @@ func (h *TenantHandler) CreateTenant(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
tenant, err := h.svc.Create(r.Context(), userID, req.Name, req.Slug)
|
tenant, err := h.svc.Create(r.Context(), userID, req.Name, req.Slug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
slog.Error("failed to create tenant", "error", err)
|
||||||
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,10 +60,16 @@ func (h *TenantHandler) ListTenants(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
tenants, err := h.svc.ListForUser(r.Context(), userID)
|
tenants, err := h.svc.ListForUser(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
slog.Error("failed to list tenants", "error", err)
|
||||||
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mask CalDAV passwords in tenant settings
|
||||||
|
for i := range tenants {
|
||||||
|
tenants[i].Settings = maskSettingsPassword(tenants[i].Settings)
|
||||||
|
}
|
||||||
|
|
||||||
jsonResponse(w, tenants, http.StatusOK)
|
jsonResponse(w, tenants, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +90,8 @@ func (h *TenantHandler) GetTenant(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Verify user has access to this tenant
|
// Verify user has access to this tenant
|
||||||
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
|
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
slog.Error("failed to get user role", "error", err)
|
||||||
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if role == "" {
|
if role == "" {
|
||||||
@@ -92,7 +101,8 @@ func (h *TenantHandler) GetTenant(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
tenant, err := h.svc.GetByID(r.Context(), tenantID)
|
tenant, err := h.svc.GetByID(r.Context(), tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
slog.Error("failed to get tenant", "error", err)
|
||||||
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if tenant == nil {
|
if tenant == nil {
|
||||||
@@ -100,6 +110,9 @@ func (h *TenantHandler) GetTenant(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mask CalDAV password before returning
|
||||||
|
tenant.Settings = maskSettingsPassword(tenant.Settings)
|
||||||
|
|
||||||
jsonResponse(w, tenant, http.StatusOK)
|
jsonResponse(w, tenant, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +133,8 @@ func (h *TenantHandler) InviteUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Only owners and admins can invite
|
// Only owners and admins can invite
|
||||||
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
|
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
slog.Error("failed to get user role", "error", err)
|
||||||
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if role != "owner" && role != "admin" {
|
if role != "owner" && role != "admin" {
|
||||||
@@ -150,7 +164,8 @@ func (h *TenantHandler) InviteUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
ut, err := h.svc.InviteByEmail(r.Context(), tenantID, req.Email, req.Role)
|
ut, err := h.svc.InviteByEmail(r.Context(), tenantID, req.Email, req.Role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, err.Error(), http.StatusBadRequest)
|
// These are user-facing validation errors (user not found, already member)
|
||||||
|
jsonError(w, "failed to invite user", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +195,8 @@ func (h *TenantHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Only owners and admins can remove members (or user removing themselves)
|
// Only owners and admins can remove members (or user removing themselves)
|
||||||
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
|
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
slog.Error("failed to get user role", "error", err)
|
||||||
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if role != "owner" && role != "admin" && userID != memberID {
|
if role != "owner" && role != "admin" && userID != memberID {
|
||||||
@@ -189,7 +205,8 @@ func (h *TenantHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.svc.RemoveMember(r.Context(), tenantID, memberID); err != nil {
|
if err := h.svc.RemoveMember(r.Context(), tenantID, memberID); err != nil {
|
||||||
jsonError(w, err.Error(), http.StatusBadRequest)
|
// These are user-facing validation errors (not a member, last owner, etc.)
|
||||||
|
jsonError(w, "failed to remove member", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +230,8 @@ func (h *TenantHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Only owners and admins can update settings
|
// Only owners and admins can update settings
|
||||||
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
|
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
slog.Error("failed to get user role", "error", err)
|
||||||
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if role != "owner" && role != "admin" {
|
if role != "owner" && role != "admin" {
|
||||||
@@ -229,10 +247,14 @@ func (h *TenantHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
tenant, err := h.svc.UpdateSettings(r.Context(), tenantID, settings)
|
tenant, err := h.svc.UpdateSettings(r.Context(), tenantID, settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
slog.Error("failed to update settings", "error", err)
|
||||||
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mask CalDAV password before returning
|
||||||
|
tenant.Settings = maskSettingsPassword(tenant.Settings)
|
||||||
|
|
||||||
jsonResponse(w, tenant, http.StatusOK)
|
jsonResponse(w, tenant, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +275,8 @@ func (h *TenantHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Verify user has access
|
// Verify user has access
|
||||||
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
|
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
slog.Error("failed to get user role", "error", err)
|
||||||
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if role == "" {
|
if role == "" {
|
||||||
@@ -263,7 +286,8 @@ func (h *TenantHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
members, err := h.svc.ListMembers(r.Context(), tenantID)
|
members, err := h.svc.ListMembers(r.Context(), tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
slog.Error("failed to list members", "error", err)
|
||||||
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
49
backend/internal/middleware/security.go
Normal file
49
backend/internal/middleware/security.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SecurityHeaders adds standard security headers to all responses.
|
||||||
|
func SecurityHeaders(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Frame-Options", "DENY")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||||
|
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||||
|
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORS returns middleware that restricts cross-origin requests to the given origin.
|
||||||
|
// If allowedOrigin is empty, CORS headers are not set (same-origin only).
|
||||||
|
func CORS(allowedOrigin string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
|
||||||
|
if allowedOrigin != "" && origin != "" && matchOrigin(origin, allowedOrigin) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Tenant-ID")
|
||||||
|
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||||
|
w.Header().Set("Vary", "Origin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle preflight
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchOrigin checks if the request origin matches the allowed origin.
|
||||||
|
func matchOrigin(origin, allowed string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimRight(origin, "/"), strings.TrimRight(allowed, "/"))
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
|||||||
var aiH *handlers.AIHandler
|
var aiH *handlers.AIHandler
|
||||||
if cfg.AnthropicAPIKey != "" {
|
if cfg.AnthropicAPIKey != "" {
|
||||||
aiSvc := services.NewAIService(cfg.AnthropicAPIKey, db)
|
aiSvc := services.NewAIService(cfg.AnthropicAPIKey, db)
|
||||||
aiH = handlers.NewAIHandler(aiSvc, db)
|
aiH = handlers.NewAIHandler(aiSvc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
@@ -48,7 +48,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
|||||||
caseH := handlers.NewCaseHandler(caseSvc)
|
caseH := handlers.NewCaseHandler(caseSvc)
|
||||||
partyH := handlers.NewPartyHandler(partySvc)
|
partyH := handlers.NewPartyHandler(partySvc)
|
||||||
apptH := handlers.NewAppointmentHandler(appointmentSvc)
|
apptH := handlers.NewAppointmentHandler(appointmentSvc)
|
||||||
deadlineH := handlers.NewDeadlineHandlers(deadlineSvc, db)
|
deadlineH := handlers.NewDeadlineHandlers(deadlineSvc)
|
||||||
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
|
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
|
||||||
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
|
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
|
||||||
dashboardH := handlers.NewDashboardHandler(dashboardSvc)
|
dashboardH := handlers.NewDashboardHandler(dashboardSvc)
|
||||||
@@ -149,14 +149,20 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
|||||||
|
|
||||||
mux.Handle("/api/", authMW.RequireAuth(api))
|
mux.Handle("/api/", authMW.RequireAuth(api))
|
||||||
|
|
||||||
return requestLogger(mux)
|
// Apply security middleware stack: CORS -> Security Headers -> Request Logger -> Routes
|
||||||
|
var handler http.Handler = mux
|
||||||
|
handler = requestLogger(handler)
|
||||||
|
handler = middleware.SecurityHeaders(handler)
|
||||||
|
handler = middleware.CORS(cfg.FrontendOrigin)(handler)
|
||||||
|
|
||||||
|
return handler
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleHealth(db *sqlx.DB) http.HandlerFunc {
|
func handleHealth(db *sqlx.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := db.Ping(); err != nil {
|
if err := db.Ping(); err != nil {
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "error", "error": err.Error()})
|
json.NewEncoder(w).Encode(map[string]string{"status": "error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -194,4 +200,3 @@ func requestLogger(next http.Handler) http.Handler {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,19 @@ func (s *TenantService) GetUserRole(ctx context.Context, userID, tenantID uuid.U
|
|||||||
return role, nil
|
return role, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VerifyAccess checks if a user has access to a given tenant.
|
||||||
|
func (s *TenantService) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := s.db.GetContext(ctx, &exists,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM user_tenants WHERE user_id = $1 AND tenant_id = $2)`,
|
||||||
|
userID, tenantID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("verify tenant access: %w", err)
|
||||||
|
}
|
||||||
|
return exists, nil
|
||||||
|
}
|
||||||
|
|
||||||
// FirstTenantForUser returns the user's first tenant (by name), used as default.
|
// FirstTenantForUser returns the user's first tenant (by name), used as default.
|
||||||
func (s *TenantService) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
|
func (s *TenantService) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
|
||||||
var tenantID uuid.UUID
|
var tenantID uuid.UUID
|
||||||
|
|||||||
Reference in New Issue
Block a user