Merge pull request #2208 from rcourtman/claude/msp-org-owner-settings

Offer org owners the settings their own org's routes serve
This commit is contained in:
rcourtman
2026-09-23 22:48:19 +01:00
committed by GitHub
7 changed files with 227 additions and 41 deletions
@@ -3410,6 +3410,18 @@ Agent` secondary handoff against the live setup wizard instead of relying
## Current State
### Org managers reach the agent install path in their own organization
An owner or admin of the selected organization now receives
`settingsCapabilities.infrastructureRead` from `/api/security/status`, matching
the `ensureAdminSession` rule that already let them through the Infrastructure
routes. A provider opening a client workspace from the Pulse Account portal
therefore lands on Settings → Infrastructure with the host install flow open,
instead of a navigation that hid the page and fell through to General. The
install tokens it generates stay bound to that organization; nothing about token
scope, agent registration or reporting identity changes. See the api-contracts
contract, rule 35, for the capability rule.
### Auto-register preserves the operator's stored TLS choice
Re-registering an existing Proxmox node through the canonical auto-register
@@ -4533,6 +4533,25 @@ auto-register mutation boundary.
`TestConfigTransferSSOViewerDeniedBeforeBodyRead` and
`TestActionRoutesRequireExplicitSSOAdminOnSSOOnlyInstance`.
Org-bound settings are the one place an org-scoped session is not
withheld. `ensureAdminSession` admits the configured administrator or a
manager (owner or admin) of the request's organization inside an
org-scoped request, and that rule is `sessionUserHasSettingsAdminPrivileges`
for both the route gate and the snapshot. The snapshot records it as
`sessionManagesOrg`, never as `sessionIsAdmin`, and it widens only
`infrastructureRead`, `availabilityRead` and `reportingRead`, whose routes
pass `ensureAdminSession` and whose handlers read that organization's own
config and monitor state; `reportingRead` still requires the authorizer's
`read` on `nodes`. Every other capability, `detailLevel` and `authUsername`
stay instance administration. Before this, a provider opening a client
workspace from the Pulse Account portal arrived as the org owner with every
capability `false`, so Settings hid the Infrastructure and Reporting pages
that the routes would have served and the handoff to
`/settings/infrastructure` fell through to General. Regression coverage:
`TestSecurityStatusOrgBoundSettingsFollowOrgManagementRule` and
`TestSecurityStatusOrgBoundReportingRespectsAuthorizer` in
`internal/api/contract_test.go`.
36. Configured SSO admission restrictions fail closed on missing identity
claims. OIDC and SAML providers with `allowedEmails` or `allowedDomains`
must reject an assertion that omits the email claim; absence is not a way
@@ -304,7 +304,7 @@ administrator or settings-scope checks, expose queue contents to new callers, or
change tenant selection. Missing owners fail with 503 rather than dereferencing
nil.
The authenticated security-status currentUsername is the validated caller principal, not an administrator configuration disclosure. Public responses omit it. An organisation-scoped local session may receive its own identity while authUsername and instance-settings capabilities remain withheld; clients must not interpret identity availability as privilege.
The authenticated security-status currentUsername is the validated caller principal, not an administrator configuration disclosure. Public responses omit it. An organisation-scoped local session may receive its own identity while authUsername and instance-settings capabilities remain withheld; clients must not interpret identity availability as privilege. A manager of that organisation additionally receives the org-bound infrastructureRead, availabilityRead and reportingRead capabilities, because their routes already admit org managers through ensureAdminSession and read only that organisation's state; this never extends to authentication, SSO, users, roles, audit, relay, system, diagnostics or billing surfaces.
API token scope copy must match runtime authority. `ai:chat` covers Assistant
conversation, model selection, sessions, and knowledge reads only. Knowledge
@@ -2618,6 +2618,17 @@ vdev layout is reported` in
## Current State
### Org managers see org-bound settings, not recovery administration
`/api/security/status` now grants an owner or admin of the selected
organization the org-bound `infrastructureRead`, `availabilityRead` and
`reportingRead` capabilities, because those routes already admit org managers
and read only that organization's state. `systemSettingsRead`, which gates
System → Recovery with backup polling and configuration export/import, stays
withheld from every org-scoped session, so an org manager is not offered
instance recovery or configuration transfer. See the api-contracts contract,
rule 35.
### TLS verification preference survives node re-registration
A PVE or PBS node whose operator disabled certificate verification keeps that
+110
View File
@@ -24809,6 +24809,116 @@ func TestSecurityStatusCurrentUserForScopedLocalSession(t *testing.T) {
}
}
func orgScopedSessionRequest(t *testing.T, user string, org *models.Organization) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil)
req.AddCookie(platformAdminSession(t, user))
return req.WithContext(context.WithValue(req.Context(), OrgContextKey, org))
}
// A provider opening a client workspace lands in that client's organization as
// its owner. ensureAdminSession has always let an org manager through the
// org-bound settings routes, but the security status snapshot reported every
// settings capability as false for any org-scoped session, so the settings
// navigation hid Infrastructure and Reporting and the owner had no way to
// connect a single system. Those org-bound capabilities must follow the route
// rule, while instance administration stays closed to org-scoped sessions.
func TestSecurityStatusOrgBoundSettingsFollowOrgManagementRule(t *testing.T) {
prev := authpkg.GetAuthorizer()
authpkg.SetAuthorizer(&authpkg.DefaultAuthorizer{})
defer authpkg.SetAuthorizer(prev)
cfg := platformAdminConfig(t, "admin")
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
org := &models.Organization{
ID: "t-client01",
DisplayName: "Client Workspace",
OwnerUserID: "u_provider_owner",
Members: []models.OrganizationMember{
{UserID: "u_provider_owner", Role: models.OrgRoleOwner},
{UserID: "u_org_admin", Role: models.OrgRoleAdmin},
{UserID: "u_org_viewer", Role: models.OrgRoleViewer},
},
}
for _, tc := range []struct {
name string
user string
manager bool
}{
{name: "owner", user: "u_provider_owner", manager: true},
{name: "org admin", user: "u_org_admin", manager: true},
{name: "viewer", user: "u_org_viewer", manager: false},
{name: "outsider", user: "u_outsider", manager: false},
} {
t.Run(tc.name, func(t *testing.T) {
if got := ensureAdminSession(router.config, httptest.NewRecorder(), orgScopedSessionRequest(t, tc.user, org)); got != tc.manager {
t.Fatalf("precondition: ensureAdminSession = %v, want %v", got, tc.manager)
}
caps := router.securityStatusSettingsCapabilitiesFromSnapshot(
router.buildSecurityStatusAuthSnapshot(orgScopedSessionRequest(t, tc.user, org)))
orgBound := map[string]bool{
"infrastructureRead": caps.InfrastructureRead,
"availabilityRead": caps.AvailabilityRead,
"reportingRead": caps.ReportingRead,
}
for name, got := range orgBound {
if got != tc.manager {
t.Errorf("%s = %v for an org-scoped %s; its route answers %v", name, got, tc.name, tc.manager)
}
}
instanceWide := map[string]bool{
"systemSettingsRead": caps.SystemSettingsRead,
"diagnosticsRead": caps.DiagnosticsRead,
"systemLogsRead": caps.SystemLogsRead,
"authenticationRead": caps.AuthenticationRead,
"authenticationWrite": caps.AuthenticationWrite,
"singleSignOnWrite": caps.SingleSignOnWrite,
"apiAccessWrite": caps.APIAccessWrite,
"users": caps.Users,
"roles": caps.Roles,
"relayWrite": caps.RelayWrite,
"billingAdmin": caps.BillingAdmin,
}
for name, got := range instanceWide {
if got {
t.Errorf("%s advertised to an org-scoped %s; instance administration stays closed to org-scoped sessions", name, tc.name)
}
}
})
}
}
// With a real RBAC authorizer, reporting also needs read:nodes. An org owner
// the authorizer refuses must not be offered the reporting surface, because
// RequirePermission refuses the route before ensureAdminSession runs.
func TestSecurityStatusOrgBoundReportingRespectsAuthorizer(t *testing.T) {
cfg := platformAdminConfig(t, "admin")
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
router.authorizer = orgBoundDenyAllAuthorizer{}
org := &models.Organization{ID: "t-client03", DisplayName: "Client", OwnerUserID: "u_owner"}
caps := router.securityStatusSettingsCapabilitiesFromSnapshot(
router.buildSecurityStatusAuthSnapshot(orgScopedSessionRequest(t, "u_owner", org)))
if caps.ReportingRead {
t.Fatal("reportingRead advertised although the authorizer refuses read:nodes")
}
if !caps.InfrastructureRead {
t.Fatal("infrastructureRead must still follow ensureAdminSession for the org owner")
}
}
type orgBoundDenyAllAuthorizer struct{}
func (orgBoundDenyAllAuthorizer) Authorize(context.Context, string, string) (bool, error) {
return false, nil
}
// Embedding the interface makes any unexpected mutation fail rather than
// supplying no-op write methods to this read-only API contract fixture.
type diagnosticContractManager struct{ alerting.NotificationManager }
+42 -31
View File
@@ -138,44 +138,55 @@ func ensureAdminSession(cfg *config.Config, w http.ResponseWriter, req *http.Req
// Session users must match configured admin identity for privileged operations.
if cookie, err := readSessionCookie(req); err == nil && cookie.Value != "" && ValidateSession(cookie.Value) {
sessionUser := strings.TrimSpace(GetSessionUsername(cookie.Value))
configuredAdmin := ""
if cfg != nil {
configuredAdmin = strings.TrimSpace(cfg.AuthUser)
}
if configuredAdmin != "" && strings.EqualFold(sessionUser, configuredAdmin) {
if sessionUserHasSettingsAdminPrivileges(cfg, req, sessionUser) {
return true
}
// Org-scoped tenant sessions preserve canonical org management
// privileges for settings-bound routes.
orgScoped := false
if org := GetOrganization(req.Context()); org != nil {
orgID := strings.TrimSpace(org.ID)
if orgID != "" && orgID != "default" {
orgScoped = true
if org.CanUserIDManage(sessionUser) {
return true
}
}
}
if !orgScoped && sessionUserCarriesAdminPrivileges(cfg, sessionUser) {
return true
}
if configuredAdmin == "" || !strings.EqualFold(sessionUser, configuredAdmin) {
log.Warn().
Str("path", req.URL.Path).
Str("user", sessionUser).
Msg("Session user missing admin privileges for privileged operation")
http.Error(w, "Admin privileges required", http.StatusForbidden)
return false
}
log.Warn().
Str("path", req.URL.Path).
Str("user", sessionUser).
Msg("Session user missing admin privileges for privileged operation")
http.Error(w, "Admin privileges required", http.StatusForbidden)
return false
}
return true
}
// sessionUserHasSettingsAdminPrivileges is the session rule ensureAdminSession
// enforces for settings-bound routes: the configured local admin; on an
// org-scoped request, a user who can manage that organization; otherwise an
// instance administrator. The security status snapshot publishes settings
// capabilities from this same rule, so a surface the UI offers is one its
// routes accept.
//
// It is deliberately wider than the platform admin rule. An org owner
// administers their own organization's settings but is never an instance or
// platform administrator; RequirePlatformAdmin and the billingAdmin capability
// keep excluding org-scoped sessions.
func sessionUserHasSettingsAdminPrivileges(cfg *config.Config, req *http.Request, sessionUser string) bool {
sessionUser = strings.TrimSpace(sessionUser)
if sessionUser == "" {
return false
}
configuredAdmin := ""
if cfg != nil {
configuredAdmin = strings.TrimSpace(cfg.AuthUser)
}
if configuredAdmin != "" && strings.EqualFold(sessionUser, configuredAdmin) {
return true
}
// Org-scoped tenant sessions preserve canonical org management
// privileges for settings-bound routes.
if sessionIsOrgScoped(req) {
org := GetOrganization(req.Context())
return org != nil && org.CanUserIDManage(sessionUser)
}
return sessionUserCarriesAdminPrivileges(cfg, sessionUser)
}
// sessionIsOrgScoped reports whether the request is bound to a tenant
// organization rather than the instance itself. Instance-admin rules must not
// be applied to an org-scoped session: those callers are governed by their
+32 -9
View File
@@ -78,7 +78,13 @@ type securityStatusAuthSnapshot struct {
username string
proxyIsAdmin bool
sessionIsAdmin bool
tokenRecord *config.APITokenRecord
// sessionManagesOrg is set for an org-scoped session that
// ensureAdminSession admits: an owner or admin of the request's
// organization, or the configured admin. It widens only the org-bound
// settings surfaces, never sessionIsAdmin, so instance administration and
// the privileged status fields stay closed to org-scoped sessions.
sessionManagesOrg bool
tokenRecord *config.APITokenRecord
}
func (s securityStatusAuthSnapshot) tokenScopes() []string {
@@ -189,15 +195,19 @@ func (r *Router) buildSecurityStatusAuthSnapshot(req *http.Request) securityStat
// identity or an explicit RBAC admin grant (including SSO group role
// mappings). Org-scoped sessions keep their own management rules.
sessionIsAdmin := false
if !sessionIsOrgScoped(req) {
sessionManagesOrg := false
if sessionIsOrgScoped(req) {
sessionManagesOrg = sessionUserHasSettingsAdminPrivileges(r.config, req, username)
} else {
sessionIsAdmin = sessionUserCarriesAdminPrivileges(r.config, username)
}
return securityStatusAuthSnapshot{
request: snapshotReq,
authenticated: true,
authMethod: "session",
username: username,
sessionIsAdmin: sessionIsAdmin,
request: snapshotReq,
authenticated: true,
authMethod: "session",
username: username,
sessionIsAdmin: sessionIsAdmin,
sessionManagesOrg: sessionManagesOrg,
}
}
@@ -275,9 +285,22 @@ func (r *Router) securityStatusSettingsCapabilitiesFromSnapshot(snapshot securit
canReadReporting := canReadSettings &&
r.canAccessPermissionSurface(snapshot, internalauth.ActionRead, internalauth.ResourceNodes, config.ScopeSettingsRead)
// Infrastructure, availability, and reporting are org-bound: their routes
// pass ensureAdminSession, which admits an org manager inside their own
// organization, and their handlers read that organization's config and
// monitor state. A provider opening a client workspace lands there as the
// org owner, and without these the settings navigation hides the only
// pages that connect the client's systems and build its reports. Every
// other capability stays instance administration.
canReadOrgSettings := canReadSettings || snapshot.sessionManagesOrg
if snapshot.sessionManagesOrg && !canReadReporting {
allowed, err := r.authorizer.Authorize(snapshot.request.Context(), internalauth.ActionRead, internalauth.ResourceNodes)
canReadReporting = err == nil && allowed
}
return securityStatusSettingsCapabilities{
InfrastructureRead: canReadSettings,
AvailabilityRead: canReadSettings,
InfrastructureRead: canReadOrgSettings,
AvailabilityRead: canReadOrgSettings,
PulseIntelligenceRead: canReadPulseIntelligence,
DiagnosticsRead: canReadSettings,
SystemLogsRead: canReadSettings,