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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user