Files
pulse/internal/api/instance_credential_session_authority_test.go
rcourtman f8b11b8c9f Require an instance admin to replace instance credentials
POST /api/security/regenerate-token and a forced POST
/api/security/quick-setup replace the instance's own credentials: the
whole API token inventory and, for quick setup, the administrator login.
Their session check was ensureAdminSession, which on an org-scoped
request admits anyone who can manage that organization. An organization
owner who named their organization in the request could therefore mint
a full-access default-organization token or set a new administrator
password, and take the instance over.

Session callers of both routes now also need instance administrator
privileges: a session that is not org-scoped and belongs to the
configured administrator or an RBAC admin grant, the rule
RequirePlatformAdmin and the security status capabilities already use.
Token callers keep the instance-wide token check added earlier, and
first-run setup, the bootstrap token and the recovery token are
unchanged.

Contract-Neutral: Narrows two instance-credential routes to the instance-administrator rule the status capabilities already advertise; no new API surface.
2026-10-17 00:16:58 +01:00

145 lines
5.4 KiB
Go

package api
import (
"context"
"net/http"
"net/http/httptest"
"os"
"strings"
"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"
)
// Regenerating every API token and force-running quick setup replace the
// instance's own credentials. An organization owner manages their own
// organization's settings, but naming that organization in the request must not
// let them rotate the instance administrator login or mint a full-access
// default-organization token.
const (
credentialTestAdmin = "instance-admin"
credentialTestOrgOwner = "tenant-owner"
)
func credentialAuthorityConfig(t *testing.T) *config.Config {
t.Helper()
dir := t.TempDir()
InitSessionStore(dir)
hash, err := internalauth.HashPassword("OriginalPass!1")
if err != nil {
t.Fatalf("hash password: %v", err)
}
existing, err := config.NewAPITokenRecord("existing-admin-token.12345678", "existing", nil)
if err != nil {
t.Fatalf("new token record: %v", err)
}
return &config.Config{
DataPath: dir, ConfigPath: dir,
AuthUser: credentialTestAdmin, AuthPass: hash,
APITokens: []config.APITokenRecord{*existing},
}
}
// sessionCredentialRequest builds a session request; a non-empty orgID binds it
// to that organization, owned by credentialTestOrgOwner, as the tenant
// middleware would for an X-Pulse-Org-ID header.
func sessionCredentialRequest(t *testing.T, target, body, username, orgID string) *http.Request {
t.Helper()
token := generateSessionToken()
GetSessionStore().CreateSession(token, time.Hour, "agent", "127.0.0.1", username)
req := newLoopbackRequest(http.MethodPost, target, strings.NewReader(body))
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: token})
if orgID != "" {
org := &models.Organization{ID: orgID, DisplayName: orgID, OwnerUserID: credentialTestOrgOwner}
req = req.WithContext(context.WithValue(req.Context(), apicontext.OrgContextKey, org))
}
authLimiter.Reset("127.0.0.1")
return req
}
func assertInstanceCredentialsUnchanged(t *testing.T, cfg *config.Config, tokenID string) {
t.Helper()
if cfg.AuthUser != credentialTestAdmin {
t.Fatalf("administrator username changed to %q", cfg.AuthUser)
}
if !internalauth.CheckPasswordHash("OriginalPass!1", cfg.AuthPass) {
t.Fatal("administrator password was replaced")
}
if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != tokenID {
t.Fatalf("token inventory changed: %+v", cfg.APITokens)
}
}
func TestRegenerateAPITokenRefusesOrganizationOwnerSession(t *testing.T) {
cfg := credentialAuthorityConfig(t)
tokenID := cfg.APITokens[0].ID
router := &Router{config: cfg, persistence: config.NewConfigPersistence(cfg.DataPath)}
req := sessionCredentialRequest(t, "/api/security/regenerate-token", "", credentialTestOrgOwner, "org-a")
rec := httptest.NewRecorder()
router.HandleRegenerateAPIToken(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d (%s), want 403", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "pulse_") {
t.Fatalf("refused response carried a token: %s", rec.Body.String())
}
assertInstanceCredentialsUnchanged(t, cfg, tokenID)
}
func TestRegenerateAPITokenAllowsInstanceAdminSession(t *testing.T) {
cfg := credentialAuthorityConfig(t)
router := &Router{config: cfg, persistence: config.NewConfigPersistence(cfg.DataPath)}
req := sessionCredentialRequest(t, "/api/security/regenerate-token", "", credentialTestAdmin, "")
rec := httptest.NewRecorder()
router.HandleRegenerateAPIToken(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d (%s), want 200", rec.Code, rec.Body.String())
}
}
func TestQuickSecuritySetupForceRefusesOrganizationOwnerSession(t *testing.T) {
cfg := credentialAuthorityConfig(t)
tokenID := cfg.APITokens[0].ID
router := &Router{config: cfg, persistence: config.NewConfigPersistence(cfg.DataPath)}
// A strong password: only the authority check stands between this caller
// and the credential write.
body := `{"username":"attacker","password":"Attacker-Pass-2026!x","apiToken":"` + strings.Repeat("ab", 32) + `","force":true}`
req := sessionCredentialRequest(t, "/api/security/quick-setup", body, credentialTestOrgOwner, "org-a")
rec := httptest.NewRecorder()
handleQuickSecuritySetupFixed(router)(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d (%s), want 403", rec.Code, rec.Body.String())
}
assertInstanceCredentialsUnchanged(t, cfg, tokenID)
if _, err := os.Stat(resolveAuthEnvPath(cfg.ConfigPath)); err == nil {
t.Fatal("a refused setup still wrote the authentication file")
}
}
func TestQuickSecuritySetupForceLetsInstanceAdminSessionReachValidation(t *testing.T) {
cfg := credentialAuthorityConfig(t)
router := &Router{config: cfg, persistence: config.NewConfigPersistence(cfg.DataPath)}
// Past the authority check, the weak password is rejected as a bad request
// before anything is written.
body := `{"username":"new-admin","password":"weak","apiToken":"` + strings.Repeat("ab", 32) + `","force":true}`
req := sessionCredentialRequest(t, "/api/security/quick-setup", body, credentialTestAdmin, "")
rec := httptest.NewRecorder()
handleQuickSecuritySetupFixed(router)(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d (%s), want 400 from password validation", rec.Code, rec.Body.String())
}
}