mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 21:47:20 +00:00
dc2057a08c
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.
146 lines
5.6 KiB
Go
146 lines
5.6 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/api/apicontext"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
)
|
|
|
|
// ensureAdminSession guards instance-wide state: credentials, API tokens, SSO,
|
|
// system settings, updates and the rest of instance administration. An
|
|
// organization's owner or admin manages that organization, not the instance,
|
|
// so only routes that opt in with ensureOrgAdminSession / RequireOrgAdmin admit
|
|
// them. Before this split every admin route admitted an org manager who named
|
|
// their organization in the request, which let them rotate the instance
|
|
// administrator's API token and receive its secret.
|
|
|
|
const (
|
|
orgAuthorityAdmin = "instance-admin"
|
|
orgAuthorityOwner = "tenant-owner"
|
|
orgAuthorityAdmin2 = "tenant-admin"
|
|
orgAuthorityMember = "tenant-member"
|
|
orgAuthorityRBAC = "sso:oidc:corp:rbac-admin"
|
|
)
|
|
|
|
func orgAuthorityConfig(t *testing.T) *config.Config {
|
|
t.Helper()
|
|
InitSessionStore(t.TempDir())
|
|
manager := installTestRBACManager(t)
|
|
if err := manager.UpdateUserRoles(orgAuthorityRBAC, []string{internalauth.RoleAdmin}); err != nil {
|
|
t.Fatalf("assign admin role: %v", err)
|
|
}
|
|
return &config.Config{AuthUser: orgAuthorityAdmin}
|
|
}
|
|
|
|
func orgAuthorityRequest(t *testing.T, method, target, username, orgID string) *http.Request {
|
|
t.Helper()
|
|
token := generateSessionToken()
|
|
GetSessionStore().CreateSession(token, time.Hour, "agent", "127.0.0.1", username)
|
|
req := newLoopbackRequest(method, target, nil)
|
|
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: token})
|
|
if orgID != "" {
|
|
org := &models.Organization{
|
|
ID: orgID,
|
|
DisplayName: orgID,
|
|
OwnerUserID: orgAuthorityOwner,
|
|
Members: []models.OrganizationMember{
|
|
{UserID: orgAuthorityAdmin2, Role: models.OrgRoleAdmin},
|
|
{UserID: orgAuthorityMember, Role: models.OrgRoleViewer},
|
|
},
|
|
}
|
|
req = req.WithContext(context.WithValue(req.Context(), apicontext.OrgContextKey, org))
|
|
}
|
|
return req
|
|
}
|
|
|
|
type orgAuthorityCase struct {
|
|
name string
|
|
user string
|
|
org string
|
|
instance bool // admitted by ensureAdminSession / RequireAdmin
|
|
orgBound bool // admitted by ensureOrgAdminSession / RequireOrgAdmin
|
|
}
|
|
|
|
var orgAuthorityCases = []orgAuthorityCase{
|
|
{"configured admin, instance context", orgAuthorityAdmin, "", true, true},
|
|
{"RBAC admin, instance context", orgAuthorityRBAC, "", true, true},
|
|
{"configured admin inside a tenant org", orgAuthorityAdmin, "org-a", true, true},
|
|
{"RBAC admin inside a tenant org", orgAuthorityRBAC, "org-a", true, true},
|
|
{"org owner inside their org", orgAuthorityOwner, "org-a", false, true},
|
|
{"org admin inside their org", orgAuthorityAdmin2, "org-a", false, true},
|
|
{"org member inside their org", orgAuthorityMember, "org-a", false, false},
|
|
{"org owner without an org", orgAuthorityOwner, "", false, false},
|
|
{"unknown user without an org", "mallory", "", false, false},
|
|
}
|
|
|
|
func TestAdminSessionChecksSplitInstanceAndOrgAuthority(t *testing.T) {
|
|
cfg := orgAuthorityConfig(t)
|
|
for _, tc := range orgAuthorityCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
if got := ensureAdminSession(cfg, rec, orgAuthorityRequest(t, http.MethodGet, "/api/x", tc.user, tc.org)); got != tc.instance {
|
|
t.Fatalf("ensureAdminSession = %v, want %v", got, tc.instance)
|
|
}
|
|
if !tc.instance && rec.Code != http.StatusForbidden {
|
|
t.Fatalf("refused instance check answered %d, want 403", rec.Code)
|
|
}
|
|
rec = httptest.NewRecorder()
|
|
if got := ensureOrgAdminSession(cfg, rec, orgAuthorityRequest(t, http.MethodGet, "/api/x", tc.user, tc.org)); got != tc.orgBound {
|
|
t.Fatalf("ensureOrgAdminSession = %v, want %v", got, tc.orgBound)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRequireAdminAndRequireOrgAdminFollowTheSessionSplit(t *testing.T) {
|
|
cfg := orgAuthorityConfig(t)
|
|
reached := func(wrap func(*config.Config, http.HandlerFunc) http.HandlerFunc, req *http.Request) (bool, int) {
|
|
hit := false
|
|
rec := httptest.NewRecorder()
|
|
wrap(cfg, func(w http.ResponseWriter, r *http.Request) { hit = true })(rec, req)
|
|
return hit, rec.Code
|
|
}
|
|
for _, tc := range orgAuthorityCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if hit, code := reached(RequireAdmin, orgAuthorityRequest(t, http.MethodGet, "/api/x", tc.user, tc.org)); hit != tc.instance {
|
|
t.Fatalf("RequireAdmin reached handler = %v (status %d), want %v", hit, code, tc.instance)
|
|
}
|
|
if hit, code := reached(RequireOrgAdmin, orgAuthorityRequest(t, http.MethodGet, "/api/x", tc.user, tc.org)); hit != tc.orgBound {
|
|
t.Fatalf("RequireOrgAdmin reached handler = %v (status %d), want %v", hit, code, tc.orgBound)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Token management, SSO, system settings and the other instance surfaces
|
|
// reach their handlers through the settings scope helpers. An org owner's
|
|
// session must not pass them, whatever organization it names.
|
|
func TestSettingsScopeRefusesOrgOwnerSession(t *testing.T) {
|
|
cfg := orgAuthorityConfig(t)
|
|
for _, check := range []struct {
|
|
name string
|
|
fn func(*config.Config, http.ResponseWriter, *http.Request) bool
|
|
}{
|
|
{"settings read", ensureSettingsReadScope},
|
|
{"settings write", ensureSettingsWriteScope},
|
|
} {
|
|
t.Run(check.name, func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
req := orgAuthorityRequest(t, http.MethodPost, "/api/security/tokens/some-id/rotate", orgAuthorityOwner, "org-a")
|
|
if check.fn(cfg, rec, req) {
|
|
t.Fatal("an org owner's session passed the instance settings scope check")
|
|
}
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403", rec.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|