fix(backup): handle nested error response and add system owner fallback

SSE progress hook crashed React when backend returned nested error
object from writeError ({"error": {"code": ..., "message": ...}})
instead of flat string. Now parses both formats correctly.

PolicyEngine.IsOwner lacked the "system" fallback that isHTTPOwnerID
already had — when owner_ids is empty, "system" user was rejected from
all backup/restore endpoints. Added consistent fallback logic.

Also added slog.Warn to all silent owner-check rejections across
backup, restore, tenant backup/restore, and S3 handlers.
This commit is contained in:
viettranx committed 2026-04-13 21:01:21 +07:00
1 parent 1a471cbde3
commit 1ca49734ff
7 files changed
+18 -1

No files matched your search

+1
View File
@@ -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
+5
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+7
View File
@@ -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]
}
+2 -1
View File
@@ -166,7 +166,8 @@ export function useSseProgress(authHeaders: () => Record<string, string>): 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;