mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 21:47:20 +00:00
5fb27d4039
The organization-owner fix judged browser sessions only. API tokens on instance routes were still checked for scope alone, and the tenant middleware resolves an org-bound token to its own organization without the caller naming one. A token bound to a tenant organization and holding settings:write therefore reached system settings, SSO providers, token management, relay, log level, updates and the other instance surfaces. ensureAdminSession, behind RequireAdmin, and ensureSettingsScope now refuse a token unless it acts for the instance: not bound to any organization other than the default one, with OrgIDs taking precedence over OrgID. The route's scope still applies. The org variants (RequireOrgAdmin, ensureOrgAdminSession, ensureOrgSettingsWriteScope) keep judging tokens by scope alone. tokenHasInstanceWideAuthority is now the same binding rule plus full-access scope. The test notification broadcasts only to the request organization, so it moves to RequireOrgAdmin. Its tenant delivery test already relied on an org-bound token sending it, and that organization's managers may now send it from a session too. No path an organization manager can reach mints a settings scope: relay mobile, install, deploy and action-runner tokens carry relay, agent and report scopes only, and token management is instance administration. The API reference and the api-contracts, security-privacy, agent-lifecycle and storage-recovery contracts record the token rule.
208 lines
8.7 KiB
Go
208 lines
8.7 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
|
)
|
|
|
|
// Instance administration is the same boundary for API tokens as for
|
|
// sessions. A token bound to a tenant organization acts for that organization,
|
|
// so on instance routes (RequireAdmin, ensureAdminSession and the settings
|
|
// scope helpers) it is refused whatever scopes it holds, the same as that
|
|
// organization's owner. Routes that serve only the request organization
|
|
// (RequireOrgAdmin, ensureOrgAdminSession, ensureOrgSettingsWriteScope) keep
|
|
// judging tokens by scope alone. Before this, an org-bound token holding
|
|
// settings:write reached system settings, SSO providers, token management,
|
|
// updates, relay, log level and the other instance surfaces.
|
|
|
|
type orgBoundTokenBinding struct {
|
|
name string
|
|
orgID string
|
|
orgIDs []string
|
|
instance bool // holds instance-wide authority
|
|
}
|
|
|
|
var orgBoundTokenBindings = []orgBoundTokenBinding{
|
|
{name: "unbound legacy token", instance: true},
|
|
{name: "bound to the default org", orgID: "default", instance: true},
|
|
{name: "OrgIDs naming only the default org", orgIDs: []string{"default"}, instance: true},
|
|
{name: "bound to a tenant org", orgID: "org-a", instance: false},
|
|
{name: "OrgIDs naming a tenant org", orgIDs: []string{"org-a"}, instance: false},
|
|
{name: "OrgIDs naming the default and a tenant org", orgIDs: []string{"default", "org-a"}, instance: false},
|
|
{name: "OrgIDs taking precedence over a default OrgID", orgID: "default", orgIDs: []string{"org-a"}, instance: false},
|
|
}
|
|
|
|
func orgBoundTokenFixture(t *testing.T, binding orgBoundTokenBinding, scopes []string) (*config.Config, string) {
|
|
t.Helper()
|
|
raw := "org-bound-authority-token-" + strings.ReplaceAll(binding.name, " ", "-") + ".12345678"
|
|
record := newTokenRecord(t, raw, scopes, nil)
|
|
record.OrgID = binding.orgID
|
|
record.OrgIDs = append([]string(nil), binding.orgIDs...)
|
|
return newTestConfigWithTokens(t, record), raw
|
|
}
|
|
|
|
func orgBoundTokenRequest(t *testing.T, cfg *config.Config, method, target, raw string) *http.Request {
|
|
t.Helper()
|
|
req := newLoopbackRequest(method, target, nil)
|
|
record, ok := cfg.ValidateAPIToken(raw)
|
|
if !ok || record == nil {
|
|
t.Fatalf("fixture token did not validate")
|
|
}
|
|
attachAPITokenRecord(req, record)
|
|
return req
|
|
}
|
|
|
|
func TestInstanceSettingsScopeRequiresInstanceWideToken(t *testing.T) {
|
|
for _, binding := range orgBoundTokenBindings {
|
|
for _, scopes := range [][]string{
|
|
{config.ScopeSettingsRead, config.ScopeSettingsWrite},
|
|
{config.ScopeWildcard},
|
|
{config.ScopeMonitoringRead},
|
|
} {
|
|
hasSettings := scopes[0] != config.ScopeMonitoringRead
|
|
t.Run(binding.name+"/"+strings.Join(scopes, ","), func(t *testing.T) {
|
|
cfg, raw := orgBoundTokenFixture(t, binding, scopes)
|
|
checks := []struct {
|
|
name string
|
|
fn func(*config.Config, http.ResponseWriter, *http.Request) bool
|
|
want bool
|
|
}{
|
|
{"ensureSettingsReadScope", ensureSettingsReadScope, binding.instance && hasSettings},
|
|
{"ensureSettingsWriteScope", ensureSettingsWriteScope, binding.instance && hasSettings},
|
|
{"ensureOrgSettingsWriteScope", ensureOrgSettingsWriteScope, hasSettings},
|
|
{"ensureAdminSession", ensureAdminSession, binding.instance},
|
|
{"ensureOrgAdminSession", ensureOrgAdminSession, true},
|
|
}
|
|
for _, check := range checks {
|
|
rec := httptest.NewRecorder()
|
|
got := check.fn(cfg, rec, orgBoundTokenRequest(t, cfg, http.MethodPost, "/api/x", raw))
|
|
if got != check.want {
|
|
t.Fatalf("%s = %v, want %v", check.name, got, check.want)
|
|
}
|
|
if !got && rec.Code != http.StatusForbidden {
|
|
t.Fatalf("%s refused with %d, want 403", check.name, rec.Code)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRequireAdminRequiresInstanceWideTokenAndRequireOrgAdminDoesNot(t *testing.T) {
|
|
for _, binding := range orgBoundTokenBindings {
|
|
t.Run(binding.name, func(t *testing.T) {
|
|
cfg, raw := orgBoundTokenFixture(t, binding, []string{config.ScopeSettingsWrite})
|
|
reached := func(wrap func(*config.Config, http.HandlerFunc) http.HandlerFunc) (bool, int) {
|
|
hit := false
|
|
rec := httptest.NewRecorder()
|
|
req := newLoopbackRequest(http.MethodPost, "/api/x", nil)
|
|
req.Header.Set("X-API-Token", raw)
|
|
wrap(cfg, RequireScope(config.ScopeSettingsWrite, func(http.ResponseWriter, *http.Request) { hit = true }))(rec, req)
|
|
return hit, rec.Code
|
|
}
|
|
if hit, code := reached(RequireAdmin); hit != binding.instance {
|
|
t.Fatalf("RequireAdmin reached handler = %v (status %d), want %v", hit, code, binding.instance)
|
|
} else if !hit && code != http.StatusForbidden {
|
|
t.Fatalf("RequireAdmin refused with %d, want 403", code)
|
|
}
|
|
if hit, code := reached(RequireOrgAdmin); !hit {
|
|
t.Fatalf("RequireOrgAdmin refused a settings:write token with %d", code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The full router: an org-bound token resolves to its own organization
|
|
// without naming one, passes the tenant middleware, and must still be refused
|
|
// by every instance route while reaching the org-bound routes its scope covers.
|
|
func TestOrgBoundSettingsTokenRouterMatrix(t *testing.T) {
|
|
defer SetMultiTenantEnabled(false)
|
|
SetMultiTenantEnabled(true)
|
|
t.Setenv("PULSE_DEV", "true")
|
|
|
|
scopes := []string{config.ScopeSettingsRead, config.ScopeSettingsWrite}
|
|
instanceRaw := "instance-settings-token.12345678"
|
|
orgRaw := "org-a-settings-token.12345678"
|
|
orgReadOnlyRaw := "org-a-monitoring-token.12345678"
|
|
instance := newTokenRecord(t, instanceRaw, scopes, nil)
|
|
orgBound := newTokenRecord(t, orgRaw, scopes, nil)
|
|
orgBound.OrgID = "org-a"
|
|
orgReadOnly := newTokenRecord(t, orgReadOnlyRaw, []string{config.ScopeMonitoringRead}, nil)
|
|
orgReadOnly.OrgID = "org-a"
|
|
cfg := newTestConfigWithTokens(t, instance, orgBound, orgReadOnly)
|
|
mtp := config.NewMultiTenantPersistence(cfg.DataPath)
|
|
if err := mtp.SaveOrganization(&models.Organization{ID: "org-a", DisplayName: "Org A", OwnerUserID: "owner"}); err != nil {
|
|
t.Fatalf("save organization: %v", err)
|
|
}
|
|
mtm := monitoring.NewMultiTenantMonitor(cfg, mtp, nil)
|
|
t.Cleanup(mtm.Stop)
|
|
router := NewRouter(cfg, nil, mtm, nil, nil, "1.0.0")
|
|
cleanupTestRouter(t, router)
|
|
|
|
serve := func(method, path, raw string) *httptest.ResponseRecorder {
|
|
body := ""
|
|
switch {
|
|
case method == http.MethodGet:
|
|
case path == "/api/security/tokens":
|
|
// Without scopes the new token would ask for full access, which a
|
|
// settings token may not grant.
|
|
body = `{"name":"matrix","scopes":["settings:read"]}`
|
|
default:
|
|
body = "{}"
|
|
}
|
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-API-Token", raw)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
instanceRoutes := []struct{ method, path string }{
|
|
{http.MethodPost, "/api/system/settings/update"},
|
|
{http.MethodGet, "/api/system/settings"},
|
|
{http.MethodPost, "/api/security/sso/providers"},
|
|
{http.MethodGet, "/api/security/sso/providers"},
|
|
{http.MethodGet, "/api/security/tokens"},
|
|
{http.MethodPost, "/api/security/tokens"},
|
|
{http.MethodPost, "/api/security/reset-lockout"},
|
|
{http.MethodGet, "/api/logs/level"},
|
|
{http.MethodPost, "/api/logs/level"},
|
|
{http.MethodGet, "/api/updates/status"},
|
|
{http.MethodGet, "/api/settings/relay"},
|
|
}
|
|
for _, route := range instanceRoutes {
|
|
t.Run("instance "+route.method+" "+route.path, func(t *testing.T) {
|
|
if rec := serve(route.method, route.path, orgRaw); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("org-bound settings token answered %d, want 403: %s", rec.Code, strings.TrimSpace(rec.Body.String()))
|
|
}
|
|
if rec := serve(route.method, route.path, instanceRaw); rec.Code == http.StatusForbidden || rec.Code == http.StatusUnauthorized {
|
|
t.Fatalf("instance-wide settings token answered %d: %s", rec.Code, strings.TrimSpace(rec.Body.String()))
|
|
}
|
|
})
|
|
}
|
|
|
|
orgRoutes := []struct{ method, path string }{
|
|
// The handler answers 405 to anything but POST, so reaching it proves
|
|
// ensureOrgSettingsWriteScope admitted the token without minting one.
|
|
{http.MethodGet, "/api/security/tokens/relay-mobile"},
|
|
{http.MethodGet, "/api/agents/diagnostics"},
|
|
}
|
|
for _, route := range orgRoutes {
|
|
t.Run("org "+route.method+" "+route.path, func(t *testing.T) {
|
|
if rec := serve(route.method, route.path, orgRaw); rec.Code == http.StatusForbidden || rec.Code == http.StatusUnauthorized {
|
|
t.Fatalf("org-bound settings token answered %d on an org route: %s", rec.Code, strings.TrimSpace(rec.Body.String()))
|
|
}
|
|
if rec := serve(route.method, route.path, orgReadOnlyRaw); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("org-bound token without settings scope answered %d, want 403", rec.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|