Files
pulse/internal/api/org_admin_session_authority_test.go
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

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)
}
})
}
}