diff --git a/internal/http/backup_handler.go b/internal/http/backup_handler.go index a5e10a3d..787829e4 100644 --- a/internal/http/backup_handler.go +++ b/internal/http/backup_handler.go @@ -85,6 +85,7 @@ func (h *BackupHandler) handleBackup(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) if !h.isOwnerUser(userID) { + slog.Warn("security.backup_owner_denied", "user_id", userID) writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "system backup")) return diff --git a/internal/http/backup_s3_handler.go b/internal/http/backup_s3_handler.go index b3971925..16b5cd55 100644 --- a/internal/http/backup_s3_handler.go +++ b/internal/http/backup_s3_handler.go @@ -52,6 +52,7 @@ func (h *BackupS3Handler) handleGetConfig(w http.ResponseWriter, r *http.Request locale := extractLocale(r) if !h.isOwnerUser(userID) { + slog.Warn("security.s3_owner_denied", "user_id", userID, "path", r.URL.Path) writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "s3 config")) return @@ -83,6 +84,7 @@ func (h *BackupS3Handler) handleSaveConfig(w http.ResponseWriter, r *http.Reques locale := extractLocale(r) if !h.isOwnerUser(userID) { + slog.Warn("security.s3_owner_denied", "user_id", userID, "path", r.URL.Path) writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "s3 config")) return @@ -136,6 +138,7 @@ func (h *BackupS3Handler) handleList(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) if !h.isOwnerUser(userID) { + slog.Warn("security.s3_owner_denied", "user_id", userID, "path", r.URL.Path) writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "s3 list")) return @@ -167,6 +170,7 @@ func (h *BackupS3Handler) handleUpload(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) if !h.isOwnerUser(userID) { + slog.Warn("security.s3_owner_denied", "user_id", userID, "path", r.URL.Path) writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "s3 upload")) return @@ -234,6 +238,7 @@ func (h *BackupS3Handler) handleBackupAndUpload(w http.ResponseWriter, r *http.R locale := extractLocale(r) if !h.isOwnerUser(userID) { + slog.Warn("security.s3_owner_denied", "user_id", userID, "path", r.URL.Path) writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "s3 backup")) return diff --git a/internal/http/restore_handler.go b/internal/http/restore_handler.go index f1e54dae..42ed8da7 100644 --- a/internal/http/restore_handler.go +++ b/internal/http/restore_handler.go @@ -44,6 +44,7 @@ func (h *RestoreHandler) handleRestore(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) if !h.isOwnerUser(userID) { + slog.Warn("security.restore_owner_denied", "user_id", userID) writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "system restore")) return diff --git a/internal/http/tenant_backup_handler.go b/internal/http/tenant_backup_handler.go index 4b045318..a3348ddc 100644 --- a/internal/http/tenant_backup_handler.go +++ b/internal/http/tenant_backup_handler.go @@ -73,6 +73,7 @@ func (h *TenantBackupHandler) handleBackup(w http.ResponseWriter, r *http.Reques } if !h.authorised(r, userID, tenantID) { + slog.Warn("security.tenant_backup_denied", "user_id", userID, "tenant_id", tenantID) writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "tenant backup")) return diff --git a/internal/http/tenant_restore_handler.go b/internal/http/tenant_restore_handler.go index 456727b1..20ec37b9 100644 --- a/internal/http/tenant_restore_handler.go +++ b/internal/http/tenant_restore_handler.go @@ -20,6 +20,7 @@ func (h *TenantBackupHandler) handleRestore(w http.ResponseWriter, r *http.Reque locale := extractLocale(r) if !h.isOwnerUser(userID) { + slog.Warn("security.tenant_restore_owner_denied", "user_id", userID) writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "tenant restore")) return diff --git a/internal/permissions/policy.go b/internal/permissions/policy.go index 4f5cf7f8..d5227d8c 100644 --- a/internal/permissions/policy.go +++ b/internal/permissions/policy.go @@ -74,9 +74,16 @@ func NewPolicyEngine(ownerIDs []string) *PolicyEngine { } // IsOwner checks if a sender ID is an owner. +// When no owner IDs are configured, "system" is treated as owner (fail-closed default). func (pe *PolicyEngine) IsOwner(senderID string) bool { + if senderID == "" { + return false + } pe.mu.RLock() defer pe.mu.RUnlock() + if len(pe.ownerIDs) == 0 { + return senderID == "system" + } return pe.ownerIDs[senderID] } diff --git a/ui/web/src/hooks/use-sse-progress.ts b/ui/web/src/hooks/use-sse-progress.ts index 54164143..2d02cde2 100644 --- a/ui/web/src/hooks/use-sse-progress.ts +++ b/ui/web/src/hooks/use-sse-progress.ts @@ -166,7 +166,8 @@ export function useSseProgress(authHeaders: () => Record): UseSs const res = await fetch(url, { ...init, signal: controller.signal }); if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })); - setError({ phase: "request", detail: err.error ?? res.statusText, rolled_back: false }); + const detail = typeof err.error === "string" ? err.error : err.error?.message ?? res.statusText; + setError({ phase: "request", detail, rolled_back: false }); setStatus("error"); cleanup(); return;