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.
This commit is contained in:
sysops
2026-07-11 23:20:52 +02:00
parent a946c059c3
commit 5b72a7c118
5 changed files with 75 additions and 1 deletions
+20
View File
@@ -419,6 +419,26 @@ func ListServers(db store.IStore) echo.HandlerFunc {
false, fmt.Sprintf("Cannot get server list: %v", err), 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) return c.JSON(http.StatusOK, servers)
} }
} }
+29
View File
@@ -8,6 +8,7 @@ import (
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session" "github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/ngoduykhanh/wireguard-ui/store"
"github.com/ngoduykhanh/wireguard-ui/util" "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 { func isValidSession(c echo.Context) bool {
if util.DisableLogin { if util.DisableLogin {
return true return true
+1 -1
View File
@@ -252,7 +252,7 @@ func main() {
app.POST(util.BasePath+"/global-settings", handler.GlobalSettingSubmit(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin) 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+"/status", handler.Status(db), handler.ValidSession, handler.RefreshSession)
app.GET(util.BasePath+"/servers", handler.ListServers(db), handler.ValidSession) 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/clients", handler.GetClients(db), handler.ValidSession)
app.GET(util.BasePath+"/api/client/:id", handler.GetClient(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) app.GET(util.BasePath+"/api/machine-ips", handler.MachineIPAddresses(), handler.ValidSession)
+4
View File
@@ -7,4 +7,8 @@ type User struct {
// PasswordHash takes precedence over Password. // PasswordHash takes precedence over Password.
PasswordHash string `json:"password_hash"` PasswordHash string `json:"password_hash"`
Admin bool `json:"admin"` 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"`
} }
+21
View File
@@ -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 // NOTE: the legacy "server" directory is intentionally left in place
// (not renamed/removed) at this stage. Existing store methods (GetServer, // (not renamed/removed) at this stage. Existing store methods (GetServer,
// GetGlobalSettings, SaveServerInterface, ...) still read/write it // GetGlobalSettings, SaveServerInterface, ...) still read/write it