Files
pulse/internal/api/platform_connection_shared.go
T
rcourtman dc2057a08c Keep instance administration away from organization owners
ensureAdminSession, the session check behind RequireAdmin and the
settings scope helpers, admitted anyone who could manage the
organization named in the request. That was meant for the org-bound
settings pages, but it applied to every admin route. On a multi-tenant
install an organization owner could therefore rotate the instance
administrator's API token and receive its secret, widen any token's
scopes, add or replace SSO providers, change system settings, relay
and log level, apply or roll back updates, restart the service, and
read instance logs and diagnostics.

ensureAdminSession now admits only instance administrators: the
configured admin or an instance RBAC admin grant, whichever org the
request names. Routes whose handlers act only on the request
organization opt in explicitly through RequireOrgAdmin,
ensureOrgAdminSession or ensureOrgSettingsWriteScope, which also admit
that organization's owners and admins. Those routes cover agents,
nodes, connections, availability, TrueNAS and VMware, agent profiles,
install commands, deploy status, configuration transfer and the
org-bound relay mobile token. Anything that does not opt in fails
closed.

Reporting stays instance administration, and the security status
snapshot stops offering it to org managers, because report generation
still reads the instance's default metrics store. Notifications,
LAN discovery and the action-capability fallback also stay instance
administration. The two credential routes no longer need their own
check, so ensureInstanceAdminSession is folded into this rule.
2026-10-17 09:11:00 +01:00

130 lines
4.7 KiB
Go

package api
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
// decodeOptionalInstanceRequest decodes an optional JSON body onto a copy of
// base. The results are (instance, decoded, ok): an empty body returns base
// with decoded=false, a malformed body writes a 400 response and returns
// ok=false. Shared by the TrueNAS and VMware connection handlers.
func decodeOptionalInstanceRequest[T any](w http.ResponseWriter, r *http.Request, base T) (T, bool, bool) {
var zero T
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()})
return zero, false, false
}
if len(bytes.TrimSpace(body)) == 0 {
return base, false, true
}
instance := base
if err := json.Unmarshal(body, &instance); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()})
return zero, false, false
}
return instance, true, true
}
// platformConnectionUpdateSpec wires the per-platform pieces of the shared
// connection-update flow. The flow itself — locate by trimmed ID, decode with
// the stored record as fallback, normalize, preserve masked secrets,
// validate, persist, respond redacted — is platform-invariant policy;
// single-sourcing it means a new platform cannot forget masked-secret
// preservation.
type platformConnectionUpdateSpec[T any] struct {
notFoundCode string
saveFailCode string
saveFailMsg string
id func(T) string
decode func(http.ResponseWriter, *http.Request, T) (T, bool)
setID func(*T, string)
normalize func(*T)
preserve func(updated *T, stored T)
validate func(T) error
redacted func(T) any
save func([]T) error
}
// updatePlatformConnection runs the shared platform connection update flow
// over the loaded instances for the resolved connection ID.
func updatePlatformConnection[T any](w http.ResponseWriter, r *http.Request, connectionID string, instances []T, spec platformConnectionUpdateSpec[T]) {
index := -1
for i := range instances {
if strings.TrimSpace(spec.id(instances[i])) == connectionID {
index = i
break
}
}
if index < 0 {
writeErrorResponse(w, http.StatusNotFound, spec.notFoundCode, "Connection not found", nil)
return
}
instance, ok := spec.decode(w, r, instances[index])
if !ok {
return
}
spec.setID(&instance, connectionID)
spec.normalize(&instance)
spec.preserve(&instance, instances[index])
if err := spec.validate(instance); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil)
return
}
instances[index] = instance
if err := spec.save(instances); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, spec.saveFailCode, spec.saveFailMsg, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, spec.redacted(instance))
}
// platformConnectionItemHandlers carries the per-platform handlers for the
// "/api/<platform>/connections/" item routes.
type platformConnectionItemHandlers struct {
test http.HandlerFunc
preview http.HandlerFunc
delete http.HandlerFunc
update http.HandlerFunc
}
// platformConnectionItemRoute builds the shared mux handler for platform
// connection item routes: POST .../test, POST .../preview, DELETE, PUT — all
// org admin + settings:write gated; the handlers act on the request
// organization's own connections. handlers is resolved per request so late
// handler wiring and nil-service checks keep working.
func (r *Router) platformConnectionItemRoute(unavailableCode, unavailableMsg string, handlers func() *platformConnectionItemHandlers) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
h := handlers()
if h == nil {
writeErrorResponse(w, http.StatusServiceUnavailable, unavailableCode, unavailableMsg, nil)
return
}
if req.Method == http.MethodPost && strings.HasSuffix(strings.Trim(req.URL.Path, "/"), "/test") {
RequireOrgAdmin(r.config, RequireScope(config.ScopeSettingsWrite, h.test))(w, req)
} else if req.Method == http.MethodPost && strings.HasSuffix(strings.Trim(req.URL.Path, "/"), "/preview") {
RequireOrgAdmin(r.config, RequireScope(config.ScopeSettingsWrite, h.preview))(w, req)
} else if req.Method == http.MethodDelete {
RequireOrgAdmin(r.config, RequireScope(config.ScopeSettingsWrite, h.delete))(w, req)
} else if req.Method == http.MethodPut {
RequireOrgAdmin(r.config, RequireScope(config.ScopeSettingsWrite, h.update))(w, req)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
}