From 5b72a7c118992c598864cb414bad1901bbdbb2d8 Mon Sep 17 00:00:00 2001 From: sysops Date: Sat, 11 Jul 2026 23:20:52 +0200 Subject: [PATCH] Add per-server access control (User.ServerIDs) Non-admin users are now restricted to servers explicitly listed in their new ServerIDs field; empty means no access (secure by default). Admins always have full access. Migration backfills existing users' ServerIDs with the migrated legacy server so nobody is locked out on upgrade. New RequireServerAccess middleware enforces this on /servers/:id/... routes (applied to GET /servers/:id/clients so far); GET /servers also filters its list for non-admins. --- handler/routes.go | 20 ++++++++++++++++++++ handler/session.go | 29 +++++++++++++++++++++++++++++ main.go | 2 +- model/user.go | 4 ++++ store/jsondb/jsondb.go | 21 +++++++++++++++++++++ 5 files changed, 75 insertions(+), 1 deletion(-) diff --git a/handler/routes.go b/handler/routes.go index 76b35e4..c03ab7f 100644 --- a/handler/routes.go +++ b/handler/routes.go @@ -419,6 +419,26 @@ func ListServers(db store.IStore) echo.HandlerFunc { false, fmt.Sprintf("Cannot get server list: %v", err), }) } + + // non-admins only see servers they have been explicitly granted + if !util.DisableLogin && !isAdmin(c) { + user, err := db.GetUserByName(currentUser(c)) + if err != nil { + return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Access denied"}) + } + allowed := make(map[string]bool, len(user.ServerIDs)) + for _, id := range user.ServerIDs { + allowed[id] = true + } + var filtered []model.Server + for _, s := range servers { + if allowed[s.ID] { + filtered = append(filtered, s) + } + } + servers = filtered + } + return c.JSON(http.StatusOK, servers) } } diff --git a/handler/session.go b/handler/session.go index b660d9c..7c25e45 100644 --- a/handler/session.go +++ b/handler/session.go @@ -8,6 +8,7 @@ import ( "github.com/gorilla/sessions" "github.com/labstack/echo-contrib/session" "github.com/labstack/echo/v4" + "github.com/ngoduykhanh/wireguard-ui/store" "github.com/ngoduykhanh/wireguard-ui/util" ) @@ -43,6 +44,34 @@ func NeedsAdmin(next echo.HandlerFunc) echo.HandlerFunc { } } +// RequireServerAccess must only be used after ValidSession middleware. +// It restricts /servers/:id/... routes to admins (always allowed) and to +// non-admin users whose User.ServerIDs contains the requested server ID. +// A user with an empty ServerIDs list has no server access at all - the +// migration backfills existing users so nobody is locked out by this +// change on upgrade. +func RequireServerAccess(db store.IStore) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if util.DisableLogin || isAdmin(c) { + return next(c) + } + + serverID := c.Param("id") + user, err := db.GetUserByName(currentUser(c)) + if err != nil { + return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Access denied"}) + } + for _, id := range user.ServerIDs { + if id == serverID { + return next(c) + } + } + return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "You do not have access to this server"}) + } + } +} + func isValidSession(c echo.Context) bool { if util.DisableLogin { return true diff --git a/main.go b/main.go index c776b95..2bf23c7 100644 --- a/main.go +++ b/main.go @@ -252,7 +252,7 @@ func main() { app.POST(util.BasePath+"/global-settings", handler.GlobalSettingSubmit(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin) app.GET(util.BasePath+"/status", handler.Status(db), handler.ValidSession, handler.RefreshSession) app.GET(util.BasePath+"/servers", handler.ListServers(db), handler.ValidSession) - app.GET(util.BasePath+"/servers/:id/clients", handler.GetServerClients(db), handler.ValidSession) + app.GET(util.BasePath+"/servers/:id/clients", handler.GetServerClients(db), handler.ValidSession, handler.RequireServerAccess(db)) app.GET(util.BasePath+"/api/clients", handler.GetClients(db), handler.ValidSession) app.GET(util.BasePath+"/api/client/:id", handler.GetClient(db), handler.ValidSession) app.GET(util.BasePath+"/api/machine-ips", handler.MachineIPAddresses(), handler.ValidSession) diff --git a/model/user.go b/model/user.go index 71f4d13..27ec11e 100644 --- a/model/user.go +++ b/model/user.go @@ -7,4 +7,8 @@ type User struct { // PasswordHash takes precedence over Password. PasswordHash string `json:"password_hash"` Admin bool `json:"admin"` + // ServerIDs restricts a non-admin user to only these servers. + // Empty/nil means no server access at all (secure by default). + // Admins always have access to every server regardless of this field. + ServerIDs []string `json:"server_ids,omitempty"` } diff --git a/store/jsondb/jsondb.go b/store/jsondb/jsondb.go index 12b7330..e755e82 100644 --- a/store/jsondb/jsondb.go +++ b/store/jsondb/jsondb.go @@ -303,6 +303,27 @@ func (o *JsonDB) migrateLegacyServer() error { } } + // backfill ServerIDs on every existing user so nobody is locked out of + // the migrated server by the new per-server access control (users + // created after migration default to no access, per model.User). + userRecords, err := o.conn.ReadAll("users") + if err != nil && err != scribble.ErrMissingCollection { + return fmt.Errorf("migration: cannot read users: %v", err) + } + for _, rec := range userRecords { + var user model.User + if err := json.Unmarshal(rec, &user); err != nil { + return fmt.Errorf("migration: cannot decode user json: %v", err) + } + if len(user.ServerIDs) > 0 { + continue + } + user.ServerIDs = []string{serverID} + if err := o.conn.Write("users", user.Username, user); err != nil { + return fmt.Errorf("migration: cannot backfill server_ids on user %s: %v", user.Username, err) + } + } + // NOTE: the legacy "server" directory is intentionally left in place // (not renamed/removed) at this stage. Existing store methods (GetServer, // GetGlobalSettings, SaveServerInterface, ...) still read/write it