Merge: critical security fixes (JWT verification, Termine leak, role gates, email whitelist)
This commit is contained in:
@@ -26,7 +26,12 @@ func main() {
|
||||
log.Fatal("SUPABASE_URL and SUPABASE_ANON_KEY must be set")
|
||||
}
|
||||
|
||||
client := auth.NewClient(supabaseURL, supabaseAnonKey)
|
||||
jwtSecret := os.Getenv("SUPABASE_JWT_SECRET")
|
||||
if jwtSecret == "" {
|
||||
log.Fatal("SUPABASE_JWT_SECRET must be set — session cookies cannot be trusted without signature verification")
|
||||
}
|
||||
|
||||
client := auth.NewClient(supabaseURL, supabaseAnonKey, []byte(jwtSecret))
|
||||
|
||||
giteaToken := os.Getenv("GITEA_TOKEN")
|
||||
if giteaToken == "" {
|
||||
|
||||
@@ -7,6 +7,10 @@ services:
|
||||
- PORT=8080
|
||||
- SUPABASE_URL=${SUPABASE_URL}
|
||||
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
|
||||
- SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET}
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- CALDAV_ENCRYPTION_KEY=${CALDAV_ENCRYPTION_KEY}
|
||||
- ALLOWED_EMAIL_DOMAINS=${ALLOWED_EMAIL_DOMAINS}
|
||||
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} # Phase H (AI Frist-Extraktion), currently deferred
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -70,7 +70,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"login.confirm.placeholder": "Passwort wiederholen",
|
||||
"login.minchars": "Mind. 8 Zeichen",
|
||||
"login.register.submit": "Registrieren",
|
||||
"login.hint": "Nur f\u00fcr @hoganlovells.com Adressen.",
|
||||
"login.hint": "Nur f\u00fcr autorisierte HLC-E-Mail-Adressen.",
|
||||
"login.error.connection": "Verbindungsfehler. Bitte versuchen Sie es erneut.",
|
||||
"login.error.mismatch": "Passw\u00f6rter stimmen nicht \u00fcberein.",
|
||||
"login.error.minlength": "Passwort muss mindestens 8 Zeichen lang sein.",
|
||||
@@ -843,7 +843,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"login.confirm.placeholder": "Repeat password",
|
||||
"login.minchars": "Min. 8 characters",
|
||||
"login.register.submit": "Register",
|
||||
"login.hint": "Only for @hoganlovells.com addresses.",
|
||||
"login.hint": "Only for authorised HLC email addresses.",
|
||||
"login.error.connection": "Connection error. Please try again.",
|
||||
"login.error.mismatch": "Passwords do not match.",
|
||||
"login.error.minlength": "Password must be at least 8 characters.",
|
||||
|
||||
@@ -23,7 +23,7 @@ export function renderLogin(loginJs: string): string {
|
||||
|
||||
<form className="login-form" id="login-form">
|
||||
<label htmlFor="login-email" className="login-label" data-i18n="login.email">E-Mail</label>
|
||||
<input type="email" id="login-email" name="email" placeholder="name@hoganlovells.com" required autofocus autocomplete="email" className="login-input" />
|
||||
<input type="email" id="login-email" name="email" placeholder="name@hlc.com" required autofocus autocomplete="email" className="login-input" />
|
||||
<label htmlFor="login-password" className="login-label" data-i18n="login.password">Passwort</label>
|
||||
<input type="password" id="login-password" name="password" placeholder="Passwort" data-i18n-placeholder="login.password.placeholder" required autocomplete="current-password" className="login-input" />
|
||||
<button type="submit" className="login-button" data-i18n="login.submit">Anmelden</button>
|
||||
@@ -31,7 +31,7 @@ export function renderLogin(loginJs: string): string {
|
||||
|
||||
<form className="login-form" id="register-form" style="display:none">
|
||||
<label htmlFor="reg-email" className="login-label" data-i18n="login.email">E-Mail</label>
|
||||
<input type="email" id="reg-email" name="email" placeholder="name@hoganlovells.com" required autocomplete="email" className="login-input" />
|
||||
<input type="email" id="reg-email" name="email" placeholder="name@hlc.com" required autocomplete="email" className="login-input" />
|
||||
<label htmlFor="reg-password" className="login-label" data-i18n="login.password">Passwort</label>
|
||||
<input type="password" id="reg-password" name="password" placeholder="Mind. 8 Zeichen" data-i18n-placeholder="login.minchars" required minlength="8" autocomplete="new-password" className="login-input" />
|
||||
<label htmlFor="reg-confirm" className="login-label" data-i18n="login.confirm">Passwort bestätigen</label>
|
||||
@@ -39,7 +39,7 @@ export function renderLogin(loginJs: string): string {
|
||||
<button type="submit" className="login-button" data-i18n="login.register.submit">Registrieren</button>
|
||||
</form>
|
||||
|
||||
<p className="login-hint" data-i18n="login.hint">{"Nur f\u00FCr @hoganlovells.com Adressen."}</p>
|
||||
<p className="login-hint" data-i18n="login.hint">{"Nur f\u00FCr autorisierte HLC-E-Mail-Adressen."}</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
9
go.mod
9
go.mod
@@ -3,8 +3,9 @@ module mgit.msbls.de/m/patholo
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jmoiron/sqlx v1.4.0 // indirect
|
||||
github.com/lib/pq v1.12.3 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/lib/pq v1.12.3
|
||||
)
|
||||
|
||||
64
go.sum
64
go.sum
@@ -1,13 +1,75 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -2,7 +2,6 @@ package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -11,6 +10,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -20,16 +21,21 @@ const (
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
URL string
|
||||
AnonKey string
|
||||
HTTP *http.Client
|
||||
URL string
|
||||
AnonKey string
|
||||
JWTSecret []byte
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func NewClient(supabaseURL, anonKey string) *Client {
|
||||
func NewClient(supabaseURL, anonKey string, jwtSecret []byte) *Client {
|
||||
if len(jwtSecret) == 0 {
|
||||
log.Fatal("SUPABASE_JWT_SECRET must be set — session cookies cannot be trusted without signature verification")
|
||||
}
|
||||
return &Client{
|
||||
URL: strings.TrimRight(supabaseURL, "/"),
|
||||
AnonKey: anonKey,
|
||||
HTTP: &http.Client{Timeout: 10 * time.Second},
|
||||
URL: strings.TrimRight(supabaseURL, "/"),
|
||||
AnonKey: anonKey,
|
||||
JWTSecret: jwtSecret,
|
||||
HTTP: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,70 +156,111 @@ func (c *Client) SignOut(accessToken string) {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// DecodeJWTExpiry reads the exp claim from a JWT without signature verification.
|
||||
func DecodeJWTExpiry(token string) (time.Time, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return time.Time{}, errors.New("invalid token format")
|
||||
}
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("decode payload: %w", err)
|
||||
}
|
||||
|
||||
var claims struct {
|
||||
Exp float64 `json:"exp"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return time.Time{}, fmt.Errorf("parse claims: %w", err)
|
||||
}
|
||||
if claims.Exp == 0 {
|
||||
return time.Time{}, errors.New("no exp claim")
|
||||
}
|
||||
return time.Unix(int64(claims.Exp), 0), nil
|
||||
// VerifiedClaims is the subset of Supabase JWT claims the app cares about.
|
||||
// Populated only after signature + expiry verification succeed.
|
||||
type VerifiedClaims struct {
|
||||
Sub string
|
||||
Exp time.Time
|
||||
}
|
||||
|
||||
// Middleware requires a valid session for protected routes.
|
||||
// VerifyToken parses and fully validates a Supabase access token. It verifies
|
||||
// the HS256 signature against the shared secret, enforces the standard
|
||||
// exp/nbf/iat checks, and extracts the `sub` claim as the authenticated
|
||||
// user's UUID string. Returns an error if the signature is wrong, the token
|
||||
// is expired, or any required claim is missing.
|
||||
func (c *Client) VerifyToken(token string) (*VerifiedClaims, error) {
|
||||
parsed, err := jwt.Parse(token, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return c.JWTSecret, nil
|
||||
}, jwt.WithValidMethods([]string{"HS256"}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := parsed.Claims.(jwt.MapClaims)
|
||||
if !ok || !parsed.Valid {
|
||||
return nil, errors.New("invalid claims")
|
||||
}
|
||||
sub, _ := claims["sub"].(string)
|
||||
if sub == "" {
|
||||
return nil, errors.New("no sub claim")
|
||||
}
|
||||
var exp time.Time
|
||||
if expClaim, err := claims.GetExpirationTime(); err == nil && expClaim != nil {
|
||||
exp = expClaim.Time
|
||||
}
|
||||
return &VerifiedClaims{Sub: sub, Exp: exp}, nil
|
||||
}
|
||||
|
||||
// Middleware requires a valid, signature-verified session for protected routes.
|
||||
// Browser requests get a 302 to /login; API requests get a 401 JSON response.
|
||||
func (c *Client) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sessionCookie, err := r.Cookie(SessionCookieName)
|
||||
if err != nil || sessionCookie.Value == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
rejectUnauthenticated(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
exp, err := DecodeJWTExpiry(sessionCookie.Value)
|
||||
if err != nil {
|
||||
claims, err := c.VerifyToken(sessionCookie.Value)
|
||||
if err == nil {
|
||||
ctx := withVerifiedClaims(r.Context(), claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
// Only attempt refresh if the failure was expiry. Any other signature
|
||||
// error means the token is forged or tampered with — reject.
|
||||
if !errors.Is(err, jwt.ErrTokenExpired) {
|
||||
ClearAuthCookies(w)
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
rejectUnauthenticated(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(exp) {
|
||||
// Access token expired — try refresh
|
||||
refreshCookie, err := r.Cookie(RefreshCookieName)
|
||||
if err != nil || refreshCookie.Value == "" {
|
||||
ClearAuthCookies(w)
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := c.RefreshSession(refreshCookie.Value)
|
||||
if err != nil {
|
||||
log.Printf("token refresh failed: %v", err)
|
||||
ClearAuthCookies(w)
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
SetAuthCookies(w, r, tokens)
|
||||
refreshCookie, err := r.Cookie(RefreshCookieName)
|
||||
if err != nil || refreshCookie.Value == "" {
|
||||
ClearAuthCookies(w)
|
||||
rejectUnauthenticated(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
tokens, err := c.RefreshSession(refreshCookie.Value)
|
||||
if err != nil {
|
||||
log.Printf("token refresh failed: %v", err)
|
||||
ClearAuthCookies(w)
|
||||
rejectUnauthenticated(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
newClaims, err := c.VerifyToken(tokens.AccessToken)
|
||||
if err != nil {
|
||||
log.Printf("refreshed token failed verification: %v", err)
|
||||
ClearAuthCookies(w)
|
||||
rejectUnauthenticated(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
SetAuthCookies(w, r, tokens)
|
||||
ctx := withVerifiedClaims(r.Context(), newClaims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func rejectUnauthenticated(w http.ResponseWriter, r *http.Request) {
|
||||
if isAPIRequest(r) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"unauthenticated"}`))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
|
||||
func isAPIRequest(r *http.Request) bool {
|
||||
return strings.HasPrefix(r.URL.Path, "/api/")
|
||||
}
|
||||
|
||||
// SetAuthCookies writes session and refresh token cookies.
|
||||
func SetAuthCookies(w http.ResponseWriter, r *http.Request, tokens *TokenResponse) {
|
||||
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
||||
|
||||
96
internal/auth/auth_test.go
Normal file
96
internal/auth/auth_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// testSecret mirrors the format of a Supabase JWT signing key.
|
||||
var testSecret = []byte("test-secret-for-hs256-verification-123")
|
||||
|
||||
func sign(t *testing.T, secret []byte, claims jwt.MapClaims) string {
|
||||
t.Helper()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
s, err := tok.SignedString(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestVerifyToken_Valid(t *testing.T) {
|
||||
c := &Client{JWTSecret: testSecret}
|
||||
token := sign(t, testSecret, jwt.MapClaims{
|
||||
"sub": "11111111-1111-1111-1111-111111111111",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
got, err := c.VerifyToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Sub != "11111111-1111-1111-1111-111111111111" {
|
||||
t.Errorf("sub: got %q", got.Sub)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyToken_WrongSecret(t *testing.T) {
|
||||
c := &Client{JWTSecret: testSecret}
|
||||
token := sign(t, []byte("attacker-guessed-wrong"), jwt.MapClaims{
|
||||
"sub": "11111111-1111-1111-1111-111111111111",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
if _, err := c.VerifyToken(token); err == nil {
|
||||
t.Fatal("expected error for wrong-signature token, got nil (auth bypass)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyToken_Expired(t *testing.T) {
|
||||
c := &Client{JWTSecret: testSecret}
|
||||
token := sign(t, testSecret, jwt.MapClaims{
|
||||
"sub": "11111111-1111-1111-1111-111111111111",
|
||||
"exp": time.Now().Add(-time.Hour).Unix(),
|
||||
})
|
||||
_, err := c.VerifyToken(token)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for expired token")
|
||||
}
|
||||
if !errors.Is(err, jwt.ErrTokenExpired) {
|
||||
t.Errorf("expected ErrTokenExpired, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyToken_AlgNone(t *testing.T) {
|
||||
c := &Client{JWTSecret: testSecret}
|
||||
// An attacker might try alg=none to bypass signature checks.
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodNone, jwt.MapClaims{
|
||||
"sub": "22222222-2222-2222-2222-222222222222",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
token, err := tok.SignedString(jwt.UnsafeAllowNoneSignatureType)
|
||||
if err != nil {
|
||||
t.Fatalf("sign none: %v", err)
|
||||
}
|
||||
if _, err := c.VerifyToken(token); err == nil {
|
||||
t.Fatal("expected error for alg=none, got nil (critical bypass)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyToken_MissingSub(t *testing.T) {
|
||||
c := &Client{JWTSecret: testSecret}
|
||||
token := sign(t, testSecret, jwt.MapClaims{
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
if _, err := c.VerifyToken(token); err == nil {
|
||||
t.Fatal("expected error for missing sub claim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyToken_Garbage(t *testing.T) {
|
||||
c := &Client{JWTSecret: testSecret}
|
||||
if _, err := c.VerifyToken("not.a.jwt"); err == nil {
|
||||
t.Fatal("expected error for garbage token")
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,17 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userIDContextKey contextKey = "paliad.userID"
|
||||
const (
|
||||
userIDContextKey contextKey = "paliad.userID"
|
||||
claimsContextKey contextKey = "paliad.claims"
|
||||
)
|
||||
|
||||
// UserIDFromContext returns the authenticated user's UUID, populated by
|
||||
// WithUserID middleware (which runs after the session middleware).
|
||||
@@ -26,25 +25,31 @@ func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) {
|
||||
return v, true
|
||||
}
|
||||
|
||||
// WithUserID extracts the `sub` claim from the Supabase JWT in the session
|
||||
// cookie and injects it into the request context. Runs after Client.Middleware
|
||||
// so the cookie is guaranteed valid (otherwise we'd already be at /login).
|
||||
//
|
||||
// If the claim is missing or malformed, the request continues without a user
|
||||
// in context — handlers needing a user must check.
|
||||
// withVerifiedClaims stores signature-verified JWT claims in the request
|
||||
// context. Called only from Client.Middleware after VerifyToken succeeds.
|
||||
func withVerifiedClaims(ctx context.Context, claims *VerifiedClaims) context.Context {
|
||||
return context.WithValue(ctx, claimsContextKey, claims)
|
||||
}
|
||||
|
||||
// verifiedClaimsFromContext returns the signature-verified JWT claims
|
||||
// attached by Client.Middleware.
|
||||
func verifiedClaimsFromContext(ctx context.Context) (*VerifiedClaims, bool) {
|
||||
v, ok := ctx.Value(claimsContextKey).(*VerifiedClaims)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// WithUserID reads the `sub` claim from verified JWT claims attached by
|
||||
// Client.Middleware and injects the user's UUID into the request context.
|
||||
// Must run after Client.Middleware — the claims are only set there, after
|
||||
// signature verification succeeds.
|
||||
func (c *Client) WithUserID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(SessionCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
claims, ok := verifiedClaimsFromContext(r.Context())
|
||||
if !ok {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
sub, err := decodeJWTSub(cookie.Value)
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
uid, err := uuid.Parse(sub)
|
||||
uid, err := uuid.Parse(claims.Sub)
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
@@ -53,27 +58,3 @@ func (c *Client) WithUserID(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// decodeJWTSub reads the `sub` claim from a JWT without signature verification.
|
||||
// Verification was already done by the session middleware (which trusts
|
||||
// Supabase to issue the cookie); we only need the user-id claim here.
|
||||
func decodeJWTSub(token string) (string, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return "", errors.New("invalid token format")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var claims struct {
|
||||
Sub string `json:"sub"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if claims.Sub == "" {
|
||||
return "", errors.New("no sub claim")
|
||||
}
|
||||
return claims.Sub, nil
|
||||
}
|
||||
|
||||
@@ -4,15 +4,15 @@ import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/auth"
|
||||
)
|
||||
|
||||
func handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(auth.SessionCookieName); err == nil && cookie.Value != "" {
|
||||
if exp, err := auth.DecodeJWTExpiry(cookie.Value); err == nil && time.Now().Before(exp) {
|
||||
if _, err := authClient.VerifyToken(cookie.Value); err == nil {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
@@ -36,8 +36,8 @@ func handleAPILogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if !isHoganLovellsEmail(req.Email) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Zugang nur für @hoganlovells.com E-Mail-Adressen."})
|
||||
if !isAllowedEmailDomain(req.Email) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Zugang nur für autorisierte HLC-E-Mail-Adressen."})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@ func handleAPIRegister(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if !isHoganLovellsEmail(req.Email) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Registrierung nur für @hoganlovells.com E-Mail-Adressen."})
|
||||
if !isAllowedEmailDomain(req.Email) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Registrierung nur für autorisierte HLC-E-Mail-Adressen."})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -110,7 +110,35 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
|
||||
func isHoganLovellsEmail(email string) bool {
|
||||
// isAllowedEmailDomain gates sign-in/register to the HLC email domains.
|
||||
// Whitelist is configurable via ALLOWED_EMAIL_DOMAINS (comma-separated),
|
||||
// defaulting to hoganlovells.com,hlc.com,hlc.de so existing Hogan Lovells
|
||||
// addresses keep working during the post-merger transition.
|
||||
func isAllowedEmailDomain(email string) bool {
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
return len(parts) == 2 && strings.EqualFold(parts[1], "hoganlovells.com")
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
domain := strings.ToLower(parts[1])
|
||||
for _, allowed := range allowedEmailDomains() {
|
||||
if domain == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func allowedEmailDomains() []string {
|
||||
raw := os.Getenv("ALLOWED_EMAIL_DOMAINS")
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return []string{"hoganlovells.com", "hlc.com", "hlc.de"}
|
||||
}
|
||||
var out []string
|
||||
for _, d := range strings.Split(raw, ",") {
|
||||
d = strings.TrimSpace(strings.ToLower(d))
|
||||
if d != "" {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
40
internal/handlers/auth_test.go
Normal file
40
internal/handlers/auth_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsAllowedEmailDomain_Default(t *testing.T) {
|
||||
t.Setenv("ALLOWED_EMAIL_DOMAINS", "")
|
||||
cases := []struct {
|
||||
email string
|
||||
want bool
|
||||
}{
|
||||
{"alice@hoganlovells.com", true},
|
||||
{"alice@HOGANLOVELLS.COM", true},
|
||||
{"alice@hlc.com", true},
|
||||
{"alice@hlc.de", true},
|
||||
{"alice@gmail.com", false},
|
||||
{"alice@evil.hoganlovells.com.attacker.net", false},
|
||||
{"", false},
|
||||
{"no-at-sign", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isAllowedEmailDomain(tc.email); got != tc.want {
|
||||
t.Errorf("isAllowedEmailDomain(%q) = %v; want %v", tc.email, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowedEmailDomain_EnvOverride(t *testing.T) {
|
||||
t.Setenv("ALLOWED_EMAIL_DOMAINS", "example.com, Other.Org ")
|
||||
if !isAllowedEmailDomain("u@example.com") {
|
||||
t.Error("example.com should be allowed by env override")
|
||||
}
|
||||
if !isAllowedEmailDomain("u@other.org") {
|
||||
t.Error("other.org should be allowed (case-insensitive, trimmed)")
|
||||
}
|
||||
if isAllowedEmailDomain("u@hoganlovells.com") {
|
||||
t.Error("env override should replace defaults — hoganlovells.com no longer allowed")
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/auth"
|
||||
)
|
||||
@@ -53,18 +52,14 @@ func handleRootPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/index.html")
|
||||
}
|
||||
|
||||
// hasValidSession returns true when a session cookie is present, parses, and
|
||||
// hasn't expired. Kept intentionally loose: we don't reach out to Supabase
|
||||
// here. A near-expiry token will still be accepted and the downstream
|
||||
// Middleware handles refresh on the next protected request.
|
||||
// hasValidSession returns true when the session cookie carries a signed,
|
||||
// unexpired Supabase JWT. Verification is the same one Middleware uses —
|
||||
// forged or tampered cookies never reach the authenticated branch.
|
||||
func hasValidSession(r *http.Request) bool {
|
||||
cookie, err := r.Cookie(auth.SessionCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return false
|
||||
}
|
||||
exp, err := auth.DecodeJWTExpiry(cookie.Value)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return time.Now().Before(exp)
|
||||
_, err = authClient.VerifyToken(cookie.Value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
@@ -227,8 +227,9 @@ SELECT f.id,
|
||||
}
|
||||
|
||||
func (s *DashboardService) loadUpcomingAppointments(ctx context.Context, data *DashboardData, user *models.User, now time.Time) error {
|
||||
// Termine may be ad-hoc (no parent Akte). Apply visibility to Termine that
|
||||
// reference an Akte; ad-hoc Termine are visible to any authenticated user.
|
||||
// Personal Termine (akte_id IS NULL) are creator-only — must mirror
|
||||
// TerminService.canSee. Akte-attached rows follow the office-scoped
|
||||
// visibility predicate.
|
||||
query := `
|
||||
SELECT t.id,
|
||||
t.title,
|
||||
@@ -242,11 +243,12 @@ SELECT t.id,
|
||||
LEFT JOIN paliad.akten a ON a.id = t.akte_id
|
||||
WHERE t.start_at >= $4
|
||||
AND t.start_at < ($4 + interval '7 days')
|
||||
AND (t.akte_id IS NULL
|
||||
OR a.firm_wide_visible = true
|
||||
OR a.owning_office = $1
|
||||
OR $2::uuid = ANY (a.collaborators)
|
||||
OR $3 = 'admin')
|
||||
AND ((t.akte_id IS NULL AND t.created_by = $2::uuid)
|
||||
OR (t.akte_id IS NOT NULL AND (
|
||||
a.firm_wide_visible = true
|
||||
OR a.owning_office = $1
|
||||
OR $2::uuid = ANY (a.collaborators)
|
||||
OR $3 = 'admin')))
|
||||
ORDER BY t.start_at ASC
|
||||
LIMIT 10`
|
||||
if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, query,
|
||||
|
||||
@@ -89,11 +89,24 @@ func (s *ParteienService) Create(ctx context.Context, userID, akteID uuid.UUID,
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// Delete removes a Partei; visibility is checked on the parent Akte.
|
||||
// Delete removes a Partei. Partner/admin only — mirrors the FristService
|
||||
// delete policy so associates can't erase a Klägerin record on a firm-wide
|
||||
// Akte they merely have visibility for.
|
||||
func (s *ParteienService) Delete(ctx context.Context, userID, parteiID uuid.UUID) error {
|
||||
user, err := s.akten.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil {
|
||||
return ErrNotVisible
|
||||
}
|
||||
if user.Role != "partner" && user.Role != "admin" {
|
||||
return fmt.Errorf("%w: only partners/admins can delete Parteien", ErrForbidden)
|
||||
}
|
||||
|
||||
// Resolve the parent Akte to enforce visibility before DELETE executes.
|
||||
var akteID uuid.UUID
|
||||
err := s.db.GetContext(ctx, &akteID,
|
||||
err = s.db.GetContext(ctx, &akteID,
|
||||
`SELECT akte_id FROM paliad.parteien WHERE id = $1`, parteiID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotVisible
|
||||
|
||||
@@ -194,6 +194,26 @@ func (s *TerminService) GetByID(ctx context.Context, userID, terminID uuid.UUID)
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// requireMutationRole enforces the partner/admin gate on Akte-linked
|
||||
// Termin mutations (Update, Delete). The Termin's own creator is also
|
||||
// allowed — they're the one who added it.
|
||||
func (s *TerminService) requireMutationRole(ctx context.Context, userID uuid.UUID, t *models.Termin) error {
|
||||
if t.CreatedBy != nil && *t.CreatedBy == userID {
|
||||
return nil
|
||||
}
|
||||
user, err := s.akten.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil {
|
||||
return ErrNotVisible
|
||||
}
|
||||
if user.Role != "partner" && user.Role != "admin" {
|
||||
return fmt.Errorf("%w: only partners/admins can modify Termine on an Akte", ErrForbidden)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// canSee mirrors the SELECT visibility predicate for one in-memory Termin.
|
||||
func (s *TerminService) canSee(ctx context.Context, userID uuid.UUID, t *models.Termin) bool {
|
||||
if t.AkteID == nil {
|
||||
@@ -268,14 +288,23 @@ func (s *TerminService) Create(ctx context.Context, userID uuid.UUID, input Crea
|
||||
}
|
||||
|
||||
// Update applies a partial update.
|
||||
//
|
||||
// Policy:
|
||||
// - Personal Termin (akte_id IS NULL) → only the creator may edit.
|
||||
// - Akte-linked Termin → partner/admin only (or the Termin's creator).
|
||||
// Mirrors the FristService delete policy so an associate in another
|
||||
// office can't mutate a hearing on a firm-wide Akte.
|
||||
func (s *TerminService) Update(ctx context.Context, userID, terminID uuid.UUID, input UpdateTerminInput) (*models.Termin, error) {
|
||||
current, err := s.GetByID(ctx, userID, terminID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Personal Termine: only the creator may edit.
|
||||
if current.AkteID == nil && (current.CreatedBy == nil || *current.CreatedBy != userID) {
|
||||
return nil, fmt.Errorf("%w: only the creator can edit a personal Termin", ErrForbidden)
|
||||
if current.AkteID == nil {
|
||||
if current.CreatedBy == nil || *current.CreatedBy != userID {
|
||||
return nil, fmt.Errorf("%w: only the creator can edit a personal Termin", ErrForbidden)
|
||||
}
|
||||
} else if err := s.requireMutationRole(ctx, userID, current); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
@@ -351,16 +380,24 @@ func (s *TerminService) Update(ctx context.Context, userID, terminID uuid.UUID,
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Delete removes a Termin. Personal Termine: creator only. Akte-attached:
|
||||
// any user with visibility may delete (matches the FristService policy
|
||||
// upgrade: termin removal is recoverable from external CalDAV if synced).
|
||||
// Delete removes a Termin.
|
||||
//
|
||||
// Policy:
|
||||
// - Personal Termin (akte_id IS NULL) → only the creator may delete.
|
||||
// - Akte-linked Termin → partner/admin only (or the Termin's creator).
|
||||
// Matches the FristService delete gate so an associate viewing a
|
||||
// firm-wide Akte can't erase hearings from the calendar.
|
||||
func (s *TerminService) Delete(ctx context.Context, userID, terminID uuid.UUID) error {
|
||||
current, err := s.GetByID(ctx, userID, terminID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current.AkteID == nil && (current.CreatedBy == nil || *current.CreatedBy != userID) {
|
||||
return fmt.Errorf("%w: only the creator can delete a personal Termin", ErrForbidden)
|
||||
if current.AkteID == nil {
|
||||
if current.CreatedBy == nil || *current.CreatedBy != userID {
|
||||
return fmt.Errorf("%w: only the creator can delete a personal Termin", ErrForbidden)
|
||||
}
|
||||
} else if err := s.requireMutationRole(ctx, userID, current); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
|
||||
Reference in New Issue
Block a user