mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 21:20:00 +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.
815 lines
26 KiB
Go
815 lines
26 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/bootstrap"
|
|
"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"
|
|
)
|
|
|
|
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
|
|
var matched *http.Cookie
|
|
for _, cookie := range cookies {
|
|
if cookie.Name == name {
|
|
matched = cookie
|
|
}
|
|
}
|
|
return matched
|
|
}
|
|
|
|
func TestQuickSecuritySetupRejectsUnauthenticatedForce(t *testing.T) {
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
|
resetTrustedProxyConfig()
|
|
|
|
hashed, err := internalauth.HashPassword("OldPassword!1")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
AuthUser: "admin",
|
|
AuthPass: hashed,
|
|
DataPath: t.TempDir(),
|
|
ConfigPath: t.TempDir(),
|
|
}
|
|
|
|
router := &Router{config: cfg}
|
|
handler := handleQuickSecuritySetupFixed(router)
|
|
|
|
// Ensure rate limiter does not block test runs
|
|
authLimiter.Reset("198.51.100.42")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup",
|
|
strings.NewReader(`{"username":"newadmin","password":"NewPassword!1","apiToken":"abcd","force":true}`))
|
|
req.RemoteAddr = "198.51.100.42:54321"
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler(rr, req)
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 unauthorized, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestQuickSecuritySetupRequiresBootstrapToken(t *testing.T) {
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
|
resetTrustedProxyConfig()
|
|
|
|
dataDir := t.TempDir()
|
|
|
|
cfg := &config.Config{
|
|
DataPath: dataDir,
|
|
ConfigPath: dataDir,
|
|
}
|
|
|
|
router := &Router{
|
|
config: cfg,
|
|
persistence: config.NewConfigPersistence(cfg.DataPath),
|
|
}
|
|
router.initializeBootstrapToken()
|
|
InitPersistentAuthStores(dataDir)
|
|
|
|
tokenPath := filepath.Join(cfg.DataPath, bootstrapTokenFilename)
|
|
bootstrapToken, _, _, err := bootstrap.Load(cfg.DataPath)
|
|
if err != nil {
|
|
t.Fatalf("load bootstrap token: %v", err)
|
|
}
|
|
|
|
handler := handleQuickSecuritySetupFixed(router)
|
|
|
|
authLimiter.Reset("127.0.0.1")
|
|
|
|
payload := `{"username":"bootstrap","password":"StrongPass!1","apiToken":"` + strings.Repeat("aa", 32) + `"}`
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(payload))
|
|
req.RemoteAddr = "127.0.0.1:54321"
|
|
rr := httptest.NewRecorder()
|
|
handler(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 unauthorized without bootstrap token, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// Remote IP without a bootstrap token is rejected — loopback is the only
|
|
// no-token fallback.
|
|
authLimiter.Reset("198.51.100.80")
|
|
|
|
reqRemoteNoToken := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(payload))
|
|
reqRemoteNoToken.RemoteAddr = "198.51.100.80:54321"
|
|
rrRemoteNoToken := httptest.NewRecorder()
|
|
handler(rrRemoteNoToken, reqRemoteNoToken)
|
|
if rrRemoteNoToken.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 forbidden for remote quick setup without bootstrap token, got %d (%s)", rrRemoteNoToken.Code, rrRemoteNoToken.Body.String())
|
|
}
|
|
|
|
authLimiter.Reset("127.0.0.1")
|
|
|
|
reqLoopback := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(payload))
|
|
reqLoopback.RemoteAddr = "127.0.0.1:54321"
|
|
reqLoopback.Header.Set(bootstrapTokenHeader, bootstrapToken)
|
|
|
|
rrLoopback := httptest.NewRecorder()
|
|
handler(rrLoopback, reqLoopback)
|
|
if rrLoopback.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 OK with valid bootstrap token from loopback, got %d (%s)", rrLoopback.Code, rrLoopback.Body.String())
|
|
}
|
|
|
|
cookies := rrLoopback.Result().Cookies()
|
|
sessionCookie := findCookie(cookies, sessionCookieName(false))
|
|
if sessionCookie == nil || strings.TrimSpace(sessionCookie.Value) == "" {
|
|
t.Fatalf("expected quick setup to establish a browser session cookie")
|
|
}
|
|
if !ValidateSession(sessionCookie.Value) {
|
|
t.Fatalf("expected established session to validate")
|
|
}
|
|
if got := GetSessionUsername(sessionCookie.Value); got != "bootstrap" {
|
|
t.Fatalf("session username=%q, want %q", got, "bootstrap")
|
|
}
|
|
|
|
csrfCookie := findCookie(cookies, CookieNameCSRF)
|
|
if csrfCookie == nil || strings.TrimSpace(csrfCookie.Value) == "" {
|
|
t.Fatalf("expected quick setup to issue a CSRF cookie")
|
|
}
|
|
|
|
if _, err := os.Stat(tokenPath); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("expected bootstrap token file to be removed after successful setup, got err=%v", err)
|
|
}
|
|
if router.bootstrapTokenHash != "" {
|
|
t.Fatalf("expected bootstrap token hash to be cleared after successful setup")
|
|
}
|
|
|
|
authEnv, err := os.ReadFile(resolveAuthEnvPath(cfg.ConfigPath))
|
|
if err != nil {
|
|
t.Fatalf("read persisted authentication configuration: %v", err)
|
|
}
|
|
authEnvText := string(authEnv)
|
|
if !strings.Contains(authEnvText, "PULSE_AUTH_USER='bootstrap'\n") {
|
|
t.Fatalf("persisted authentication configuration missing username:\n%s", authEnvText)
|
|
}
|
|
if !strings.Contains(authEnvText, "PULSE_AUTH_PASS='$2") {
|
|
t.Fatalf("persisted authentication configuration missing password hash:\n%s", authEnvText)
|
|
}
|
|
}
|
|
|
|
func TestQuickSecuritySetupRejectsUnsafeUsernamesBeforeStateChanges(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
username string
|
|
}{
|
|
{name: "systemd directive injection", username: "admin\n[Service]\nExecStartPre=/bin/false"},
|
|
{name: "environment quote injection", username: "admin'\nPULSE_AUTH_PASS='attacker"},
|
|
{name: "leading whitespace", username: " admin"},
|
|
{name: "unsupported punctuation", username: "admin;restart"},
|
|
{name: "too long", username: strings.Repeat("a", maxLocalAuthUsernameRunes+1)},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
|
resetTrustedProxyConfig()
|
|
|
|
dataDir := t.TempDir()
|
|
cfg := &config.Config{DataPath: dataDir, ConfigPath: dataDir}
|
|
router := &Router{
|
|
config: cfg,
|
|
persistence: config.NewConfigPersistence(dataDir),
|
|
}
|
|
router.initializeBootstrapToken()
|
|
InitPersistentAuthStores(dataDir)
|
|
bootstrapToken, _, _, err := bootstrap.Load(dataDir)
|
|
if err != nil {
|
|
t.Fatalf("load bootstrap token: %v", err)
|
|
}
|
|
|
|
body, err := json.Marshal(map[string]string{
|
|
"username": tt.username,
|
|
"password": "StrongPass!1",
|
|
"apiToken": strings.Repeat("ab", 24),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal request: %v", err)
|
|
}
|
|
|
|
authLimiter.Reset("198.51.100.91")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(string(body)))
|
|
req.RemoteAddr = "198.51.100.91:54321"
|
|
req.Header.Set(bootstrapTokenHeader, bootstrapToken)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handleQuickSecuritySetupFixed(router)(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400 (%s)", rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(strings.ToLower(rec.Body.String()), "username") {
|
|
t.Fatalf("response = %q, want username validation error", rec.Body.String())
|
|
}
|
|
if cfg.AuthUser != "" || cfg.AuthPass != "" || cfg.HasAPITokens() {
|
|
t.Fatalf("unsafe username changed runtime auth state: user=%q pass_set=%v tokens=%d", cfg.AuthUser, cfg.AuthPass != "", len(cfg.APITokens))
|
|
}
|
|
if _, err := os.Stat(resolveAuthEnvPath(dataDir)); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("unsafe username created auth env file: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthConfigurationRenderersRejectStructuralInjection(t *testing.T) {
|
|
if _, err := quoteAuthEnvValue("admin'\nPULSE_AUTH_PASS='attacker"); err == nil {
|
|
t.Fatal("quoteAuthEnvValue accepted structural injection")
|
|
}
|
|
if _, err := quoteSystemdEnvironment("PULSE_AUTH_USER", "admin\nExecStartPre=/bin/false"); err == nil {
|
|
t.Fatal("quoteSystemdEnvironment accepted structural injection")
|
|
}
|
|
|
|
generatedAt := time.Date(2026, time.July, 10, 0, 0, 0, 0, time.UTC)
|
|
content, err := renderSystemdAuthOverride(generatedAt, "admin@example.com", "$2a$10$abcdefghijklmnopqrstuvwxyzABCDE1234567890123456789012")
|
|
if err != nil {
|
|
t.Fatalf("renderSystemdAuthOverride: %v", err)
|
|
}
|
|
if strings.Count(string(content), "\nEnvironment=") != 3 {
|
|
t.Fatalf("rendered override does not contain exactly three environment directives:\n%s", content)
|
|
}
|
|
}
|
|
|
|
func TestValidateBootstrapTokenEndpoint(t *testing.T) {
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
|
resetTrustedProxyConfig()
|
|
|
|
dataDir := t.TempDir()
|
|
cfg := &config.Config{
|
|
DataPath: dataDir,
|
|
ConfigPath: dataDir,
|
|
}
|
|
|
|
router := &Router{config: cfg}
|
|
router.initializeBootstrapToken()
|
|
|
|
tokenPath := filepath.Join(cfg.DataPath, bootstrapTokenFilename)
|
|
token, _, _, err := bootstrap.Load(cfg.DataPath)
|
|
if err != nil {
|
|
t.Fatalf("load bootstrap token: %v", err)
|
|
}
|
|
|
|
handler := http.HandlerFunc(router.handleValidateBootstrapToken)
|
|
|
|
// GET not allowed
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/security/validate-bootstrap-token", nil)
|
|
handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("expected 405 for GET, got %d", rr.Code)
|
|
}
|
|
|
|
// Missing token payload
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader("{}"))
|
|
handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for missing token, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// Invalid token
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{"token":"deadbeef"}`))
|
|
handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for invalid token, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// Valid token
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{"token":"`+token+`"}`))
|
|
handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204 for valid token, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// Bootstrap token should remain on disk after validation
|
|
if _, err := os.Stat(tokenPath); err != nil {
|
|
t.Fatalf("bootstrap token should remain after validation, got err=%v", err)
|
|
}
|
|
|
|
// Once token removed, endpoint should report conflict
|
|
router.clearBootstrapToken()
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{"token":"`+token+`"}`))
|
|
handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusConflict {
|
|
t.Fatalf("expected 409 when bootstrap token unavailable, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestValidateBootstrapTokenEndpoint_RateLimited(t *testing.T) {
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
|
resetTrustedProxyConfig()
|
|
|
|
dataDir := t.TempDir()
|
|
cfg := &config.Config{
|
|
DataPath: dataDir,
|
|
ConfigPath: dataDir,
|
|
}
|
|
|
|
router := &Router{
|
|
config: cfg,
|
|
bootstrapTokenValidationLimiter: NewRateLimiter(1, time.Hour),
|
|
}
|
|
t.Cleanup(router.bootstrapTokenValidationLimiter.Stop)
|
|
router.initializeBootstrapToken()
|
|
|
|
handler := http.HandlerFunc(router.handleValidateBootstrapToken)
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{"token":"deadbeef"}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for first invalid token, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{"token":"deadbeef"}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("expected 429 after exhausting bootstrap token validation limit, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
if rr.Header().Get("Retry-After") == "" {
|
|
t.Fatal("expected Retry-After header on bootstrap token validation rate limit")
|
|
}
|
|
}
|
|
|
|
func TestQuickSecuritySetupAllowsRecoveryTokenRotation(t *testing.T) {
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
|
resetTrustedProxyConfig()
|
|
resetRecoveryStore()
|
|
|
|
dataDir := t.TempDir()
|
|
|
|
hashed, err := internalauth.HashPassword("OldPassword!1")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
AuthUser: "admin",
|
|
AuthPass: hashed,
|
|
DataPath: dataDir,
|
|
ConfigPath: dataDir,
|
|
}
|
|
|
|
persistence := config.NewConfigPersistence(cfg.DataPath)
|
|
|
|
router := &Router{
|
|
config: cfg,
|
|
persistence: persistence,
|
|
}
|
|
|
|
InitRecoveryTokenStore(cfg.DataPath)
|
|
token, err := GetRecoveryTokenStore().GenerateRecoveryToken(5*time.Minute, "127.0.0.1")
|
|
if err != nil {
|
|
t.Fatalf("generate recovery token: %v", err)
|
|
}
|
|
|
|
authLimiter.Reset("127.0.0.1")
|
|
|
|
body := `{"username":"rotated","password":"RotatedPassword!1","apiToken":"deadbeef","force":true}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(body))
|
|
req.Header.Set("X-Recovery-Token", token)
|
|
req.RemoteAddr = "127.0.0.1:54321"
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler := handleQuickSecuritySetupFixed(router)
|
|
handler(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 OK, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
if cfg.AuthUser != "rotated" {
|
|
t.Fatalf("expected AuthUser to be rotated, got %q", cfg.AuthUser)
|
|
}
|
|
if !internalauth.CheckPasswordHash("RotatedPassword!1", cfg.AuthPass) {
|
|
t.Fatalf("stored password hash does not match new password")
|
|
}
|
|
if len(cfg.APITokens) != 1 {
|
|
t.Fatalf("expected one API token, got %d", len(cfg.APITokens))
|
|
}
|
|
}
|
|
|
|
func TestQuickSecuritySetupRequiresSettingsScopeForTokens(t *testing.T) {
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
|
resetTrustedProxyConfig()
|
|
|
|
dataDir := t.TempDir()
|
|
hashed, err := internalauth.HashPassword("ExistingPassword!1")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
|
|
rawToken := strings.Repeat("ab", 32) // 64 hex chars
|
|
record, err := config.NewAPITokenRecord(rawToken, "limited", []string{config.ScopeAgentReport})
|
|
if err != nil {
|
|
t.Fatalf("new token record: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
AuthUser: "admin",
|
|
AuthPass: hashed,
|
|
DataPath: dataDir,
|
|
ConfigPath: dataDir,
|
|
APITokens: []config.APITokenRecord{*record},
|
|
}
|
|
cfg.SortAPITokens()
|
|
|
|
router := &Router{
|
|
config: cfg,
|
|
persistence: config.NewConfigPersistence(cfg.DataPath),
|
|
}
|
|
|
|
authLimiter.Reset("203.0.113.10")
|
|
|
|
body := `{"username":"rotated","password":"RotatedPassword!1","apiToken":"deadbeef","force":true}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(body))
|
|
req.RemoteAddr = "203.0.113.10:1234"
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler := handleQuickSecuritySetupFixed(router)
|
|
handler(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 forbidden for missing scope, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRegenerateAPITokenRequiresSettingsScope(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
hashed, err := internalauth.HashPassword("ExistingPassword!1")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
|
|
rawToken := strings.Repeat("cd", 32)
|
|
record, err := config.NewAPITokenRecord(rawToken, "limited", []string{config.ScopeAgentReport})
|
|
if err != nil {
|
|
t.Fatalf("new token record: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
AuthUser: "admin",
|
|
AuthPass: hashed,
|
|
DataPath: dataDir,
|
|
ConfigPath: dataDir,
|
|
APITokens: []config.APITokenRecord{*record},
|
|
}
|
|
cfg.SortAPITokens()
|
|
|
|
router := &Router{
|
|
config: cfg,
|
|
persistence: config.NewConfigPersistence(cfg.DataPath),
|
|
}
|
|
|
|
authLimiter.Reset("198.51.100.7")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/regenerate-token", nil)
|
|
req.RemoteAddr = "198.51.100.7:44321"
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
|
|
rr := httptest.NewRecorder()
|
|
router.HandleRegenerateAPIToken(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 forbidden for missing scope, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestValidateAPITokenRequiresSettingsScope(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
hashed, err := internalauth.HashPassword("ExistingPassword!1")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
|
|
rawToken := strings.Repeat("ef", 32)
|
|
record, err := config.NewAPITokenRecord(rawToken, "limited", []string{config.ScopeAgentReport})
|
|
if err != nil {
|
|
t.Fatalf("new token record: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
AuthUser: "admin",
|
|
AuthPass: hashed,
|
|
DataPath: dataDir,
|
|
ConfigPath: dataDir,
|
|
APITokens: []config.APITokenRecord{*record},
|
|
}
|
|
cfg.SortAPITokens()
|
|
|
|
router := &Router{
|
|
config: cfg,
|
|
persistence: config.NewConfigPersistence(cfg.DataPath),
|
|
}
|
|
|
|
authLimiter.Reset("198.51.100.8")
|
|
body := `{"token":"abcdef"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/validate-token", strings.NewReader(body))
|
|
req.RemoteAddr = "198.51.100.8:54321"
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
|
|
rr := httptest.NewRecorder()
|
|
router.HandleValidateAPIToken(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 forbidden for missing scope, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestResetLockoutRequiresSettingsScope(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
hashed, err := internalauth.HashPassword("ExistingPassword!1")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
|
|
rawToken := strings.Repeat("12", 32)
|
|
record, err := config.NewAPITokenRecord(rawToken, "limited", []string{config.ScopeAgentReport})
|
|
if err != nil {
|
|
t.Fatalf("new token record: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
AuthUser: "admin",
|
|
AuthPass: hashed,
|
|
DataPath: dataDir,
|
|
ConfigPath: dataDir,
|
|
APITokens: []config.APITokenRecord{*record},
|
|
}
|
|
cfg.SortAPITokens()
|
|
|
|
router := &Router{
|
|
config: cfg,
|
|
}
|
|
|
|
authLimiter.Reset("198.51.100.9")
|
|
body := `{"identifier":"admin"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/reset-lockout", strings.NewReader(body))
|
|
req.RemoteAddr = "198.51.100.9:54321"
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
|
|
rr := httptest.NewRecorder()
|
|
router.handleResetLockout(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 forbidden for missing scope, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestEnsureSettingsWriteScopeWithValidScope(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
|
|
rawToken := strings.Repeat("ab", 32)
|
|
record, err := config.NewAPITokenRecord(rawToken, "admin-token", []string{config.ScopeSettingsWrite})
|
|
if err != nil {
|
|
t.Fatalf("new token record: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
DataPath: dataDir,
|
|
ConfigPath: dataDir,
|
|
APITokens: []config.APITokenRecord{*record},
|
|
}
|
|
cfg.SortAPITokens()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/test", nil)
|
|
attachAPITokenRecord(req, record)
|
|
|
|
rr := httptest.NewRecorder()
|
|
result := ensureSettingsWriteScope(cfg, rr, req)
|
|
|
|
if !result {
|
|
t.Error("ensureSettingsWriteScope should return true when token has settings:write scope")
|
|
}
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("expected no error response, got status %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestEnsureSettingsWriteScopeRejectsNonAdminSession(t *testing.T) {
|
|
cfg := &config.Config{
|
|
AuthUser: "admin",
|
|
}
|
|
|
|
sessionToken := "settings-write-member-" + time.Now().Format("150405.000000000")
|
|
GetSessionStore().CreateSession(sessionToken, time.Hour, "agent", "127.0.0.1", "member")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/test", nil)
|
|
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
|
|
|
|
rr := httptest.NewRecorder()
|
|
result := ensureSettingsWriteScope(cfg, rr, req)
|
|
|
|
if result {
|
|
t.Fatal("ensureSettingsWriteScope should reject non-admin session users")
|
|
}
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin session, got %d", rr.Code)
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "Admin privileges required") {
|
|
t.Fatalf("expected admin privileges error, got %q", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestEnsureSettingsWriteScopeAllowsConfiguredAdminSession(t *testing.T) {
|
|
cfg := &config.Config{
|
|
AuthUser: "admin",
|
|
}
|
|
|
|
sessionToken := "settings-write-admin-" + time.Now().Format("150405.000000001")
|
|
GetSessionStore().CreateSession(sessionToken, time.Hour, "agent", "127.0.0.1", "admin")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/test", nil)
|
|
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
|
|
|
|
rr := httptest.NewRecorder()
|
|
result := ensureSettingsWriteScope(cfg, rr, req)
|
|
|
|
if !result {
|
|
t.Fatal("ensureSettingsWriteScope should allow configured admin session users")
|
|
}
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for admin session, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
// Settings scope guards instance-wide settings, so an organization's owner is
|
|
// refused there; routes that serve only that organization use the org variant.
|
|
func TestSettingsWriteScopeSplitsInstanceAndOrgManagerSession(t *testing.T) {
|
|
cfg := &config.Config{
|
|
AuthUser: "platform-admin",
|
|
}
|
|
|
|
sessionToken := "settings-write-owner-" + time.Now().Format("150405.000000002")
|
|
GetSessionStore().CreateSession(sessionToken, time.Hour, "agent", "127.0.0.1", "legacy-owner")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/test", nil)
|
|
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
|
|
req = req.WithContext(context.WithValue(req.Context(), OrgContextKey, &models.Organization{
|
|
ID: "org-a",
|
|
DisplayName: "Org A",
|
|
OwnerUserID: "operator-owner",
|
|
Members: []models.OrganizationMember{
|
|
{UserID: "legacy-owner", Role: models.OrgRoleOwner},
|
|
{UserID: "operator-owner", Role: models.OrgRoleOwner},
|
|
},
|
|
}))
|
|
|
|
rr := httptest.NewRecorder()
|
|
if ensureSettingsWriteScope(cfg, rr, req) {
|
|
t.Fatal("ensureSettingsWriteScope must refuse an org manager who is not an instance admin")
|
|
}
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for org manager session on instance settings, got %d", rr.Code)
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
if !ensureOrgSettingsWriteScope(cfg, rr, req) {
|
|
t.Fatal("ensureOrgSettingsWriteScope should allow org manager session users")
|
|
}
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for org manager session, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
// A token bound to a tenant organization is held to the same split as that
|
|
// organization's owner: its settings scope reaches the org variant only.
|
|
func TestSettingsWriteScopeSplitsInstanceAndOrgBoundToken(t *testing.T) {
|
|
cfg := &config.Config{AuthUser: "platform-admin"}
|
|
request := func(orgID string, orgIDs ...string) *http.Request {
|
|
record, err := config.NewAPITokenRecord("settings-split-token.12345678", "settings", []string{config.ScopeSettingsWrite})
|
|
if err != nil {
|
|
t.Fatalf("NewAPITokenRecord: %v", err)
|
|
}
|
|
record.OrgID = orgID
|
|
record.OrgIDs = orgIDs
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/test", nil)
|
|
attachAPITokenRecord(req, record)
|
|
return req
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
req *http.Request
|
|
instance bool
|
|
}{
|
|
{"unbound", request(""), true},
|
|
{"default org", request("default"), true},
|
|
{"tenant org", request("org-a"), false},
|
|
{"default and tenant orgs", request("", "default", "org-a"), false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
if got := ensureSettingsWriteScope(cfg, rr, tc.req); got != tc.instance {
|
|
t.Fatalf("ensureSettingsWriteScope = %v, want %v (status %d)", got, tc.instance, rr.Code)
|
|
}
|
|
if !tc.instance && rr.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for org-bound token on instance settings, got %d", rr.Code)
|
|
}
|
|
if !ensureOrgSettingsWriteScope(cfg, httptest.NewRecorder(), tc.req) {
|
|
t.Fatal("ensureOrgSettingsWriteScope should allow a settings:write token")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateBcryptHash(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hash string
|
|
wantErr bool
|
|
errMsg string
|
|
}{
|
|
{
|
|
name: "valid $2a$ hash",
|
|
hash: "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid $2b$ hash",
|
|
hash: "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid $2y$ hash",
|
|
hash: "$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "truncated hash (59 chars)",
|
|
hash: "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhW",
|
|
wantErr: true,
|
|
errMsg: "expected 60 characters, got 59",
|
|
},
|
|
{
|
|
name: "too long hash (61 chars)",
|
|
hash: "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWyX",
|
|
wantErr: true,
|
|
errMsg: "expected 60 characters, got 61",
|
|
},
|
|
{
|
|
name: "empty hash",
|
|
hash: "",
|
|
wantErr: true,
|
|
errMsg: "expected 60 characters, got 0",
|
|
},
|
|
{
|
|
name: "wrong prefix (md5 style, 60 chars)",
|
|
hash: "$1$saltsalt$WabcdEFGhijKLMnopQRstuV0123456789012345123456789",
|
|
wantErr: true,
|
|
errMsg: "must start with $2a$, $2b$, or $2y$",
|
|
},
|
|
{
|
|
name: "wrong prefix ($2x$)",
|
|
hash: "$2x$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
|
|
wantErr: true,
|
|
errMsg: "must start with $2a$, $2b$, or $2y$",
|
|
},
|
|
{
|
|
name: "no prefix at all (60 chars)",
|
|
hash: "N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy1234567",
|
|
wantErr: true,
|
|
errMsg: "must start with $2a$, $2b$, or $2y$",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateBcryptHash(tt.hash)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Errorf("validateBcryptHash() expected error, got nil")
|
|
return
|
|
}
|
|
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
|
|
t.Errorf("validateBcryptHash() error = %q, want substring %q", err.Error(), tt.errMsg)
|
|
}
|
|
} else {
|
|
if err != nil {
|
|
t.Errorf("validateBcryptHash() unexpected error = %v", err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|