diff --git a/apps/backend/internal/system/backups/handler.go b/apps/backend/internal/system/backups/handler.go index 897e5e46c6..da681313f3 100644 --- a/apps/backend/internal/system/backups/handler.go +++ b/apps/backend/internal/system/backups/handler.go @@ -9,14 +9,18 @@ import ( "github.com/gin-gonic/gin" ) -// RegisterRoutes wires the backups endpoints onto g (typically the -// /api/v1/system group). -func RegisterRoutes(g *gin.RouterGroup, svc *Service) { - g.GET("/backups", HandleList(svc)) - g.POST("/backups", HandleCreate(svc)) - g.GET("/backups/:name/download", HandleDownload(svc)) - g.POST("/backups/:name/restore", HandleRestore(svc)) - g.DELETE("/backups/:name", HandleDelete(svc)) +// RegisterRoutes wires the backups endpoints onto the /api/v1/system groups. +// Only the metadata listing is readable by any authenticated caller. Creating, +// downloading, restoring, and deleting a snapshot act on the whole install: +// a snapshot is a copy of the multi-user database, so the download route is an +// export of every user's data and belongs behind the admin role along with the +// destructive mutations. +func RegisterRoutes(read, admin *gin.RouterGroup, svc *Service) { + read.GET("/backups", HandleList(svc)) + admin.POST("/backups", HandleCreate(svc)) + admin.GET("/backups/:name/download", HandleDownload(svc)) + admin.POST("/backups/:name/restore", HandleRestore(svc)) + admin.DELETE("/backups/:name", HandleDelete(svc)) } // HandleList returns GET /api/v1/system/backups -> { snapshots: [...] }. diff --git a/apps/backend/internal/system/backups/handler_test.go b/apps/backend/internal/system/backups/handler_test.go index 539b7600ce..1a10eca585 100644 --- a/apps/backend/internal/system/backups/handler_test.go +++ b/apps/backend/internal/system/backups/handler_test.go @@ -13,14 +13,25 @@ import ( "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/system/jobs" ) +// newRouter mirrors the production wiring in internal/system: a read group +// plus an admin group guarded by authn.RequireAdmin. The identity is an admin +// (as the synthetic single-user identity is when auth is disabled), so these +// handler tests exercise behavior rather than the role guard, which +// internal/system's route tests own. func newRouter(svc *Service) *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() + r.Use(func(c *gin.Context) { + authn.SetOnGin(c, authn.Identity{UserID: "admin-1", Role: authn.RoleAdmin}) + c.Next() + }) g := r.Group("/api/v1/system") - RegisterRoutes(g, svc) + RegisterRoutes(g, g.Group("", authn.RequireAdmin()), svc) return r } diff --git a/apps/backend/internal/system/backups/store.go b/apps/backend/internal/system/backups/store.go index 87ded10ab1..bb7bdc0293 100644 --- a/apps/backend/internal/system/backups/store.go +++ b/apps/backend/internal/system/backups/store.go @@ -20,9 +20,14 @@ import ( ) // Snapshot is the public representation of a backup file on disk. +// Snapshot is the metadata one backup file exposes over the API. It +// deliberately carries no on-disk path: GET /backups is readable by any +// authenticated user (only the mutating and downloading routes are admin +// only), and the absolute path would disclose the install's data directory to +// all of them. Callers name a snapshot by Name; the service resolves it to a +// path itself via resolveSnapshotPath. type Snapshot struct { Name string `json:"name"` - Path string `json:"path"` SizeBytes int64 `json:"size_bytes"` ModTime time.Time `json:"mtime"` Kind string `json:"kind"` // "auto" | "manual" @@ -121,7 +126,6 @@ func (s *Service) List() ([]Snapshot, error) { } out = append(out, Snapshot{ Name: e.Name(), - Path: filepath.Join(dir, e.Name()), SizeBytes: info.Size(), ModTime: info.ModTime().UTC(), Kind: kind, diff --git a/apps/backend/internal/system/storage/handler.go b/apps/backend/internal/system/storage/handler.go index 6958948257..eb0877a3ee 100644 --- a/apps/backend/internal/system/storage/handler.go +++ b/apps/backend/internal/system/storage/handler.go @@ -107,19 +107,24 @@ func (h *Handler) logError(message string, err error) { } } -func RegisterRoutes(group *gin.RouterGroup, handler *Handler) { - group.GET("/storage", handler.getStorage) - group.GET("/storage/disk", handler.getStorageDisk) - group.GET("/storage/settings", handler.getStorageSettings) - group.PATCH("/storage/settings", handler.patchSettings) - group.POST("/storage/go-cache/adopt", handler.adoptGoCache) - group.POST("/storage/analyze", handler.analyze) - group.POST("/storage/run", handler.runNow) - group.GET("/storage/runs", handler.listRuns) - group.GET("/storage/quarantine", handler.listQuarantine) - group.POST("/storage/quarantine/:id/restore", handler.restoreQuarantine) - group.DELETE("/storage/quarantine", handler.deleteQuarantineBulk) - group.DELETE("/storage/quarantine/:id", handler.deleteQuarantine) +// RegisterRoutes wires storage maintenance onto the /api/v1/system groups. +// Reading the current usage, policy, run history, and quarantine contents is +// open to any authenticated caller; every route that changes install-wide +// state (settings, adoption, cleanup passes, quarantine restore/purge) +// requires the admin role. +func RegisterRoutes(read, admin *gin.RouterGroup, handler *Handler) { + read.GET("/storage", handler.getStorage) + read.GET("/storage/disk", handler.getStorageDisk) + read.GET("/storage/settings", handler.getStorageSettings) + read.GET("/storage/runs", handler.listRuns) + read.GET("/storage/quarantine", handler.listQuarantine) + admin.PATCH("/storage/settings", handler.patchSettings) + admin.POST("/storage/go-cache/adopt", handler.adoptGoCache) + admin.POST("/storage/analyze", handler.analyze) + admin.POST("/storage/run", handler.runNow) + admin.POST("/storage/quarantine/:id/restore", handler.restoreQuarantine) + admin.DELETE("/storage/quarantine", handler.deleteQuarantineBulk) + admin.DELETE("/storage/quarantine/:id", handler.deleteQuarantine) } func (h *Handler) getStorageDisk(c *gin.Context) { diff --git a/apps/backend/internal/system/storage/handler_test.go b/apps/backend/internal/system/storage/handler_test.go index 37973336e0..32d28e1ca4 100644 --- a/apps/backend/internal/system/storage/handler_test.go +++ b/apps/backend/internal/system/storage/handler_test.go @@ -12,13 +12,28 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/auth/authn" ) +// newTestRouter mirrors the production wiring in internal/system: a read group +// plus an admin group guarded by authn.RequireAdmin. The identity is an admin +// (as the synthetic single-user identity is when auth is disabled), so these +// handler tests exercise behavior rather than the role guard, which +// internal/system's route tests own. +func newTestRouter(handler *Handler) *gin.Engine { + router := gin.New() + router.Use(func(c *gin.Context) { + authn.SetOnGin(c, authn.Identity{UserID: "admin-1", Role: authn.RoleAdmin}) + c.Next() + }) + read := router.Group("/api/v1/system") + RegisterRoutes(read, read.Group("", authn.RequireAdmin()), handler) + return router +} + func TestPatchSettingsHidesInternalSaveFailure(t *testing.T) { gin.SetMode(gin.TestMode) - router := gin.New() - group := router.Group("/api/v1/system") - RegisterRoutes(group, NewHandler(HandlerConfig{ + router := newTestRouter(NewHandler(HandlerConfig{ Settings: failingSettingsManager{err: errors.New("database credentials leaked")}, })) body, err := json.Marshal(map[string]any{"settings": DefaultSettings()}) @@ -43,8 +58,7 @@ func TestPatchSettingsHidesInternalSaveFailure(t *testing.T) { func TestGetStorageReturnsSnapshotAnalyzedAt(t *testing.T) { gin.SetMode(gin.TestMode) analyzedAt := time.Date(2026, time.July, 23, 12, 0, 0, 0, time.UTC) - router := gin.New() - RegisterRoutes(router.Group("/api/v1/system"), NewHandler(HandlerConfig{ + router := newTestRouter(NewHandler(HandlerConfig{ Settings: staticSettingsManager{}, Runs: staticRunLister{}, Overview: staticCachedOverview{snapshot: OverviewSnapshot{ Summary: Summary{Workspaces: map[string]any{"bytes": 42}}, AnalyzedAt: analyzedAt, @@ -73,8 +87,7 @@ func TestGetStorageReturnsSnapshotAnalyzedAt(t *testing.T) { func TestGetStorageDiskReturnsIndependentCapacityResponse(t *testing.T) { gin.SetMode(gin.TestMode) - router := gin.New() - RegisterRoutes(router.Group("/api/v1/system"), NewHandler(HandlerConfig{})) + router := newTestRouter(NewHandler(HandlerConfig{})) response := httptest.NewRecorder() router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/system/storage/disk", nil)) @@ -95,8 +108,7 @@ func TestGetStorageDiskReturnsIndependentCapacityResponse(t *testing.T) { func TestGetStorageDiskReturnsMeasuredFields(t *testing.T) { gin.SetMode(gin.TestMode) - router := gin.New() - RegisterRoutes(router.Group("/api/v1/system"), NewHandler(HandlerConfig{ + router := newTestRouter(NewHandler(HandlerConfig{ DiskPath: "/data", DiskCapacity: func(context.Context, string) (DiskCapacity, error) { return DiskCapacity{ @@ -126,8 +138,7 @@ func TestGetStorageDiskReturnsMeasuredFields(t *testing.T) { func TestGetStorageDiskLogsReaderErrorsAndReturnsUnavailable(t *testing.T) { gin.SetMode(gin.TestMode) var loggedMessage string - router := gin.New() - RegisterRoutes(router.Group("/api/v1/system"), NewHandler(HandlerConfig{ + router := newTestRouter(NewHandler(HandlerConfig{ DiskPath: "/data", DiskCapacity: func(context.Context, string) (DiskCapacity, error) { return DiskCapacity{}, errors.New("statfs failed") @@ -164,8 +175,7 @@ func TestGetStorageSettingsReturnsPolicyWithoutOverviewScan(t *testing.T) { HostGlobalDockerCleanup: true, } overview := &recordingOverviewReader{capabilities: capabilities} - router := gin.New() - RegisterRoutes(router.Group("/api/v1/system"), NewHandler(HandlerConfig{ + router := newTestRouter(NewHandler(HandlerConfig{ Settings: staticSettingsManager{settings: settings}, Overview: overview, })) @@ -205,8 +215,7 @@ func TestGetStorageSettingsHidesInternalLoadFailure(t *testing.T) { internalErr := errors.New("database credentials leaked") var loggedMessage string var loggedErr error - router := gin.New() - RegisterRoutes(router.Group("/api/v1/system"), NewHandler(HandlerConfig{ + router := newTestRouter(NewHandler(HandlerConfig{ Settings: failingSettingsManager{getErr: internalErr}, LogError: func(message string, err error) { loggedMessage, loggedErr = message, err @@ -231,8 +240,7 @@ func TestGetStorageSettingsHidesInternalLoadFailure(t *testing.T) { func TestDeleteQuarantineBulkValidatesConfirmation(t *testing.T) { gin.SetMode(gin.TestMode) mutations := &recordingMutations{} - router := gin.New() - RegisterRoutes(router.Group("/api/v1/system"), NewHandler(HandlerConfig{Mutations: mutations})) + router := newTestRouter(NewHandler(HandlerConfig{Mutations: mutations})) for _, test := range []struct { body string diff --git a/apps/backend/internal/system/storage_routes_test.go b/apps/backend/internal/system/storage_routes_test.go index 686802e0ed..affca0e695 100644 --- a/apps/backend/internal/system/storage_routes_test.go +++ b/apps/backend/internal/system/storage_routes_test.go @@ -169,7 +169,9 @@ func newStorageRoutesTestRouter(t *testing.T) *gin.Engine { func newStorageRoutesTestRouterWithMutations(t *testing.T, mutations storage.Mutations) *gin.Engine { t.Helper() gin.SetMode(gin.TestMode) - router := gin.New() + // The synthetic identity is what httpmw injects while auth is disabled, so + // this router reproduces single-user mode exactly. + router := systemRouterForSyntheticIdentity() connection, err := sqlx.Open("sqlite3", ":memory:") if err != nil { t.Fatal(err) diff --git a/apps/backend/internal/system/system.go b/apps/backend/internal/system/system.go index 7dd433fa42..ae3b26cd33 100644 --- a/apps/backend/internal/system/system.go +++ b/apps/backend/internal/system/system.go @@ -185,7 +185,7 @@ func (s *Service) RegisterRoutes(router *gin.Engine, log *logger.Logger) { g.GET("/info", info.Handler(s.Info)) if s.Storage != nil { - storage.RegisterRoutes(g, s.Storage) + storage.RegisterRoutes(g, admin, s.Storage) } g.GET("/disk-usage", disk.HandleGet(s.Disk)) @@ -197,7 +197,7 @@ func (s *Service) RegisterRoutes(router *gin.Engine, log *logger.Logger) { admin.POST("/database/optimize", database.HandleOptimize(s.Database)) admin.POST("/database/reset", database.HandleReset(s.Database)) - backups.RegisterRoutes(g, s.Backups) + backups.RegisterRoutes(g, admin, s.Backups) if s.FrontendErrors != nil { g.POST("/logs/frontend-errors", frontenderrors.Handle(s.FrontendErrors)) diff --git a/apps/backend/internal/system/system_authz_test.go b/apps/backend/internal/system/system_authz_test.go new file mode 100644 index 0000000000..a32d90d7c7 --- /dev/null +++ b/apps/backend/internal/system/system_authz_test.go @@ -0,0 +1,426 @@ +package system + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/auth/httpmw" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/system/backups" + "github.com/kandev/kandev/internal/system/jobs" + systemsettings "github.com/kandev/kandev/internal/system/settings" + "github.com/kandev/kandev/internal/system/storage" + _ "github.com/mattn/go-sqlite3" + "go.uber.org/zap" +) + +// The backups snapshot planted on disk by newBackupsAuthzFixture. Its bytes +// double as the leak canary for the download route. +const ( + authzSnapshotName = "manual-20260101-000000.db" + authzSnapshotContent = "every-users-private-rows" + authzBackupsPath = "/api/v1/system/backups" + authzSnapshotPath = authzBackupsPath + "/" + authzSnapshotName + authzStoragePath = "/api/v1/system/storage" + authzJSONContentType = "application/json" +) + +// systemRouterForSyntheticIdentity mirrors the auth-disabled request path: +// httpmw injects the synthetic single-user admin on every request. +func systemRouterForSyntheticIdentity() *gin.Engine { + router := gin.New() + router.Use(func(ctx *gin.Context) { + authn.SetOnGin(ctx, httpmw.SyntheticIdentity()) + ctx.Next() + }) + return router +} + +type backupsAuthzFixture struct { + service *backups.Service + tracker *jobs.Tracker + backupsDir string +} + +func newBackupsAuthzFixture(t *testing.T) *backupsAuthzFixture { + t.Helper() + gin.SetMode(gin.TestMode) + dataDir := t.TempDir() + databasePath := filepath.Join(dataDir, "kandev.db") + connection, err := sqlx.Open("sqlite3", databasePath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = connection.Close() }) + if _, err := connection.Exec(`CREATE TABLE things (id TEXT PRIMARY KEY)`); err != nil { + t.Fatal(err) + } + backupsDir := filepath.Join(dataDir, "backups") + if err := os.MkdirAll(backupsDir, 0o755); err != nil { + t.Fatal(err) + } + snapshot := filepath.Join(backupsDir, authzSnapshotName) + if err := os.WriteFile(snapshot, []byte(authzSnapshotContent), 0o644); err != nil { + t.Fatal(err) + } + tracker := jobs.NewTracker(nil, testLoggerForAuthz(t)) + service := backups.NewService(databasePath, db.NewPool(connection, connection), tracker, testLoggerForAuthz(t)) + return &backupsAuthzFixture{service: service, tracker: tracker, backupsDir: backupsDir} +} + +func (f *backupsAuthzFixture) router(t *testing.T, router *gin.Engine) *gin.Engine { + t.Helper() + (&Service{Backups: f.service}).RegisterRoutes(router, testLoggerForAuthz(t)) + return router +} + +func (f *backupsAuthzFixture) snapshotExists() bool { + _, err := os.Stat(filepath.Join(f.backupsDir, authzSnapshotName)) + return err == nil +} + +func testLoggerForAuthz(t *testing.T) *logger.Logger { + t.Helper() + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatal(err) + } + return log +} + +// validStorageSettingsBody is a complete, in-range settings payload, so a +// non-403 response can only mean the request reached the handler. +func validStorageSettingsBody(t *testing.T) string { + t.Helper() + body, err := json.Marshal(map[string]any{"settings": storage.DefaultSettings()}) + if err != nil { + t.Fatal(err) + } + return string(body) +} + +func newRequest(method, path, body string) *http.Request { + request := httptest.NewRequest(method, path, strings.NewReader(body)) + request.Header.Set("Content-Type", authzJSONContentType) + return request +} + +func serve(router *gin.Engine, request *http.Request) *httptest.ResponseRecorder { + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response +} + +// TestBackupsMutatingRoutesDenyMembers locks the authorization fix: with auth +// enabled a member may not export, create, restore, or delete an install-wide +// snapshot. Each case also asserts the service never ran. +func TestBackupsMutatingRoutesDenyMembers(t *testing.T) { + fixture := newBackupsAuthzFixture(t) + router := fixture.router(t, systemRouterForRole(authn.RoleMember)) + + download := serve(router, newRequest(http.MethodGet, authzSnapshotPath+"/download", "")) + if download.Code != http.StatusForbidden { + t.Fatalf("member download status = %d, want 403; body=%s", download.Code, download.Body.String()) + } + if strings.Contains(download.Body.String(), authzSnapshotContent) { + t.Fatalf("member download leaked snapshot bytes: %s", download.Body.String()) + } + + create := serve(router, newRequest(http.MethodPost, authzBackupsPath, "{}")) + if create.Code != http.StatusForbidden { + t.Fatalf("member create status = %d, want 403; body=%s", create.Code, create.Body.String()) + } + if jobsStarted := len(fixture.tracker.List()); jobsStarted != 0 { + t.Fatalf("member create started %d jobs, want 0", jobsStarted) + } + + restore := serve(router, newRequest(http.MethodPost, authzSnapshotPath+"/restore", `{"confirm":"RESTORE"}`)) + if restore.Code != http.StatusForbidden { + t.Fatalf("member restore status = %d, want 403; body=%s", restore.Code, restore.Body.String()) + } + if jobsStarted := len(fixture.tracker.List()); jobsStarted != 0 { + t.Fatalf("member restore started %d jobs, want 0", jobsStarted) + } + + remove := serve(router, newRequest(http.MethodDelete, authzSnapshotPath, "")) + if remove.Code != http.StatusForbidden { + t.Fatalf("member delete status = %d, want 403; body=%s", remove.Code, remove.Body.String()) + } + if !fixture.snapshotExists() { + t.Fatal("member delete removed the snapshot despite the 403") + } +} + +// TestBackupsListStaysReadableForMembers keeps the metadata-only listing on +// the non-admin group so the page still renders for members. +func TestBackupsListStaysReadableForMembers(t *testing.T) { + fixture := newBackupsAuthzFixture(t) + router := fixture.router(t, systemRouterForRole(authn.RoleMember)) + + list := serve(router, newRequest(http.MethodGet, authzBackupsPath, "")) + if list.Code != http.StatusOK { + t.Fatalf("member list status = %d, want 200; body=%s", list.Code, list.Body.String()) + } + if !strings.Contains(list.Body.String(), authzSnapshotName) { + t.Fatalf("member list omitted the snapshot: %s", list.Body.String()) + } +} + +// TestBackupsRoutesUnchangedForAdminAndSyntheticIdentity proves the guard is a +// no-op for admins and for the auth-disabled synthetic single user. +func TestBackupsRoutesUnchangedForAdminAndSyntheticIdentity(t *testing.T) { + for name, build := range map[string]func() *gin.Engine{ + "admin": func() *gin.Engine { return systemRouterForRole(authn.RoleAdmin) }, + "synthetic": systemRouterForSyntheticIdentity, + } { + t.Run(name, func(t *testing.T) { + fixture := newBackupsAuthzFixture(t) + router := fixture.router(t, build()) + + download := serve(router, newRequest(http.MethodGet, authzSnapshotPath+"/download", "")) + if download.Code != http.StatusOK || download.Body.String() != authzSnapshotContent { + t.Fatalf("download status = %d body = %q", download.Code, download.Body.String()) + } + + create := serve(router, newRequest(http.MethodPost, authzBackupsPath, "{}")) + if create.Code != http.StatusAccepted { + t.Fatalf("create status = %d, want 202; body=%s", create.Code, create.Body.String()) + } + + restore := serve(router, newRequest(http.MethodPost, authzSnapshotPath+"/restore", `{"confirm":"NOPE"}`)) + if restore.Code != http.StatusBadRequest { + t.Fatalf("restore status = %d, want 400 (confirmation rejected, not authorization)", restore.Code) + } + + remove := serve(router, newRequest(http.MethodDelete, authzSnapshotPath, "")) + if remove.Code != http.StatusNoContent { + t.Fatalf("delete status = %d, want 204; body=%s", remove.Code, remove.Body.String()) + } + if fixture.snapshotExists() { + t.Fatal("delete returned 204 but left the snapshot on disk") + } + if started := len(fixture.tracker.List()); started == 0 { + t.Fatal("create never reached the service") + } + }) + } +} + +type storageAuthzCase struct { + name string + method string + path string + body string +} + +// storageMutatingRoutes is every storage endpoint that changes install-wide +// state. Bodies carry valid confirmations so a missing guard produces a +// non-403 status rather than an incidental 400. +func storageMutatingRoutes() []storageAuthzCase { + return []storageAuthzCase{ + {name: "adopt go cache", method: http.MethodPost, path: authzStoragePath + "/go-cache/adopt", body: `{"path":"/tmp/cache","confirm":"ADOPT"}`}, + {name: "analyze", method: http.MethodPost, path: authzStoragePath + "/analyze", body: "{}"}, + {name: "run cleanup", method: http.MethodPost, path: authzStoragePath + "/run", body: "{}"}, + {name: "restore quarantine entry", method: http.MethodPost, path: authzStoragePath + "/quarantine/entry-1/restore", body: "{}"}, + {name: "delete quarantine entry", method: http.MethodDelete, path: authzStoragePath + "/quarantine/entry-1", body: `{"confirm":"DELETE"}`}, + {name: "purge quarantine", method: http.MethodDelete, path: authzStoragePath + "/quarantine", body: `{"scope":"eligible","confirm":"DELETE ELIGIBLE"}`}, + } +} + +func storageReadRoutes() []string { + return []string{ + authzStoragePath, + authzStoragePath + "/disk", + authzStoragePath + "/settings", + authzStoragePath + "/runs", + authzStoragePath + "/quarantine", + } +} + +// recordingStorageMutations records which destructive operation ran so a +// blocked request can be distinguished from one that merely returned an error. +type recordingStorageMutations struct { + calls []string +} + +func (r *recordingStorageMutations) AdoptGoCache(context.Context, string, string) (storage.StorageMaintenanceSettings, storage.Capabilities, error) { + r.calls = append(r.calls, "adopt") + return storage.DefaultSettings(), storage.Capabilities{}, nil +} + +func (r *recordingStorageMutations) Analyze(context.Context) (string, error) { + r.calls = append(r.calls, "analyze") + return "analysis-job", nil +} + +func (r *recordingStorageMutations) RunNow(context.Context, []string, bool) (string, error) { + r.calls = append(r.calls, "run") + return "cleanup-job", nil +} + +func (r *recordingStorageMutations) RestoreQuarantine(context.Context, string) (storage.QuarantineEntry, error) { + r.calls = append(r.calls, "restore") + return storage.QuarantineEntry{}, nil +} + +func (r *recordingStorageMutations) DeleteQuarantine(context.Context, string, string) (string, error) { + r.calls = append(r.calls, "delete") + return "delete-job", nil +} + +func (r *recordingStorageMutations) PurgeQuarantine(context.Context, storage.QuarantinePurgeScope, string) (string, error) { + r.calls = append(r.calls, "purge") + return "purge-job", nil +} + +func newStorageAuthzRouter(t *testing.T, router *gin.Engine, mutations storage.Mutations) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + connection, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatal(err) + } + connection.SetMaxOpenConns(1) + t.Cleanup(func() { _ = connection.Close() }) + pool := db.NewPool(connection, connection) + rawSettings, err := systemsettings.NewStore(pool) + if err != nil { + t.Fatal(err) + } + storageStore, err := storage.NewStore(pool) + if err != nil { + t.Fatal(err) + } + service := &Service{Storage: storage.NewHandler(storage.HandlerConfig{ + Settings: storage.NewSettingsStore(rawSettings), Runs: storageStore, + Quarantine: storageStore, Overview: emptyStorageOverview{}, Mutations: mutations, + })} + service.RegisterRoutes(router, testLoggerForAuthz(t)) + return router +} + +// TestStorageMutatingRoutesDenyMembers locks the second half of the fix: +// members cannot run install-wide maintenance, and the operation never fires. +func TestStorageMutatingRoutesDenyMembers(t *testing.T) { + for _, testCase := range storageMutatingRoutes() { + t.Run(testCase.name, func(t *testing.T) { + mutations := &recordingStorageMutations{} + router := newStorageAuthzRouter(t, systemRouterForRole(authn.RoleMember), mutations) + + response := serve(router, newRequest(testCase.method, testCase.path, testCase.body)) + if response.Code != http.StatusForbidden { + t.Fatalf("member status = %d, want 403; body=%s", response.Code, response.Body.String()) + } + if len(mutations.calls) != 0 { + t.Fatalf("member request invoked %v despite the 403", mutations.calls) + } + }) + } +} + +// TestStorageSettingsPatchDeniesMembers covers the settings write, which goes +// through the settings store rather than Mutations. +func TestStorageSettingsPatchDeniesMembers(t *testing.T) { + router := newStorageAuthzRouter(t, systemRouterForRole(authn.RoleMember), &recordingStorageMutations{}) + + response := serve(router, newRequest(http.MethodPatch, authzStoragePath+"/settings", validStorageSettingsBody(t))) + if response.Code != http.StatusForbidden { + t.Fatalf("member PATCH status = %d, want 403; body=%s", response.Code, response.Body.String()) + } +} + +// TestStorageReadRoutesStayOpenToMembers keeps the read-only maintenance view +// on the non-admin group. +func TestStorageReadRoutesStayOpenToMembers(t *testing.T) { + router := newStorageAuthzRouter(t, systemRouterForRole(authn.RoleMember), &recordingStorageMutations{}) + for _, path := range storageReadRoutes() { + t.Run(path, func(t *testing.T) { + response := serve(router, newRequest(http.MethodGet, path, "")) + if response.Code != http.StatusOK { + t.Fatalf("member GET status = %d, want 200; body=%s", response.Code, response.Body.String()) + } + }) + } +} + +// TestStorageMutatingRoutesUnchangedForAdminAndSyntheticIdentity proves the +// guard changes nothing for admins or for the auth-disabled single user. +func TestStorageMutatingRoutesUnchangedForAdminAndSyntheticIdentity(t *testing.T) { + for name, build := range map[string]func() *gin.Engine{ + "admin": func() *gin.Engine { return systemRouterForRole(authn.RoleAdmin) }, + "synthetic": systemRouterForSyntheticIdentity, + } { + t.Run(name, func(t *testing.T) { + for _, testCase := range storageMutatingRoutes() { + t.Run(testCase.name, func(t *testing.T) { + mutations := &recordingStorageMutations{} + router := newStorageAuthzRouter(t, build(), mutations) + + response := serve(router, newRequest(testCase.method, testCase.path, testCase.body)) + if response.Code == http.StatusForbidden || response.Code == http.StatusUnauthorized { + t.Fatalf("status = %d, want the route to run; body=%s", response.Code, response.Body.String()) + } + if len(mutations.calls) != 1 { + t.Fatalf("calls = %v, want exactly one", mutations.calls) + } + }) + } + patch := serve( + newStorageAuthzRouter(t, build(), &recordingStorageMutations{}), + newRequest(http.MethodPatch, authzStoragePath+"/settings", validStorageSettingsBody(t)), + ) + if patch.Code == http.StatusForbidden || patch.Code == http.StatusUnauthorized { + t.Fatalf("PATCH settings status = %d, want the route to run; body=%s", patch.Code, patch.Body.String()) + } + }) + } +} + +// TestBackupsListLeaksOnlyMetadata keeps GET /backups on the non-admin group +// honest. The listing is the one backups route a member may call, so it must +// carry snapshot metadata and nothing else. The absolute on-disk path is not +// metadata: it discloses the install's data directory (usually including the +// operating account's home directory) to every authenticated user, and the UI +// deliberately shows only a "/backups/" placeholder instead. +func TestBackupsListLeaksOnlyMetadata(t *testing.T) { + fixture := newBackupsAuthzFixture(t) + router := fixture.router(t, systemRouterForRole(authn.RoleMember)) + + list := serve(router, newRequest(http.MethodGet, authzBackupsPath, "")) + if list.Code != http.StatusOK { + t.Fatalf("member list status = %d, want 200; body=%s", list.Code, list.Body.String()) + } + var payload struct { + Snapshots []map[string]any `json:"snapshots"` + } + if err := json.Unmarshal(list.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if len(payload.Snapshots) != 1 { + t.Fatalf("snapshots = %v, want exactly the seeded one", payload.Snapshots) + } + fields := make([]string, 0, len(payload.Snapshots[0])) + for field := range payload.Snapshots[0] { + fields = append(fields, field) + } + sort.Strings(fields) + want := []string{"kind", "mtime", "name", "size_bytes"} + if !slices.Equal(fields, want) { + t.Fatalf("snapshot fields = %v, want %v", fields, want) + } + if strings.Contains(list.Body.String(), fixture.backupsDir) { + t.Fatalf("listing disclosed the backups directory: %s", list.Body.String()) + } +} diff --git a/apps/web/components/settings/system/backups-table.test.tsx b/apps/web/components/settings/system/backups-table.test.tsx new file mode 100644 index 0000000000..f4fbfd4182 --- /dev/null +++ b/apps/web/components/settings/system/backups-table.test.tsx @@ -0,0 +1,119 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SnapshotInfo } from "@/lib/types/system"; + +const mocks = vi.hoisted(() => ({ + useBackups: vi.fn(), +})); + +// Mirrors the auth slice the admin gate reads. `undefined` is the +// auth-disabled single-user mode, which the backend treats as an admin. +let currentRole: "admin" | "member" | undefined; +let currentMode: "disabled" | "setup" | "enabled" = "enabled"; + +// Mirrors the auth slice the admin gate reads. `currentMode` distinguishes +// auth-disabled single-user mode (synthetic admin) from a cleared session. +vi.mock("@/components/state-provider", () => ({ + useAppStore: ( + selector: (state: { auth: { mode: string; user?: { role: string } } }) => unknown, + ) => + selector({ + auth: { mode: currentMode, user: currentRole ? { role: currentRole } : undefined }, + }), +})); + +vi.mock("@/hooks/domains/system/use-backups", () => ({ + useBackups: mocks.useBackups, +})); + +vi.mock("./job-progress-indicator", () => ({ + JobProgressIndicator: () => null, +})); + +vi.mock("./restore-dialog", () => ({ + RestoreDialog: () => null, +})); + +import { BackupsTable } from "./backups-table"; + +const SNAPSHOT: SnapshotInfo = { + name: "manual-20260101-000000.db", + kind: "manual", + size_bytes: 2048, + mtime: "2026-01-01T00:00:00Z", +}; + +const CREATE_TEST_ID = "system-backups-create"; +const DOWNLOAD_TEST_ID = "system-backups-download"; +const RESTORE_TEST_ID = "system-backups-restore"; +const DELETE_TEST_ID = "system-backups-delete"; +const NAME_TEST_ID = "system-backups-name"; + +function headerCellCount(): number { + return screen.getAllByRole("columnheader").length; +} + +function bodyCellCount(): number { + return screen.getByTestId("system-backups-row").querySelectorAll("td").length; +} + +describe("BackupsTable", () => { + afterEach(cleanup); + + beforeEach(() => { + mocks.useBackups.mockReturnValue({ + backups: [SNAPSHOT], + loaded: true, + isLoading: false, + reload: vi.fn(), + }); + }); + + // Create, download, restore, and delete are admin-only on the backend + // because a snapshot is a copy of the whole multi-user database. A member + // must not be offered a control that can only answer 403. + it("hides every mutating control from a member but keeps the listing", () => { + currentRole = "member"; + currentMode = "enabled"; + + render(); + + expect(screen.queryByTestId(CREATE_TEST_ID)).toBeNull(); + expect(screen.queryByTestId(DOWNLOAD_TEST_ID)).toBeNull(); + expect(screen.queryByTestId(RESTORE_TEST_ID)).toBeNull(); + expect(screen.queryByTestId(DELETE_TEST_ID)).toBeNull(); + expect(screen.getByTestId(NAME_TEST_ID).textContent).toBe(SNAPSHOT.name); + expect(screen.getByTestId("system-backups-admin-only")).toBeTruthy(); + // Header and body must agree: an empty action cell with no header leaves + // an unlabeled column for assistive technology. + expect(headerCellCount()).toBe(bodyCellCount()); + }); + + it("offers every control to an admin", () => { + currentRole = "admin"; + currentMode = "enabled"; + + render(); + + expect(screen.getByTestId(CREATE_TEST_ID)).toBeTruthy(); + expect(screen.getByTestId(DOWNLOAD_TEST_ID)).toBeTruthy(); + expect(screen.getByTestId(RESTORE_TEST_ID)).toBeTruthy(); + expect(screen.getByTestId(DELETE_TEST_ID)).toBeTruthy(); + expect(screen.queryByTestId("system-backups-admin-only")).toBeNull(); + expect(headerCellCount()).toBe(bodyCellCount()); + }); + + // Auth disabled: no user in the boot payload, and the backend's synthetic + // identity is an admin. Nothing may change. + it("offers every control when no user is signed in", () => { + currentRole = undefined; + currentMode = "disabled"; + + render(); + + expect(screen.getByTestId(CREATE_TEST_ID)).toBeTruthy(); + expect(screen.getByTestId(DOWNLOAD_TEST_ID)).toBeTruthy(); + expect(screen.getByTestId(RESTORE_TEST_ID)).toBeTruthy(); + expect(screen.getByTestId(DELETE_TEST_ID)).toBeTruthy(); + }); +}); diff --git a/apps/web/components/settings/system/backups-table.tsx b/apps/web/components/settings/system/backups-table.tsx index 8cc535f88c..de70c55704 100644 --- a/apps/web/components/settings/system/backups-table.tsx +++ b/apps/web/components/settings/system/backups-table.tsx @@ -9,6 +9,7 @@ import { Badge } from "@kandev/ui/badge"; import { Spinner } from "@kandev/ui/spinner"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; import { IconDownload, IconTrash, IconArchive, IconRotateClockwise } from "@tabler/icons-react"; +import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin"; import { useBackups } from "@/hooks/domains/system/use-backups"; import { buildBackupDownloadUrl, createBackup, deleteBackup } from "@/lib/api/domains/system-api"; import { formatDateTime } from "@/lib/i18n/formats"; @@ -45,7 +46,7 @@ const KIND_LABEL_KEYS: Record = { manual: "system:backupsKindManual", }; -function BackupRow({ +function BackupRowActions({ row, onRestore, onDelete, @@ -53,6 +54,61 @@ function BackupRow({ row: SnapshotInfo; onRestore: (name: string) => void; onDelete: (name: string) => void; +}) { + const { t } = useTranslation(); + return ( +
+ + + +
+ ); +} + +function BackupRow({ + row, + isAdmin, + onRestore, + onDelete, +}: { + row: SnapshotInfo; + isAdmin: boolean; + onRestore: (name: string) => void; + onDelete: (name: string) => void; }) { const { t } = useTranslation(); return ( @@ -68,57 +124,27 @@ function BackupRow({ {formatBytes(row.size_bytes)} {formatTimestamp(row.mtime)} - -
- - - -
-
+ {/* The whole cell goes, not just its contents: the Actions header is + conditional too, and an empty fifth cell would leave the table with + four headers and five cells: an unlabeled column for assistive + technology and dead width on a phone. */} + {isAdmin && ( + + + + )} ); } function BackupsList({ items, + isAdmin, onRestore, onDelete, }: { items: SnapshotInfo[]; + isAdmin: boolean; onRestore: (name: string) => void; onDelete: (name: string) => void; }) { @@ -131,12 +157,20 @@ function BackupsList({ {t("system:backupsColumnKind")} {t("system:backupsColumnSize")} {t("system:backupsColumnCreated")} - {t("system:backupsColumnActions")} + {isAdmin && ( + {t("system:backupsColumnActions")} + )} {items.map((row) => ( - + ))} @@ -168,6 +202,10 @@ async function waitForCreatedBackup( export function BackupsTable() { const { t } = useTranslation(); const { backups, loaded, isLoading, reload } = useBackups(); + // Creating, downloading, restoring, and deleting a snapshot are admin-only + // on the backend: a snapshot is a copy of the whole multi-user database. + // Members keep the read-only listing. + const isAdmin = useIsAdmin(); const [creating, setCreating] = useState(false); const [restoreName, setRestoreName] = useState(null); const [error, setError] = useState(null); @@ -207,15 +245,17 @@ export function BackupsTable() { } actions={ - + isAdmin && ( + + ) } /> @@ -223,6 +263,11 @@ export function BackupsTable() { {t("system:backupsHelp")}{" "} {t("system:backupsRetention", { count: BACKUP_RETENTION_COUNT })} + {!isAdmin && ( + + {t("system:backupsAdminOnly")} + + )} {error && {error}} {!loaded && isLoading && (
@@ -237,6 +282,7 @@ export function BackupsTable() { {items.length > 0 && ( setRestoreName(n)} onDelete={(n) => void onDelete(n)} /> diff --git a/apps/web/components/settings/system/storage/storage-adoption-field.tsx b/apps/web/components/settings/system/storage/storage-adoption-field.tsx index fceb509076..3d1b29e170 100644 --- a/apps/web/components/settings/system/storage/storage-adoption-field.tsx +++ b/apps/web/components/settings/system/storage/storage-adoption-field.tsx @@ -9,13 +9,22 @@ type Props = { setPath: (path: string) => void; onOpen: () => void; pending: boolean; + /** Overrides the default pending explanation, e.g. for the admin gate. */ + pendingReason?: string; enabled: boolean; }; -export function StorageAdoptionField({ path, setPath, onOpen, pending, enabled }: Props) { +export function StorageAdoptionField({ + path, + setPath, + onOpen, + pending, + pendingReason, + enabled, +}: Props) { const { t } = useTranslation(); let disabledReason: string | undefined; - if (pending) disabledReason = t("system:storageActionPending"); + if (pending) disabledReason = pendingReason ?? t("system:storageActionPending"); else if (!enabled) disabledReason = t("system:storageEnableGoCacheFirst"); else if (!path.trim()) disabledReason = t("system:storageEnterCachePathFirst"); const fieldLabel = t("system:storageExternalGoCache"); diff --git a/apps/web/components/settings/system/storage/storage-gating.test.ts b/apps/web/components/settings/system/storage/storage-gating.test.ts new file mode 100644 index 0000000000..478623c703 --- /dev/null +++ b/apps/web/components/settings/system/storage/storage-gating.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + policyBlockedReason, + policyControlsPending, + storageActionDisabledReason, +} from "./storage-gating"; + +const t = (key: string) => key; +const ADMIN_ONLY = "system:storageAdminOnly"; +const ACTION_PENDING = "system:storageActionPending"; + +describe("storageActionDisabledReason", () => { + it("blocks every maintenance action for a non-admin", () => { + expect(storageActionDisabledReason(t, null, false)).toBe(ADMIN_ONLY); + }); + + it("reports the admin gate ahead of a pending action", () => { + expect(storageActionDisabledReason(t, "save", false)).toBe(ADMIN_ONLY); + }); + + it("keeps the pending reason for an admin", () => { + expect(storageActionDisabledReason(t, "save", true)).toBe(ACTION_PENDING); + }); + + it("leaves an idle admin unblocked", () => { + expect(storageActionDisabledReason(t, null, true)).toBeUndefined(); + }); +}); + +describe("policyBlockedReason", () => { + it("blocks saving the policy for a non-admin", () => { + expect(policyBlockedReason(t, null, false, false)).toBe(ADMIN_ONLY); + }); + + it("blocks a non-admin even while the policy is still loading", () => { + expect(policyBlockedReason(t, null, true, false)).toBe(ADMIN_ONLY); + }); + + it("keeps the adoption-pending reason for an admin", () => { + expect(policyBlockedReason(t, "adopt", false, true)).toBe("system:storageAdoptionPending"); + }); + + it("keeps the loading reason for an admin", () => { + expect(policyBlockedReason(t, null, true, true)).toBe("system:storagePolicyLoadingBlock"); + }); + + it("leaves a ready admin unblocked", () => { + expect(policyBlockedReason(t, null, false, true)).toBeUndefined(); + }); +}); + +describe("policyControlsPending", () => { + it("locks the policy controls while a save or adoption is in flight", () => { + expect(policyControlsPending("save", undefined)).toBe(true); + expect(policyControlsPending("adopt", undefined)).toBe(true); + }); + + it("leaves the controls live for an idle admin", () => { + expect(policyControlsPending(null, undefined)).toBe(false); + expect(policyControlsPending("analyze", undefined)).toBe(false); + }); + + it("locks the controls whenever a read-only reason applies", () => { + expect(policyControlsPending(null, ADMIN_ONLY)).toBe(true); + }); +}); diff --git a/apps/web/components/settings/system/storage/storage-gating.ts b/apps/web/components/settings/system/storage/storage-gating.ts new file mode 100644 index 0000000000..a0025e1096 --- /dev/null +++ b/apps/web/components/settings/system/storage/storage-gating.ts @@ -0,0 +1,46 @@ +import type { StoragePendingAction } from "@/hooks/domains/system/use-storage-maintenance"; + +type Translate = (key: string) => string; + +/** + * Why the storage maintenance actions (analyze, cleanup, quarantine + * restore/purge) are unavailable, or `undefined` when they are. + * + * The admin gate wins over the transient pending reason: those routes are + * mounted on the admin group, so for a member they are permanently + * unavailable rather than momentarily busy. + */ +export function storageActionDisabledReason( + t: Translate, + action: StoragePendingAction, + isAdmin: boolean, +): string | undefined { + if (!isAdmin) return t("system:storageAdminOnly"); + if (action) return t("system:storageActionPending"); + return undefined; +} + +/** Why the storage policy form cannot be edited or saved, if it cannot be. */ +export function policyBlockedReason( + t: Translate, + action: StoragePendingAction, + loading: boolean, + isAdmin: boolean, +): string | undefined { + if (!isAdmin) return t("system:storageAdminOnly"); + if (action === "adopt") return t("system:storageAdoptionPending"); + if (loading) return t("system:storagePolicyLoadingBlock"); + return undefined; +} + +/** + * Whether the storage policy form's controls are locked: either an action + * that rewrites the policy is already in flight, or the caller may not edit + * it at all (see `policyBlockedReason`). + */ +export function policyControlsPending( + action: StoragePendingAction, + readOnlyReason: string | undefined, +): boolean { + return action === "save" || action === "adopt" || Boolean(readOnlyReason); +} diff --git a/apps/web/components/settings/system/storage/storage-maintenance-settings.test.tsx b/apps/web/components/settings/system/storage/storage-maintenance-settings.test.tsx index 1b6400cf46..bbcbfcd167 100644 --- a/apps/web/components/settings/system/storage/storage-maintenance-settings.test.tsx +++ b/apps/web/components/settings/system/storage/storage-maintenance-settings.test.tsx @@ -13,6 +13,22 @@ const mocks = vi.hoisted(() => ({ const IDLE_PERIOD_TEST_ID = "storage-idle-period"; const SAVE_BUTTON_NAME = "Save changes"; const ANALYZE_TEST_ID = "storage-analyze"; +const RUN_NOW_TEST_ID = "storage-run-now"; +// Mirrors the auth slice the admin gate reads. `undefined` is the +// auth-disabled single-user mode, which the backend treats as an admin. +let currentRole: "admin" | "member" | undefined; +let currentMode: "disabled" | "setup" | "enabled" = "enabled"; + +// Mirrors the auth slice the admin gate reads. `currentMode` distinguishes +// auth-disabled single-user mode (synthetic admin) from a cleared session. +vi.mock("@/components/state-provider", () => ({ + useAppStore: ( + selector: (state: { auth: { mode: string; user?: { role: string } } }) => unknown, + ) => + selector({ + auth: { mode: currentMode, user: currentRole ? { role: currentRole } : undefined }, + }), +})); vi.mock("@/hooks/domains/system/use-storage-maintenance", async (importOriginal) => ({ ...(await importOriginal()), @@ -112,10 +128,49 @@ describe("StorageMaintenanceSettings", () => { afterEach(cleanup); beforeEach(() => { + currentRole = "admin"; + currentMode = "enabled"; mocks.useSystemJob.mockReturnValue(undefined); mocks.useStorageMaintenance.mockReturnValue(controller(overview)); }); + // Every mutating storage route is admin-only on the backend, so a member's + // controls must be disabled here rather than 403 after the click. + it("disables the maintenance actions and the policy form for a member", () => { + currentRole = "member"; + currentMode = "enabled"; + const editableOverview = { ...overview, settings: { ...overview.settings, enabled: true } }; + mocks.useStorageMaintenance.mockReturnValue(controller(editableOverview)); + + render(, { wrapper: Providers }); + + expect((screen.getByTestId(ANALYZE_TEST_ID) as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByTestId(RUN_NOW_TEST_ID) as HTMLButtonElement).disabled).toBe(true); + const idlePeriod = screen.getByTestId(IDLE_PERIOD_TEST_ID) as HTMLInputElement; + expect(idlePeriod.disabled).toBe(true); + // A disabled field cannot go dirty, so the shared Save control stays + // inert: a member has no way to reach the admin-only PATCH at all. + fireEvent.change(idlePeriod, { target: { value: "31" } }); + expect( + (screen.getByRole("button", { name: SAVE_BUTTON_NAME }) as HTMLButtonElement).disabled, + ).toBe(true); + }); + + // Auth disabled: no user in the boot payload, and the backend's synthetic + // identity is an admin. Nothing may change. + it("leaves every control live when no user is signed in", () => { + currentRole = undefined; + currentMode = "disabled"; + const editableOverview = { ...overview, settings: { ...overview.settings, enabled: true } }; + mocks.useStorageMaintenance.mockReturnValue(controller(editableOverview)); + + render(, { wrapper: Providers }); + + expect((screen.getByTestId(ANALYZE_TEST_ID) as HTMLButtonElement).disabled).toBe(false); + expect((screen.getByTestId(RUN_NOW_TEST_ID) as HTMLButtonElement).disabled).toBe(false); + expect((screen.getByTestId(IDLE_PERIOD_TEST_ID) as HTMLInputElement).disabled).toBe(false); + }); + it("shows analysis completion inside the Analyze button", () => { const analysisJob = { id: "analysis-1", @@ -231,6 +286,10 @@ describe("StorageMaintenanceSettings busy feedback", () => { afterEach(cleanup); beforeEach(() => { + // Reset the role explicitly: without it these tests inherit whatever the + // previous describe block left behind and pass only in file order. + currentRole = undefined; + currentMode = "disabled"; mocks.useSystemJob.mockReturnValue(undefined); }); @@ -256,11 +315,38 @@ describe("StorageMaintenanceSettings busy feedback", () => { fireEvent.click(screen.getByRole("button", { name: "Run anyway" })); expect(runAnyway).toHaveBeenCalledTimes(1); }); + + // Run anyway forces POST /storage/run, which is admin only. The busy alert + // is a second, independent path to that call: gating the action buttons + // above it is not enough. + it("does not expose Run anyway to a member even when force is available", () => { + currentRole = "member"; + currentMode = "enabled"; + const runAnyway = vi.fn(); + mocks.useStorageMaintenance.mockReturnValue({ + ...controller(overview), + busy: { + resources: [{ kind: "execution_running", label: "An agent execution is running" }], + forceAvailable: true, + }, + runAnyway, + }); + + render(, { wrapper: Providers }); + + expect(screen.queryByTestId("storage-run-anyway")).toBeNull(); + expect(runAnyway).not.toHaveBeenCalled(); + }); }); describe("StorageMaintenanceSettings pending policy", () => { afterEach(cleanup); + beforeEach(() => { + currentRole = undefined; + currentMode = "disabled"; + }); + it("rebases an adopted Go cache path into a dirty policy draft before shared save", async () => { const currentController = controller(overview); mocks.useStorageMaintenance.mockReturnValue(currentController); @@ -361,6 +447,8 @@ describe("StorageMaintenanceSettings coordinated save", () => { afterEach(cleanup); beforeEach(() => { + currentRole = undefined; + currentMode = "disabled"; mocks.useSystemJob.mockReturnValue(undefined); }); diff --git a/apps/web/components/settings/system/storage/storage-maintenance-settings.tsx b/apps/web/components/settings/system/storage/storage-maintenance-settings.tsx index b78e9f1bb6..c8fb2b1760 100644 --- a/apps/web/components/settings/system/storage/storage-maintenance-settings.tsx +++ b/apps/web/components/settings/system/storage/storage-maintenance-settings.tsx @@ -12,7 +12,13 @@ import { } from "@/hooks/domains/system/use-storage-maintenance"; import type { StorageMaintenanceSettings as Settings, SystemJob } from "@/lib/types/system"; import { useSettingsSaveContributor } from "../../settings-save-provider"; +import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin"; import { StorageActionButton } from "./storage-action-button"; +import { + policyBlockedReason, + policyControlsPending, + storageActionDisabledReason, +} from "./storage-gating"; import { StorageDiskCapacityCard } from "./storage-disk-capacity-card"; import { StorageOverviewCard } from "./storage-overview-card"; import { StoragePolicyCard } from "./storage-policy-card"; @@ -135,13 +141,19 @@ function StorageActions({ function StorageActionFeedback({ controller, + isAdmin, }: { controller: ReturnType; + isAdmin: boolean; }) { const { t } = useTranslation(); if (controller.busy) { return ( - void controller.runAnyway()} /> + void controller.runAnyway()} + /> ); } if (!controller.error) return null; @@ -156,9 +168,11 @@ function StorageActionFeedback({ function StorageBusyFeedback({ busy, + isAdmin, onRunAnyway, }: { busy: StorageBusyState; + isAdmin: boolean; onRunAnyway: () => void; }) { const { t } = useTranslation(); @@ -174,7 +188,10 @@ function StorageBusyFeedback({
  • {resource.label}
  • ))} - {busy.forceAvailable && ( + {/* Run anyway forces POST /storage/run, which is admin only. The busy + alert is a second, independent path to that call, so it carries the + same gate as the action buttons rather than trusting them. */} + {busy.forceAvailable && isAdmin && ( <>

    {t("system:storageBusyForceHint")}

    ["pendingAction"]) { - return action === "save" || action === "adopt"; -} - -function policyBlockedReason( - t: (key: string) => string, - action: ReturnType["pendingAction"], - loading: boolean, +function useStoragePolicyDraft( + controller: ReturnType, + isAdmin: boolean, ) { - if (action === "adopt") return t("system:storageAdoptionPending"); - if (loading) return t("system:storagePolicyLoadingBlock"); - return undefined; -} - -function storageActionDisabledReason( - t: (key: string) => string, - action: ReturnType["pendingAction"], -) { - if (action) return t("system:storageActionPending"); - return undefined; -} - -function useStoragePolicyDraft(controller: ReturnType) { const { t } = useTranslation(); const [draft, setDraft] = useState(null); const previousServerSettings = useRef(null); @@ -240,7 +238,7 @@ function useStoragePolicyDraft(controller: ReturnType; disabledReason?: string; + readOnlyReason?: string; draft: Settings | null; setDraft: (settings: Settings | null) => void; savedSettings: Settings | null; onRunTemporaryArtifacts: () => void; }) { - const controlsPending = policyPendingAction(controller.pendingAction); + const controlsPending = policyControlsPending(controller.pendingAction, readOnlyReason); const policyLoading = controller.loading?.policy ?? !savedSettings; const capabilities = controller.policy?.capabilities ?? controller.overview?.capabilities; return ( @@ -321,6 +321,7 @@ function StoragePrimarySections({ savedSettings={savedSettings} capabilities={capabilities} pending={controlsPending} + pendingReason={readOnlyReason} onChange={setDraft} onAdopt={controller.adopt} onCleanDependencies={() => void controller.runNow(["workspace_dependencies"])} @@ -362,18 +363,23 @@ function StorageQuarantineSection({ function StoragePageSections({ controller, disabledReason, + readOnlyReason, + isAdmin, onRunTemporaryArtifacts, }: { controller: ReturnType; disabledReason?: string; + readOnlyReason?: string; + isAdmin: boolean; onRunTemporaryArtifacts: () => void; }) { - const { draft, setDraft, savedSettings } = useStoragePolicyDraft(controller); + const { draft, setDraft, savedSettings } = useStoragePolicyDraft(controller, isAdmin); return ( <> - + setTemporaryCleanupOpen(true)} /> void; onAdopt: (path: string) => Promise; onCleanDependencies?: () => void; @@ -24,7 +29,7 @@ type Props = { type PolicySectionProps = Pick< Props, - "settings" | "savedSettings" | "capabilities" | "onChange" | "pending" + "settings" | "savedSettings" | "capabilities" | "onChange" | "pending" | "pendingReason" >; function settingIsDirty( @@ -98,6 +103,7 @@ function WorkspaceSection({ settings, savedSettings, pending, + pendingReason, onChange, onCleanDependencies, }: PolicySectionProps & Pick) { @@ -158,6 +164,7 @@ function WorkspaceSection({ settings={settings} savedSettings={savedSettings} pending={pending} + pendingReason={pendingReason} onChange={onChange} onCleanDependencies={onCleanDependencies} /> @@ -184,6 +191,7 @@ function GoCacheSection({ savedSettings, capabilities, pending, + pendingReason, onChange, adoptionPath, setAdoptionPath, @@ -248,6 +256,7 @@ function GoCacheSection({ path={adoptionPath} setPath={setAdoptionPath} pending={pending} + pendingReason={pendingReason} enabled={settings.go_cache.enabled} onOpen={onOpenAdoption} /> @@ -381,6 +390,7 @@ function DockerSection({ savedSettings, capabilities, pending, + pendingReason, onChange, onOpenDedicated, }: PolicySectionProps & { onOpenDedicated: () => void }) { @@ -393,7 +403,7 @@ function DockerSection({ ? undefined : t("system:storageDockerUnavailable"); const disabledReason = - (pending ? t("system:storageActionPending") : undefined) ?? + (pending ? (pendingReason ?? t("system:storageActionPending")) : undefined) ?? unavailable ?? (!settings.docker.dedicated_daemon_acknowledged ? t("system:storageAcknowledgeDedicatedFirst") @@ -483,6 +493,7 @@ export function StoragePolicyCard({ savedSettings, capabilities, pending, + pendingReason, onChange, onAdopt, onCleanDependencies, @@ -516,6 +527,7 @@ export function StoragePolicyCard({ savedSettings={savedSettings} capabilities={capabilities} pending={pending} + pendingReason={pendingReason} onChange={onChange} /> @@ -531,6 +544,7 @@ export function StoragePolicyCard({ savedSettings={savedSettings} capabilities={capabilities} pending={pending} + pendingReason={pendingReason} onChange={onChange} adoptionPath={adoptionPath} setAdoptionPath={setAdoptionPath} @@ -541,6 +555,7 @@ export function StoragePolicyCard({ savedSettings={savedSettings} capabilities={capabilities} pending={pending} + pendingReason={pendingReason} onChange={onChange} onOpenDedicated={() => setDockerDialogOpen(true)} /> @@ -549,6 +564,7 @@ export function StoragePolicyCard({ savedSettings={savedSettings} capabilities={capabilities} pending={pending} + pendingReason={pendingReason} onChange={onChange} />
    diff --git a/apps/web/components/settings/system/storage/storage-workspace-dependency-settings.tsx b/apps/web/components/settings/system/storage/storage-workspace-dependency-settings.tsx index fc6bbf4db1..f79909174d 100644 --- a/apps/web/components/settings/system/storage/storage-workspace-dependency-settings.tsx +++ b/apps/web/components/settings/system/storage/storage-workspace-dependency-settings.tsx @@ -26,6 +26,8 @@ type Props = { settings: StorageMaintenanceSettings; savedSettings: StorageMaintenanceSettings; pending: boolean; + /** Overrides the default pending explanation, e.g. for the admin gate. */ + pendingReason?: string; onChange: (settings: StorageMaintenanceSettings) => void; onCleanDependencies?: () => void; }; @@ -34,6 +36,7 @@ export function StorageWorkspaceDependencySettings({ settings, savedSettings, pending, + pendingReason, onChange, onCleanDependencies, }: Props) { @@ -43,7 +46,7 @@ export function StorageWorkspaceDependencySettings({ const dependencyCleanupDirty = dependenciesEnabled !== savedDependenciesEnabled; let dependencyActionDisabledReason: string | undefined; if (pending) { - dependencyActionDisabledReason = t("system:storageActionPending"); + dependencyActionDisabledReason = pendingReason ?? t("system:storageActionPending"); } else if (!savedDependenciesEnabled) { dependencyActionDisabledReason = t("system:storageEnableWorkspaceDependenciesAndSave"); } diff --git a/apps/web/e2e/tests/auth/data-storage-gating-helpers.ts b/apps/web/e2e/tests/auth/data-storage-gating-helpers.ts new file mode 100644 index 0000000000..1c972eca1d --- /dev/null +++ b/apps/web/e2e/tests/auth/data-storage-gating-helpers.ts @@ -0,0 +1,154 @@ +import { expect, type APIRequestContext, type BrowserContext } from "@playwright/test"; + +/** + * Shared setup and API probes for the Data & storage member-gating specs + * (system-data-storage-member-gating.spec.ts desktop and its mobile-* twin). + * + * The backend mounts system backups and storage maintenance on two route + * groups: reads are open to any authenticated caller, mutations and the + * snapshot download require the admin role. These helpers exercise that split + * over the real HTTP stack with a real member session, so the specs can spend + * their assertions on the rendered gating. + */ + +export const GATING_ADMIN = { + email: "admin@demo.dev", + password: "adminpass123", + displayName: "Ada Admin", +}; + +export const GATING_MEMBER = { + email: "sam@demo.dev", + password: "memberpass123", + displayName: "Sam Member", +}; + +export const DATA_STORAGE_ROUTE = "/settings/system/data-storage"; + +/** Creates the member account from an authenticated admin context. */ +export async function createMember(adminContext: BrowserContext, baseUrl: string): Promise { + const res = await adminContext.request.post(`${baseUrl}/api/v1/users`, { + data: { + email: GATING_MEMBER.email, + password: GATING_MEMBER.password, + display_name: GATING_MEMBER.displayName, + role: "member", + }, + }); + expect(res.status(), await res.text()).toBe(201); +} + +type Snapshot = { name: string; kind: string }; + +/** + * Creates a manual snapshot as the admin and resolves its filename. Create is + * asynchronous (202 + job id), so poll the listing until the file lands: the + * member specs need a real row to prove the listing survives the gate. + */ +export async function createSnapshotAsAdmin( + adminContext: BrowserContext, + baseUrl: string, +): Promise { + const created = await adminContext.request.post(`${baseUrl}/api/v1/system/backups`); + expect(created.status(), await created.text()).toBe(202); + + let name = ""; + await expect + .poll( + async () => { + const snapshots = await listSnapshots(adminContext.request, baseUrl); + const manual = snapshots.find((snapshot) => snapshot.kind === "manual"); + name = manual?.name ?? ""; + return name; + }, + { timeout: 20_000 }, + ) + .not.toBe(""); + return name; +} + +export async function listSnapshots( + request: APIRequestContext, + baseUrl: string, +): Promise { + const res = await request.get(`${baseUrl}/api/v1/system/backups`); + expect(res.status(), await res.text()).toBe(200); + const body = (await res.json()) as { snapshots?: Snapshot[] }; + return body.snapshots ?? []; +} + +/** + * Asserts the backend refuses every mutating and exporting route for a member + * while leaving the reads open. This is the security contract itself; the + * rendered gating in the specs only keeps a member from meeting a 403. + */ +export async function expectMemberApiGating( + memberContext: BrowserContext, + baseUrl: string, + snapshotName: string, +): Promise { + const api = memberContext.request; + const system = `${baseUrl}/api/v1/system`; + + for (const path of [ + "/backups", + "/storage", + "/storage/disk", + "/storage/settings", + "/storage/runs", + "/storage/quarantine", + ]) { + const res = await api.get(`${system}${path}`); + expect(res.status(), `GET ${path}: ${await res.text()}`).toBe(200); + } + + // The download is the most serious of these: a snapshot is a byte copy of + // every user's rows, so a member must never receive one. + const download = await api.get(`${system}/backups/${snapshotName}/download`); + expect(download.status(), await download.text()).toBe(403); + + const denials: Array<[string, Promise<{ status(): number; text(): Promise }>]> = [ + ["POST /backups", api.post(`${system}/backups`)], + [ + "POST /backups/:name/restore", + api.post(`${system}/backups/${snapshotName}/restore`, { data: { confirm: "RESTORE" } }), + ], + ["DELETE /backups/:name", api.delete(`${system}/backups/${snapshotName}`)], + ["POST /storage/analyze", api.post(`${system}/storage/analyze`, { data: {} })], + ["POST /storage/run", api.post(`${system}/storage/run`, { data: {} })], + [ + "POST /storage/go-cache/adopt", + api.post(`${system}/storage/go-cache/adopt`, { data: { path: "/tmp/c", confirm: "ADOPT" } }), + ], + [ + "POST /storage/quarantine/:id/restore", + api.post(`${system}/storage/quarantine/entry-1/restore`, { data: {} }), + ], + [ + "DELETE /storage/quarantine/:id", + api.delete(`${system}/storage/quarantine/entry-1`, { data: { confirm: "DELETE" } }), + ], + [ + "DELETE /storage/quarantine", + api.delete(`${system}/storage/quarantine`, { + data: { scope: "eligible", confirm: "DELETE ELIGIBLE" }, + }), + ], + ]; + for (const [label, pending] of denials) { + const res = await pending; + expect(res.status(), `${label}: ${await res.text()}`).toBe(403); + } + + // PATCH needs a valid body so a 403 cannot be mistaken for a 400. + const settings = await api.get(`${system}/storage/settings`); + const currentSettings = ((await settings.json()) as { settings: unknown }).settings; + const patch = await api.patch(`${system}/storage/settings`, { + data: { settings: currentSettings }, + }); + expect(patch.status(), await patch.text()).toBe(403); + + // The snapshot must still be there after every attempt above. + const remaining = await listSnapshots(api, baseUrl); + expect(remaining.map((snapshot) => snapshot.name)).toContain(snapshotName); +} diff --git a/apps/web/e2e/tests/auth/mobile-system-data-storage-member-gating.spec.ts b/apps/web/e2e/tests/auth/mobile-system-data-storage-member-gating.spec.ts new file mode 100644 index 0000000000..4661405ff8 --- /dev/null +++ b/apps/web/e2e/tests/auth/mobile-system-data-storage-member-gating.spec.ts @@ -0,0 +1,127 @@ +import { devices, expect } from "@playwright/test"; +import path from "node:path"; +import { backendFixture as test } from "../../fixtures/backend"; +import { login, setupAdmin } from "../../helpers/auth"; +import { + createMember, + createSnapshotAsAdmin, + DATA_STORAGE_ROUTE, + expectMemberApiGating, + GATING_ADMIN, + GATING_MEMBER, +} from "./data-storage-gating-helpers"; + +/** + * Mobile parity for the Data & storage member gate. The gating adds no new + * composition: it removes controls from the backups table and disables the + * storage actions inside surfaces that already have shipped phone layouts + * (see tests/system/mobile-storage-maintenance.spec.ts). What is + * viewport-specific is the consequence: dropping the backups Actions column + * changes the table's column count, and a table is the surface most likely to + * push a phone into horizontal scroll. This spec proves the member view is + * complete, inert, and contained at Pixel 5 width. + * + * Runs in the `mobile-chrome` project (routed away from the desktop `auth` + * project via its testIgnore) and restarts the worker backend with auth on + * and its own database, mirroring system-data-storage-member-gating.spec.ts. + * afterAll restarts to the fixture baseline. + */ +test.describe.serial("Data & storage member gating (mobile)", () => { + let snapshotName = ""; + + test.beforeAll(async ({ backend, browser }) => { + await backend.restart({ + KANDEV_FEATURES_AUTH: "true", + KANDEV_DATABASE_PATH: path.join(backend.tmpDir, "kandev-mobile-data-storage.db"), + }); + const adminContext = await browser.newContext({ + ...devices["Pixel 5"], + baseURL: backend.frontendUrl, + }); + await setupAdmin(adminContext, backend.baseUrl, GATING_ADMIN); + await login(adminContext, backend.baseUrl, GATING_ADMIN); + await createMember(adminContext, backend.baseUrl); + snapshotName = await createSnapshotAsAdmin(adminContext, backend.baseUrl); + await adminContext.close(); + }); + + test.afterAll(async ({ backend }) => { + await backend.restart(); + }); + + test("a member gets the read-only view, inert and contained, on a phone", async ({ + browser, + backend, + }) => { + // Manual contexts do not inherit the project's device options; spread + // them explicitly so this spec actually exercises the mobile viewport. + const ctx = await browser.newContext({ + ...devices["Pixel 5"], + baseURL: backend.frontendUrl, + }); + await login(ctx, backend.baseUrl, GATING_MEMBER); + await expectMemberApiGating(ctx, backend.baseUrl, snapshotName); + + const page = await ctx.newPage(); + expect((await page.viewportSize())?.width).toBe(393); + await page.goto(DATA_STORAGE_ROUTE); + + await expect(page.getByTestId("system-backups-name").first()).toHaveText(snapshotName, { + timeout: 15_000, + }); + await expect(page.getByTestId("system-backups-admin-only")).toBeVisible(); + await expect(page.getByTestId("system-backups-create")).toHaveCount(0); + await expect(page.getByTestId("system-backups-download")).toHaveCount(0); + await expect(page.getByTestId("system-backups-restore")).toHaveCount(0); + await expect(page.getByTestId("system-backups-delete")).toHaveCount(0); + + const analyze = page.getByTestId("storage-analyze"); + await expect(analyze).toBeDisabled(); + await expect(page.getByTestId("storage-run-now")).toBeDisabled(); + + // The reason must stay reachable by touch rather than hover-only: the + // disabled control keeps a touch-sized target so its tooltip trigger is + // usable on a phone. + const analyzeBox = await analyze.boundingBox(); + expect(analyzeBox).not.toBeNull(); + expect(analyzeBox!.height).toBeGreaterThanOrEqual(44); + + // The narrower table must not push the document into horizontal scroll. + await expect + .poll(() => + page.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ) + .toBe(true); + + await ctx.close(); + }); + + test("an admin still gets every control on a phone", async ({ browser, backend }) => { + const ctx = await browser.newContext({ + ...devices["Pixel 5"], + baseURL: backend.frontendUrl, + }); + await login(ctx, backend.baseUrl, GATING_ADMIN); + + const page = await ctx.newPage(); + expect((await page.viewportSize())?.width).toBe(393); + await page.goto(DATA_STORAGE_ROUTE); + + await expect(page.getByTestId("system-backups-create")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("system-backups-delete").first()).toBeVisible(); + await expect(page.getByTestId("system-backups-admin-only")).toHaveCount(0); + await expect(page.getByTestId("storage-analyze")).toBeEnabled(); + + await expect + .poll(() => + page.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ) + .toBe(true); + + await ctx.close(); + }); +}); diff --git a/apps/web/e2e/tests/auth/system-data-storage-member-gating.spec.ts b/apps/web/e2e/tests/auth/system-data-storage-member-gating.spec.ts new file mode 100644 index 0000000000..79d525f618 --- /dev/null +++ b/apps/web/e2e/tests/auth/system-data-storage-member-gating.spec.ts @@ -0,0 +1,95 @@ +import { expect } from "@playwright/test"; +import path from "node:path"; +import { backendFixture as test } from "../../fixtures/backend"; +import { login, setupAdmin } from "../../helpers/auth"; +import { + createMember, + createSnapshotAsAdmin, + DATA_STORAGE_ROUTE, + expectMemberApiGating, + GATING_ADMIN, + GATING_MEMBER, +} from "./data-storage-gating-helpers"; + +/** + * System backups and storage maintenance act on the whole install: a snapshot + * is a byte copy of the multi-user database, and a cleanup pass rewrites + * shared state. Both are admin only on the backend, so Settings > System > + * Data & storage must not offer a member controls that can only answer 403. + * + * Runs in the `auth` project (backend restarted with auth required). Serial: + * the two tests share the admin, member, and snapshot created in beforeAll. + * A per-file database keeps the single-shot auth setup from colliding with + * the other auth specs sharing the worker; afterAll restarts to baseline. + */ +test.describe.serial("Data & storage member gating", () => { + let snapshotName = ""; + + test.beforeAll(async ({ backend, browser }) => { + await backend.restart({ + KANDEV_FEATURES_AUTH: "true", + KANDEV_DATABASE_PATH: path.join(backend.tmpDir, "kandev-auth-data-storage.db"), + }); + const adminContext = await browser.newContext({ baseURL: backend.frontendUrl }); + await setupAdmin(adminContext, backend.baseUrl, GATING_ADMIN); + await login(adminContext, backend.baseUrl, GATING_ADMIN); + await createMember(adminContext, backend.baseUrl); + snapshotName = await createSnapshotAsAdmin(adminContext, backend.baseUrl); + await adminContext.close(); + }); + + test.afterAll(async ({ backend }) => { + await backend.restart(); + }); + + test("a member keeps the read-only view and no mutating control", async ({ + browser, + backend, + }) => { + const ctx = await browser.newContext({ baseURL: backend.frontendUrl }); + await login(ctx, backend.baseUrl, GATING_MEMBER); + await expectMemberApiGating(ctx, backend.baseUrl, snapshotName); + + const page = await ctx.newPage(); + await page.goto(DATA_STORAGE_ROUTE); + + // The listing survives the gate: the member still sees the snapshot's + // name, size and timestamp, which is all the read route now carries. + const row = page.locator('[data-testid="system-backups-row"]', { + has: page.locator(`[data-testid="system-backups-name"]`), + }); + await expect(row.first()).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("system-backups-name").first()).toHaveText(snapshotName); + await expect(page.getByTestId("system-backups-admin-only")).toBeVisible(); + + // Every backups control that answers 403 is gone, not merely disabled. + await expect(page.getByTestId("system-backups-create")).toHaveCount(0); + await expect(page.getByTestId("system-backups-download")).toHaveCount(0); + await expect(page.getByTestId("system-backups-restore")).toHaveCount(0); + await expect(page.getByTestId("system-backups-delete")).toHaveCount(0); + + // Storage keeps its controls visible but inert, with the reason attached. + await expect(page.getByTestId("storage-analyze")).toBeDisabled(); + await expect(page.getByTestId("storage-run-now")).toBeDisabled(); + + await ctx.close(); + }); + + test("an admin still gets every control on the same page", async ({ browser, backend }) => { + const ctx = await browser.newContext({ baseURL: backend.frontendUrl }); + await login(ctx, backend.baseUrl, GATING_ADMIN); + + const page = await ctx.newPage(); + await page.goto(DATA_STORAGE_ROUTE); + + await expect(page.getByTestId("system-backups-create")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("system-backups-download").first()).toBeVisible(); + await expect(page.getByTestId("system-backups-restore").first()).toBeVisible(); + await expect(page.getByTestId("system-backups-delete").first()).toBeVisible(); + await expect(page.getByTestId("system-backups-admin-only")).toHaveCount(0); + await expect(page.getByTestId("storage-analyze")).toBeEnabled(); + await expect(page.getByTestId("storage-run-now")).toBeEnabled(); + + await ctx.close(); + }); +}); diff --git a/apps/web/hooks/domains/auth/use-is-admin.ts b/apps/web/hooks/domains/auth/use-is-admin.ts new file mode 100644 index 0000000000..20499965b7 --- /dev/null +++ b/apps/web/hooks/domains/auth/use-is-admin.ts @@ -0,0 +1,11 @@ +"use client"; + +import { useAppStore } from "@/components/state-provider"; +import { isAdminIdentity } from "@/lib/auth/is-admin"; + +/** Whether the current caller may use admin-only, install-wide controls. */ +export function useIsAdmin(): boolean { + const mode = useAppStore((state) => state.auth.mode); + const role = useAppStore((state) => state.auth.user?.role); + return isAdminIdentity(mode, role); +} diff --git a/apps/web/lib/auth/is-admin.test.ts b/apps/web/lib/auth/is-admin.test.ts new file mode 100644 index 0000000000..4750389df5 --- /dev/null +++ b/apps/web/lib/auth/is-admin.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { isAdminIdentity } from "./is-admin"; + +describe("isAdminIdentity", () => { + it("treats an admin role as admin while auth is enabled", () => { + expect(isAdminIdentity("enabled", "admin")).toBe(true); + }); + + it("treats a member role as not admin", () => { + expect(isAdminIdentity("enabled", "member")).toBe(false); + }); + + // Auth disabled: the boot payload carries no user, and the backend's + // synthetic single-user identity is an admin. This is the case that must + // keep every install-wide control available in single-user mode. + it("treats the auth-disabled single user as admin", () => { + expect(isAdminIdentity("disabled", undefined)).toBe(true); + }); + + // A backend old enough to send no auth block at all hydrates the slice + // defaults, which are mode "disabled". + it("treats a missing auth block as the disabled single user", () => { + expect(isAdminIdentity(undefined, undefined)).toBe(true); + }); + + // Session expiry calls clearAuthenticated(), which nulls the user but + // leaves mode "enabled" while the redirect to /login is in flight. Reading + // that as admin would flash install-wide controls at a signed-out caller. + it("does not treat a cleared session as admin", () => { + expect(isAdminIdentity("enabled", undefined)).toBe(false); + }); + + it("does not treat setup mode as admin", () => { + expect(isAdminIdentity("setup", undefined)).toBe(false); + }); + + it("treats an unknown role as not admin", () => { + expect(isAdminIdentity("enabled", "guest")).toBe(false); + }); +}); diff --git a/apps/web/lib/auth/is-admin.ts b/apps/web/lib/auth/is-admin.ts new file mode 100644 index 0000000000..6f2a7e08be --- /dev/null +++ b/apps/web/lib/auth/is-admin.ts @@ -0,0 +1,22 @@ +import type { AuthMode } from "@/lib/state/slices/auth/types"; + +/** + * Role gate for install-wide controls, mirroring `authn.RequireAdmin` on the + * backend. + * + * Mode is read alongside the role because "no user" means two different + * things. With authentication disabled the boot payload carries no user and + * the backend injects a synthetic *admin* identity on every request, so the + * UI must match or turning auth off would hide the controls it is meant to + * leave untouched. With authentication enabled, "no user" instead means the + * session was cleared: `clearAuthenticated()` nulls the user but leaves mode + * "enabled" while the redirect to /login is in flight, and reading that as + * admin would flash install-wide controls at a signed-out caller. + * + * A missing mode is the slice default, which is "disabled" for backends old + * enough to send no auth block at all. + */ +export function isAdminIdentity(mode: AuthMode | undefined, role: string | undefined): boolean { + if (mode === undefined || mode === "disabled") return true; + return role === "admin"; +} diff --git a/apps/web/lib/state/slices/system/system-slice.test.ts b/apps/web/lib/state/slices/system/system-slice.test.ts index 7b649d5930..996a91a1d6 100644 --- a/apps/web/lib/state/slices/system/system-slice.test.ts +++ b/apps/web/lib/state/slices/system/system-slice.test.ts @@ -61,7 +61,6 @@ const DB_STATS: DatabaseStats = { const SNAPSHOT: SnapshotInfo = { name: "manual-1.db", - path: "/data/backups/manual-1.db", size_bytes: 1024, mtime: "2026-05-17T00:00:00Z", kind: "manual", diff --git a/apps/web/lib/types/system.ts b/apps/web/lib/types/system.ts index 1ad74049eb..4c0cad2062 100644 --- a/apps/web/lib/types/system.ts +++ b/apps/web/lib/types/system.ts @@ -47,7 +47,6 @@ export type SnapshotKind = "auto" | "manual"; export interface SnapshotInfo { name: string; - path: string; size_bytes: number; /** ISO timestamp. */ mtime: string; diff --git a/apps/web/src/locales/en/system.json b/apps/web/src/locales/en/system.json index ad08e18e08..26e85c3b7d 100644 --- a/apps/web/src/locales/en/system.json +++ b/apps/web/src/locales/en/system.json @@ -13,6 +13,7 @@ "actionDone": "Done", "actionFailed": "Failed", "actionRunning": "Running...", + "backupsAdminOnly": "Only administrators can create, download, restore, or delete backups.", "backupsColumnActions": "Actions", "backupsColumnCreated": "Created", "backupsColumnKind": "Kind", @@ -263,6 +264,7 @@ "storageActionPending": "Wait for the current storage action to finish.", "storageActionsDescription": "Analyze for a read-only snapshot, or run the enabled cleanup rules when you want to recover space immediately.", "storageActionsTitle": "Reclaim disk space safely", + "storageAdminOnly": "Only administrators can change storage settings or run maintenance.", "storageAdoptCache": "Adopt cache", "storageAdoptDialogDescription": "Kandev will be allowed to rotate and quarantine the existing cache at {{path}}. This path must be absolute and on the same filesystem as Kandev trash.", "storageAdoptDialogFallbackPath": "the selected path", diff --git a/apps/web/src/locales/pseudo/system.json b/apps/web/src/locales/pseudo/system.json index 32907f6cc5..604f7a6698 100644 --- a/apps/web/src/locales/pseudo/system.json +++ b/apps/web/src/locales/pseudo/system.json @@ -13,6 +13,7 @@ "actionDone": "Ďōńē", "actionFailed": "Ƒàĩĺēď", "actionRunning": "Ŕũńńĩńĝ...", + "backupsAdminOnly": "Ōńĺŷ àďḿĩńĩśţŕàţōŕś ćàń ćŕēàţē, ďōŵńĺōàď, ŕēśţōŕē, ōŕ ďēĺēţē ƀàćķũƥś.", "backupsColumnActions": "Àćţĩōńś", "backupsColumnCreated": "Ćŕēàţēď", "backupsColumnKind": "Ķĩńď", @@ -263,6 +264,7 @@ "storageActionPending": "Ŵàĩţ ƒōŕ ţĥē ćũŕŕēńţ śţōŕàĝē àćţĩōń ţō ƒĩńĩśĥ.", "storageActionsDescription": "Àńàĺŷźē ƒōŕ à ŕēàď-ōńĺŷ śńàƥśĥōţ, ōŕ ŕũń ţĥē ēńàƀĺēď ćĺēàńũƥ ŕũĺēś ŵĥēń ŷōũ ŵàńţ ţō ŕēćōvēŕ śƥàćē ĩḿḿēďĩàţēĺŷ.", "storageActionsTitle": "Ŕēćĺàĩḿ ďĩśķ śƥàćē śàƒēĺŷ", + "storageAdminOnly": "Ōńĺŷ àďḿĩńĩśţŕàţōŕś ćàń ćĥàńĝē śţōŕàĝē śēţţĩńĝś ōŕ ŕũń ḿàĩńţēńàńćē.", "storageAdoptCache": "Àďōƥţ ćàćĥē", "storageAdoptDialogDescription": "Ķàńďēv ŵĩĺĺ ƀē àĺĺōŵēď ţō ŕōţàţē àńď qũàŕàńţĩńē ţĥē ēxĩśţĩńĝ ćàćĥē àţ {{path}}. Ţĥĩś ƥàţĥ ḿũśţ ƀē àƀśōĺũţē àńď ōń ţĥē śàḿē ƒĩĺēśŷśţēḿ àś Ķàńďēv ţŕàśĥ.", "storageAdoptDialogFallbackPath": "ţĥē śēĺēćţēď ƥàţĥ", diff --git a/apps/web/src/locales/pt-pt/system.json b/apps/web/src/locales/pt-pt/system.json index 88014c8f8f..34e018b251 100644 --- a/apps/web/src/locales/pt-pt/system.json +++ b/apps/web/src/locales/pt-pt/system.json @@ -13,6 +13,7 @@ "actionDone": "Concluído", "actionFailed": "Falhou", "actionRunning": "Em execução...", + "backupsAdminOnly": "Só os administradores podem criar, transferir, restaurar ou eliminar cópias de segurança.", "backupsColumnActions": "Ações", "backupsColumnCreated": "Criada", "backupsColumnKind": "Tipo", @@ -258,6 +259,7 @@ "storageActionPending": "Aguarde que a ação de armazenamento atual termine.", "storageActionsDescription": "Analise para obter um retrato só de leitura ou execute as regras de limpeza ativadas quando quiser recuperar espaço de imediato.", "storageActionsTitle": "Recuperar espaço em disco com segurança", + "storageAdminOnly": "Só os administradores podem alterar as definições de armazenamento ou executar a manutenção.", "storageAdoptCache": "Adotar a cache", "storageAdoptDialogDescription": "O Kandev passa a poder rodar e colocar em quarentena a cache existente em {{path}}. Este caminho tem de ser absoluto e estar no mesmo sistema de ficheiros da reciclagem do Kandev.", "storageAdoptDialogFallbackPath": "o caminho selecionado", diff --git a/apps/web/src/locales/zh-cn/system.json b/apps/web/src/locales/zh-cn/system.json index 32245881c8..36618c5d53 100644 --- a/apps/web/src/locales/zh-cn/system.json +++ b/apps/web/src/locales/zh-cn/system.json @@ -13,6 +13,7 @@ "actionDone": "完成", "actionFailed": "失败", "actionRunning": "运行中…", + "backupsAdminOnly": "只有管理员可以创建、下载、恢复或删除备份。", "backupsColumnActions": "操作", "backupsColumnCreated": "创建时间", "backupsColumnKind": "类型", @@ -260,6 +261,7 @@ "storageActionPending": "请等待当前存储操作完成。", "storageActionsDescription": "分析可得到只读快照;若要立即回收空间,可运行已启用的清理规则。", "storageActionsTitle": "安全回收磁盘空间", + "storageAdminOnly": "只有管理员可以更改存储设置或运行维护。", "storageAdoptCache": "接管缓存", "storageAdoptDialogDescription": "将允许 Kandev 轮换并隔离位于 {{path}} 的现有缓存。路径必须为绝对路径,且与 Kandev 回收站位于同一文件系统。", "storageAdoptDialogFallbackPath": "所选路径", diff --git a/apps/web/src/locales/zh-hk/system.json b/apps/web/src/locales/zh-hk/system.json index 4502608fc9..e0aab1fbdd 100644 --- a/apps/web/src/locales/zh-hk/system.json +++ b/apps/web/src/locales/zh-hk/system.json @@ -13,6 +13,7 @@ "actionDone": "完成", "actionFailed": "失敗", "actionRunning": "執行中…", + "backupsAdminOnly": "只有管理員可以建立、下載、還原或刪除備份。", "backupsColumnActions": "操作", "backupsColumnCreated": "建立時間", "backupsColumnKind": "類型", @@ -260,6 +261,7 @@ "storageActionPending": "請等待目前存儲操作完成。", "storageActionsDescription": "分析可得到只讀快照;若要立即回收空間,可執行已啓用的清理規則。", "storageActionsTitle": "安全回收磁碟空間", + "storageAdminOnly": "只有管理員可以更改存儲設定或執行維護。", "storageAdoptCache": "接管快取", "storageAdoptDialogDescription": "將允許 Kandev 輪換並隔離位於 {{path}} 的現有快取。路徑必須為絕對路徑,且與 Kandev 回收站位於同一檔案系統。", "storageAdoptDialogFallbackPath": "所選路徑", diff --git a/apps/web/src/locales/zh-tw/system.json b/apps/web/src/locales/zh-tw/system.json index 0a6b65966d..9019e354b8 100644 --- a/apps/web/src/locales/zh-tw/system.json +++ b/apps/web/src/locales/zh-tw/system.json @@ -13,6 +13,7 @@ "actionDone": "完成", "actionFailed": "失敗", "actionRunning": "執行中…", + "backupsAdminOnly": "只有管理員可以建立、下載、還原或刪除備份。", "backupsColumnActions": "操作", "backupsColumnCreated": "建立時間", "backupsColumnKind": "型別", @@ -260,6 +261,7 @@ "storageActionPending": "請等待目前儲存操作完成。", "storageActionsDescription": "分析可得到只讀快照;若要立即回收空間,可執行已啟用的清理規則。", "storageActionsTitle": "安全回收磁碟空間", + "storageAdminOnly": "只有管理員可以更改儲存設定或執行維護。", "storageAdoptCache": "接管快取", "storageAdoptDialogDescription": "將允許 Kandev 輪換並隔離位於 {{path}} 的現有快取。路徑必須為絕對路徑,且與 Kandev 回收站位於同一檔案系統。", "storageAdoptDialogFallbackPath": "所選路徑", diff --git a/docs/public/authentication.md b/docs/public/authentication.md index 1e41a1089a..27107fa71a 100644 --- a/docs/public/authentication.md +++ b/docs/public/authentication.md @@ -51,6 +51,15 @@ A server that listens on non-loopback interfaces with authentication disabled lo Roles: `admin` (user management, authentication settings, destructive system operations, feature toggles) and `member` (everything else, scoped to their own workspaces). +Install-wide data in `Settings > System > Data & storage` follows the same +split. Members can read the database stats, the backup listing, and the storage +usage, policy, run history, and quarantine contents. Creating, downloading, +restoring, and deleting a backup is admin only, because a backup is a copy of +the whole database and downloading one would export every user's workspaces. +Changing storage settings, adopting a Go cache, and running an analysis, +cleanup, or quarantine restore/purge are admin only for the same reason +database vacuum, optimize, and reset are: they act on the whole install. + ## Personal access tokens `Settings > Account > API Tokens`. Tokens look like `kandev_pat_…`, are shown **once** at creation, and are sent as a bearer header: