diff --git a/README.md b/README.md index f63f534..019f78e 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,15 @@ # Phoenix Secrets -Phoenix Secrets is a self-hosted secrets manager purpose-built for AI agent workflows. +Phoenix Secrets is a self-hosted secrets manager built for AI agent workflows. -When a secret enters an LLM's context — through a prompt, tool response, or log — you are trusting the model provider's entire infrastructure with that value: logging, caching, training pipelines, retention policies. You have no visibility into that chain of custody and no way to revoke it after the fact. +Agents are taking on real work — running tools, installing skills from marketplaces, calling APIs with keys that cost real money. But the security story hasn't caught up. Frameworks like OpenClaw give agents serious capabilities while their credentials sit in plaintext environment variables and config files, readable by every skill and tool in the stack. Making agents smarter does not make them security-conscious. That gap is what Phoenix was built to close. -Phoenix keeps secrets out of model context entirely. That is what **context-safe** means: an agent can use a secret to do its job without the raw value ever appearing in prompts, tool responses, logs, or the model's context window. +Phoenix introduces what we call **context-safe** secrets management: an agent can use a secret to do its job without the raw value ever appearing in prompts, tool responses, logs, or the model's context window. When a secret enters an LLM's context — through any of those paths — you are trusting the model provider's entire infrastructure with that value. You have no visibility into that chain of custody and no way to revoke it after the fact. No other secrets tool treats keeping values out of model context as a design goal. -No other secrets tool treats this as a design goal. Traditional managers like Vault and SOPS assume the consumer is a trusted process. Framework secret-reference features (like OpenClaw's SecretRef) solve config hygiene — they keep values out of config files, but not out of model context. Phoenix provides the actual security layer underneath all of them. +Traditional managers like Vault and SOPS assume the consumer is a trusted process. Framework features like OpenClaw's SecretRef solve config hygiene — keeping values out of config files — but not out of model context. Phoenix is the security layer underneath. + +**The OpenClaw problem:** OpenClaw came out of nowhere and changed the way the world thinks about using agents in their daily lives. But it also introduced significant security risks. Skills run with the same privileges as the agent — any skill, including ones downloaded from a marketplace, can read your environment variables and config files. If your API keys are in the environment, every skill you install has access to all of them. OpenClaw's own threat model calls this out as a critical open risk, and there is no sandboxing or credential isolation for skills today. Phoenix was built to close that gap — not just context-safety, but encrypted storage, per-agent access control, audit logging, and credential isolation that OpenClaw doesn't have on its own. See [Integrations](docs/integrations.md) for the full OpenClaw setup. ```bash # Store a secret @@ -38,9 +40,9 @@ phoenix resolve phoenix://myapp/api-key ## Why Phoenix Secrets? -You know your setup is sketchy. Raw API keys in `.env` files, pasted into prompts, passed through scripts, scattered across agent configs. But secret management shouldn't become a full-time job, and enterprise platforms like Vault are overkill for a team of five. +You know your setup is sketchy. Raw API keys in `.env` files, pasted into prompts, passed through scripts, scattered across agent configs. If you're running an agent framework like OpenClaw, every skill you install gets the same access to those credentials that your agent has — and the ecosystem is moving fast enough that nobody has stopped to fix that yet. Secret management shouldn't become a full-time job, but ignoring it isn't an option anymore either. Enterprise platforms like Vault are overkill for a team of five. -Phoenix is built for self-hosted, homelab, small-team, and internal deployments where you want real controls without the overhead. No database, no cloud account, no external service required — and your agent can help you set the whole thing up: +Phoenix is built for the people actually running agents today — self-hosted, homelab, small-team, and internal deployments where you want real controls without the overhead. No database, no cloud account, no external service required — and your agent can help you set the whole thing up: - **Context-safe delivery** — secrets stay out of agent prompts, tool output, and logs - **Encrypted storage** — AES-256-GCM envelope encryption with per-namespace keys @@ -56,20 +58,20 @@ Phoenix is built for self-hosted, homelab, small-team, and internal deployments Two binaries (`phoenix` client + `phoenix-server`), single codebase, no external runtime dependencies. -**Sealed responses** use per-agent key pairs (X25519) so the server encrypts each response to a specific agent. Even on a shared host, one agent cannot read another's secrets. In MCP mode, tool output contains opaque tokens instead of plaintext — the raw value never enters the model's context. See [Sealed Responses](docs/sealed-responses.md). +**Sealed responses** mean one agent cannot read another agent's secrets, even on the same machine. The server encrypts each response to a specific agent's key pair, so intercepting the response is useless without the matching private key. In MCP mode, tool output contains opaque tokens instead of plaintext — the raw value never enters the model's context. Under the hood this is X25519 key agreement with per-response encryption. See [Sealed Responses](docs/sealed-responses.md). Because "just trust the model stack with your secrets" is not a serious security plan. -| | `.env` / framework refs | Phoenix Secrets | -|---|---|---| -| Plaintext out of configs | Yes | Yes | -| Plaintext out of model context | No | Yes — sealed responses + exec isolation | -| Encrypted at rest | No | AES-256-GCM envelope encryption | -| Per-agent access control | No | Glob-pattern ACL per agent | -| Identity attestation | No | mTLS + policy + optional local attestation | -| Runtime audit trail | No | Every access logged with identity | -| Credential stripping | No | `phoenix exec` strips broker creds | -| Key rotation | No | Two-phase commit KEK rotation | +| | `.env` files | OpenClaw SecretRef | Phoenix Secrets | +|---|---|---|---| +| Secrets out of config files | No | Yes | Yes | +| Secrets out of model context | No | No | Yes | +| Encrypted at rest | No | No | Yes (AES-256-GCM) | +| Per-agent access control | No | No | Yes | +| Identity attestation | No | No | Yes (mTLS + policy) | +| Runtime audit trail | No | No | Yes | +| Credential stripping | No | No | Yes | +| Key rotation | No | No | Yes | --- @@ -102,14 +104,17 @@ phoenix-server --config /data/phoenix/config.json ### 4. Store and use a secret ```bash -export PHOENIX_SERVER="https://localhost:9090" +export PHOENIX_SERVER="http://127.0.0.1:9090" export PHOENIX_TOKEN="" -export PHOENIX_CA_CERT="/data/phoenix/ca.crt" phoenix set myapp/api-key -v "sk-live-abc123" -d "API key" phoenix exec --env API_KEY=phoenix://myapp/api-key -- env | grep API_KEY ``` +> The default server config listens on `127.0.0.1:9090` over plain HTTP. +> This is safe for local-only use. For LAN or production deployment, +> enable TLS — see [LAN Deployment](docs/lan-deployment.md). + For the full first-run walkthrough, see [Getting Started](docs/getting-started.md). --- @@ -121,6 +126,8 @@ For the full first-run walkthrough, see [Getting Started](docs/getting-started.m | First install and setup | [Getting Started](docs/getting-started.md) | | CLI commands and workflows | [CLI Usage](docs/cli-usage.md) | | Auth, mTLS, and identity | [Authentication](docs/authentication.md) | +| Session tokens and role-based access | [Session Identity](docs/session-identity.md) | +| Operator dashboard (browser UI) | [Dashboard](docs/dashboard.md) | | Per-path policy and attestation | [Policy and Attestation](docs/policy-and-attestation.md) | | Sealed responses and multi-agent | [Sealed Responses](docs/sealed-responses.md) / [Multi-Agent Setup](docs/multi-agent-setup.md) | | MCP, SDKs, OpenClaw, API | [Integrations](docs/integrations.md) | diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2c17bcd..df9e6dc 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,64 @@ -# Release Notes (Draft) +# Release Notes -## Unreleased +## v0.13.3 (2026-03-20) + +### Session Identity + +Role-based session tokens replace static bearer tokens for agent access. + +- **Named roles** — define namespace scope, allowed actions, bootstrap trust, + and optional step-up approval per role in server config +- **Session tokens** — short-lived (`phxs_` prefix), scoped credentials with + auto-renewal and explicit revocation +- **Bootstrap trust** — roles declare which auth methods (bearer, mTLS, local, + token) can mint sessions +- **Step-up approval** — roles with `step_up: true` require human confirmation + via `phoenix approve` before the session is granted +- **CLI** — `phoenix sessions list|info|revoke` for session management +- **SDK** — `NewWithRole()`, `MintSession()`, `ListSessions()`, `RevokeSession()`, + and error classification helpers (`IsSessionExpired`, `IsSessionRevoked`, + `IsScopeExceeded`, `IsApprovalRequired`, `IsActionDenied`) +- **MCP** — auto-mint via `PHOENIX_ROLE`, background renewal, + `phoenix_session_list` and `phoenix_session_revoke` tools, agent-friendly + denial messages with remediation hints +- **Structured denials** — machine-readable denial codes on all session and + access control failures (SESSION_EXPIRED, SCOPE_EXCEEDED, etc.) +- **Audit** — full session lifecycle audit trail: mint, renew, revoke, auth + failures with session context, step-up approval workflow events +- **Access isolation** — session tokens can only inspect/revoke their own + exact session; no ACL escalation from scoped credentials + +### MCP Streamable HTTP Transport + +The MCP server now supports Streamable HTTP in addition to stdio: + +```bash +phoenix mcp-server --http 127.0.0.1:8080 --mcp-token +``` + +Endpoint: `/mcp`. Auth via `Authorization: Bearer `. Tool identity +headers carry through for policy evaluation. + +### Operator Dashboard + +Lightweight browser-based operator UI at `/dashboard/` — no external +dependencies, all assets embedded via `go:embed`. + +- **Overview** — secret/agent/session counts, server uptime, recent audit +- **Approvals** — pending step-up approvals as cards with full context; + approve/deny with shared safety checks (role, bootstrap, attestation, seal key) +- **Sessions** — active sessions table with filters and one-click revoke +- **Audit** — filterable audit log with auto-refresh +- **Roles** — read-only role inspection with namespace/action/trust pills +- **Auth** — cookie-based with HMAC-signed tokens, bcrypt password or PIN, + CSRF protection on all mutations (including logout), `Secure` cookie + flag auto-detected from TLS +- **Rate limiting** — exponential backoff per source IP on login +- **Audit** — full lifecycle: login success/failure, logout, expired session + rejection, CSRF failures, approve/deny/revoke actions; post-login actions + tagged `dashboard@` for per-operator distinction +- **Mobile** — responsive layout with bottom nav bar on small screens +- **Design** — dark industrial theme, phoenix red-orange accent gradient ### Infrastructure & Publishing - Added GitHub Actions repository secrets required for Docker publishing: diff --git a/cmd/phoenix-server/main.go b/cmd/phoenix-server/main.go index bb2e1d1..3313930 100644 --- a/cmd/phoenix-server/main.go +++ b/cmd/phoenix-server/main.go @@ -4,6 +4,7 @@ // // phoenix-server [--config /data/config.json] // phoenix-server --init /data (first-time setup) +// phoenix-server --reissue-cert --san [--san ...] --config /data/config.json package main import ( @@ -15,23 +16,38 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" + "golang.org/x/crypto/bcrypt" + "github.com/phoenixsec/phoenix/internal/acl" "github.com/phoenixsec/phoenix/internal/agent" "github.com/phoenixsec/phoenix/internal/api" + "github.com/phoenixsec/phoenix/internal/approval" "github.com/phoenixsec/phoenix/internal/audit" "github.com/phoenixsec/phoenix/internal/ca" "github.com/phoenixsec/phoenix/internal/config" "github.com/phoenixsec/phoenix/internal/crypto" + "github.com/phoenixsec/phoenix/internal/dashboard" "github.com/phoenixsec/phoenix/internal/nonce" "github.com/phoenixsec/phoenix/internal/op" "github.com/phoenixsec/phoenix/internal/policy" + "github.com/phoenixsec/phoenix/internal/session" "github.com/phoenixsec/phoenix/internal/store" "github.com/phoenixsec/phoenix/internal/token" "github.com/phoenixsec/phoenix/internal/version" ) +// sanList is a flag.Value that collects multiple --san values. +type sanList []string + +func (s *sanList) String() string { return strings.Join(*s, ",") } +func (s *sanList) Set(v string) error { + *s = append(*s, v) + return nil +} + func main() { configPath := flag.String("config", "", "Path to config file (default: /data/config.json)") initDir := flag.String("init", "", "Initialize a new Phoenix data directory") @@ -39,6 +55,10 @@ func main() { passphraseStdin := flag.Bool("passphrase-stdin", false, "Read master key passphrase from stdin") initPassphrase := flag.String("passphrase", "", "Passphrase to protect master key (--init only)") protectKey := flag.Bool("protect-key", false, "Add, change, or remove passphrase on existing master key") + hashPassword := flag.Bool("hash-password", false, "Generate a bcrypt hash for dashboard password_hash config") + reissueCert := flag.Bool("reissue-cert", false, "Re-issue the server TLS certificate with new SANs") + var sans sanList + flag.Var(&sans, "san", "Subject Alternative Name for --reissue-cert (repeatable: IPs and hostnames)") flag.Parse() if *initPassphrase != "" { @@ -50,6 +70,34 @@ func main() { return } + if *hashPassword { + if err := runHashPassword(); err != nil { + log.Fatalf("hash-password failed: %v", err) + } + return + } + + if *reissueCert { + if len(sans) == 0 { + log.Fatal("--reissue-cert requires at least one --san value") + } + cfgPath := *configPath + if cfgPath == "" { + cfgPath = os.Getenv("PHOENIX_CONFIG") + } + if cfgPath == "" { + cfgPath = "/data/config.json" + } + cfg, err := config.Load(cfgPath) + if err != nil { + log.Fatalf("loading config: %v", err) + } + if err := runReissueCert(cfg, sans); err != nil { + log.Fatalf("reissue-cert failed: %v", err) + } + return + } + if *initDir != "" { if err := runInit(*initDir, *initPassphrase); err != nil { log.Fatalf("init failed: %v", err) @@ -212,6 +260,67 @@ func main() { log.Printf(" Short-lived tokens: disabled") } + // Initialize session identity if enabled + var sessionStore *session.Store + var approvalStore *approval.Store + if cfg.Session.Enabled { + sessionTTL := session.DefaultTTL + if cfg.Session.TTL != "" { + sessionTTL, _ = time.ParseDuration(cfg.Session.TTL) // validated above + } + var err error + sessionStore, err = session.NewStore(sessionTTL) + if err != nil { + log.Fatalf("initializing session store: %v", err) + } + srv.SetSessionStore(sessionStore) + srv.SetSessionRoles(cfg.Session.Roles) + defer sessionStore.Stop() + log.Printf(" Sessions: enabled (roles=%d, ttl=%s)", len(cfg.Session.Roles), sessionTTL) + + // Check if any role requires step-up approval + hasStepUp := false + var stepUpTimeout time.Duration + for _, role := range cfg.Session.Roles { + if role.StepUp { + hasStepUp = true + if role.StepUpTTL != "" { + if d, err := time.ParseDuration(role.StepUpTTL); err == nil && stepUpTimeout == 0 { + stepUpTimeout = d + } + } + } + } + if hasStepUp { + approvalStore = approval.NewStore(stepUpTimeout) + srv.SetApprovalStore(approvalStore) + defer approvalStore.Stop() + log.Printf(" Step-up approvals: enabled (timeout=%s)", stepUpTimeout) + } + } else { + log.Printf(" Sessions: disabled") + } + + // Initialize dashboard if enabled + if cfg.Dashboard.Enabled { + deps := dashboard.Deps{ + Sessions: sessionStore, + SessionRoles: cfg.Session.Roles, + Approvals: approvalStore, + AuditPath: cfg.Audit.Path, + Backend: backend, + ACL: a, + StartTime: time.Now(), + Audit: al, + } + dash, err := dashboard.New(cfg.Dashboard, deps) + if err != nil { + log.Fatalf("initializing dashboard: %v", err) + } + srv.SetDashboard(dash) + log.Printf(" Dashboard: enabled at /dashboard/") + } + // Start local attestation agent if enabled if cfg.Attestation.LocalAgent.Enabled { localAgent := agent.New(cfg.Attestation.LocalAgent.SocketPath) @@ -430,3 +539,56 @@ func runProtectKey(keyPath string) error { return nil } + +// runHashPassword prompts for a password and prints its bcrypt hash. +// The hash can be pasted into config.json as dashboard.password_hash. +func runHashPassword() error { + pass, err := crypto.PromptPassphrase("Enter dashboard password: ") + if err != nil { + return err + } + confirm, err := crypto.PromptPassphrase("Confirm dashboard password: ") + if err != nil { + return err + } + if pass != confirm { + return fmt.Errorf("passwords do not match") + } + hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("generating hash: %w", err) + } + fmt.Println(string(hash)) + return nil +} + +func runReissueCert(cfg *config.Config, sans []string) error { + if cfg.Auth.MTLS.CACert == "" || cfg.Auth.MTLS.CAKey == "" { + return fmt.Errorf("config is missing auth.mtls.ca_cert and auth.mtls.ca_key paths — run --init first") + } + if cfg.Auth.MTLS.ServerCert == "" || cfg.Auth.MTLS.ServerKey == "" { + return fmt.Errorf("config is missing auth.mtls.server_cert and auth.mtls.server_key paths") + } + + // Always include localhost and loopback + allSANs := append([]string{"localhost", "127.0.0.1"}, sans...) + + authority, err := ca.LoadCA(cfg.Auth.MTLS.CACert, cfg.Auth.MTLS.CAKey) + if err != nil { + return fmt.Errorf("loading CA: %w", err) + } + + bundle, err := authority.IssueServerCert(allSANs) + if err != nil { + return fmt.Errorf("issuing server cert: %w", err) + } + + if err := bundle.Save(cfg.Auth.MTLS.ServerCert, cfg.Auth.MTLS.ServerKey, cfg.Auth.MTLS.CACert); err != nil { + return fmt.Errorf("saving server cert: %w", err) + } + + fmt.Printf("Server certificate reissued: %s\n", cfg.Auth.MTLS.ServerCert) + fmt.Printf("SANs: %s\n", strings.Join(allSANs, ", ")) + fmt.Println("Restart the server for the new certificate to take effect.") + return nil +} diff --git a/cmd/phoenix/main.go b/cmd/phoenix/main.go index 77d5105..848d319 100644 --- a/cmd/phoenix/main.go +++ b/cmd/phoenix/main.go @@ -44,6 +44,7 @@ import ( "path/filepath" "sort" "strings" + "sync" "syscall" "time" @@ -57,6 +58,7 @@ import ( var ( serverURL string token string + tokenMu sync.RWMutex // guards token in concurrent MCP mode httpClient *http.Client ) @@ -144,7 +146,7 @@ func main() { err = cmdAudit(args) case "agent": if len(args) < 1 { - fmt.Fprintln(os.Stderr, "usage: phoenix agent ") + fmt.Fprintln(os.Stderr, "usage: phoenix agent ") os.Exit(1) } switch args[0] { @@ -152,6 +154,8 @@ func main() { err = cmdAgentCreate(args[1:]) case "list": err = cmdAgentList() + case "delete": + err = cmdAgentDelete(args[1:]) default: fmt.Fprintf(os.Stderr, "unknown agent subcommand: %s\n", args[0]) os.Exit(1) @@ -232,6 +236,24 @@ func main() { fmt.Fprintf(os.Stderr, "unknown agent-sock subcommand: %s\n", args[0]) os.Exit(1) } + case "sessions": + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: phoenix sessions ") + os.Exit(1) + } + switch args[0] { + case "list": + err = cmdSessionList(args[1:]) + case "info": + err = cmdSessionInfo(args[1:]) + case "revoke": + err = cmdSessionRevoke(args[1:]) + default: + fmt.Fprintf(os.Stderr, "unknown sessions subcommand: %s\n", args[0]) + os.Exit(1) + } + case "approve": + err = cmdApprove(args) case "mcp-server": httpAddr := "" mcpToken := os.Getenv("PHOENIX_MCP_TOKEN") @@ -301,6 +323,7 @@ Usage: phoenix audit [-n N] [-a agent] [-s time] Query audit log phoenix agent create -t --acl [--force] phoenix agent list List agents + phoenix agent delete Delete an agent phoenix resolve [--signed] [ref...] Resolve phoenix:// references to values phoenix exec --env K=phoenix://n/s -- cmd Run command with resolved secrets as env phoenix exec --output-env --env ... Write resolved env to file (no exec) @@ -319,6 +342,10 @@ Usage: phoenix agent-sock resolve Resolve refs using cached socket token phoenix keypair generate [-o dir] Generate X25519 seal key pair phoenix keypair show Show public key for a seal key pair + phoenix sessions list [--role R] [--agent A] List active sessions + phoenix sessions info Show session details + phoenix sessions revoke Revoke a session + phoenix approve Approve a step-up session request phoenix mcp-server Run MCP server (stdio JSON-RPC) phoenix mcp-server --http :8080 Run MCP server (Streamable HTTP) phoenix init Initialize data directory @@ -332,20 +359,36 @@ Environment: PHOENIX_SEAL_KEY Seal private key file (enables sealed mode) PHOENIX_POLICY Path to attestation policy file (JSON) PHOENIX_TOOL Tool/skill name for attestation (X-Phoenix-Tool header) + PHOENIX_ROLE Role name for auto-mint session identity PHOENIX_MCP_TOKEN Bearer token for MCP HTTP client auth (--http mode)`) } // requireAuth checks that at least one auth method is configured // (bearer token or mTLS client cert). func requireAuth() error { + // PHOENIX_ROLE takes precedence: always mint a scoped session token, + // even when PHOENIX_TOKEN is set (the broad token is used only for bootstrap). + if os.Getenv("PHOENIX_ROLE") != "" { + if err := autoMintSession(); err != nil { + return fmt.Errorf("session auto-mint: %w", err) + } + renewSessionIfNeeded() + return nil + } + + // Already have a token (bootstrap bearer or session from env) if token != "" { + if strings.HasPrefix(token, "phxs_") { + renewSessionIfNeeded() + } return nil } + // Check if mTLS client cert is configured if os.Getenv("PHOENIX_CLIENT_CERT") != "" && os.Getenv("PHOENIX_CLIENT_KEY") != "" { return nil } - return fmt.Errorf("no auth configured: set PHOENIX_TOKEN or PHOENIX_CLIENT_CERT + PHOENIX_CLIENT_KEY") + return fmt.Errorf("no auth configured: set PHOENIX_TOKEN, PHOENIX_ROLE, or PHOENIX_CLIENT_CERT + PHOENIX_CLIENT_KEY") } func apiRequest(method, path string, body io.Reader) (*http.Response, error) { @@ -358,8 +401,11 @@ func apiRequestWithHeaders(method, path string, body io.Reader, headers map[stri if err != nil { return nil, err } - if token != "" { - req.Header.Set("Authorization", "Bearer "+token) + tokenMu.RLock() + tok := token + tokenMu.RUnlock() + if tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) } if body != nil { req.Header.Set("Content-Type", "application/json") @@ -938,6 +984,30 @@ func cmdAgentList() error { return nil } +func cmdAgentDelete(args []string) error { + if err := requireAuth(); err != nil { + return err + } + + if len(args) < 1 { + return fmt.Errorf("usage: phoenix agent delete ") + } + name := args[0] + + resp, err := apiRequest("DELETE", "/v1/agents/"+name, nil) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return handleError(resp) + } + + fmt.Printf("agent deleted: %s\n", name) + return nil +} + func cmdResolve(args []string) error { if err := requireAuth(); err != nil { return err @@ -2035,6 +2105,257 @@ func getCachedToken(agentName string, ttlBuffer time.Duration) string { return entry.Token } +// sessionCacheKey returns a cache key scoped to both role and server URL, +// preventing collisions when the same role name is used across different servers. +func sessionCacheKey(role string) string { + h := sha256pkg.Sum256([]byte(serverURL)) + return fmt.Sprintf("session:%s@%.8x", role, h[:4]) +} + +// getCachedSessionToken returns a valid cached session token for the role, or empty string. +func getCachedSessionToken(role string, ttlBuffer time.Duration) string { + return getCachedToken(sessionCacheKey(role), ttlBuffer) +} + +// cacheSessionToken stores a session token in the cache. +func cacheSessionToken(role, tok string, expiresAt time.Time) { + cache, _ := loadTokenCache() + key := sessionCacheKey(role) + cache[key] = &tokenCacheEntry{ + Token: tok, + Agent: "session:" + role, + ExpiresAt: expiresAt, + } + if err := saveTokenCache(cache); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not cache session token: %v\n", err) + } +} + +// ensureSealKey loads or generates a seal key for session use. +// Priority: PHOENIX_SEAL_KEY env > ~/.phoenix/session-seal-.key > generate new. +func ensureSealKey(role string) (*[32]byte, string, error) { + // Check env first + envPath := os.Getenv("PHOENIX_SEAL_KEY") + if envPath != "" { + priv, err := crypto.LoadSealPrivateKey(envPath) + if err != nil { + return nil, "", fmt.Errorf("loading PHOENIX_SEAL_KEY: %w", err) + } + pub := crypto.DeriveSealPublicKey(priv) + return priv, crypto.EncodeSealKey(pub), nil + } + + // Check per-role key file + home, err := os.UserHomeDir() + if err != nil { + return nil, "", fmt.Errorf("cannot determine home directory: %w", err) + } + keyDir := filepath.Join(home, ".phoenix") + keyPath := filepath.Join(keyDir, "session-seal-"+role+".key") + + if _, statErr := os.Stat(keyPath); statErr == nil { + priv, loadErr := crypto.LoadSealPrivateKey(keyPath) + if loadErr != nil { + return nil, "", fmt.Errorf("loading seal key %s: %w", keyPath, loadErr) + } + pub := crypto.DeriveSealPublicKey(priv) + return priv, crypto.EncodeSealKey(pub), nil + } + + // Generate new key + if err := os.MkdirAll(keyDir, 0700); err != nil { + return nil, "", fmt.Errorf("creating key directory: %w", err) + } + kp, err := crypto.GenerateSealKeyPair() + if err != nil { + return nil, "", fmt.Errorf("generating seal key: %w", err) + } + privKey := kp.PrivateKey + pubKey := kp.PublicKey + encoded := crypto.EncodeSealKey(&privKey) + if err := os.WriteFile(keyPath, []byte(encoded+"\n"), 0600); err != nil { + return nil, "", fmt.Errorf("saving seal key: %w", err) + } + return &privKey, crypto.EncodeSealKey(&pubKey), nil +} + +// mintSessionViaAPI calls POST /v1/session/mint and returns the session token. +func mintSessionViaAPI(role, sealPubKeyB64 string) (string, time.Time, error) { + body := map[string]string{"role": role} + if sealPubKeyB64 != "" { + body["seal_public_key"] = sealPubKeyB64 + } + if tty := detectTTY(); tty != "" { + body["requester_tty"] = tty + } + bodyBytes, _ := json.Marshal(body) + + resp, err := apiRequest("POST", "/v1/session/mint", strings.NewReader(string(bodyBytes))) + if err != nil { + return "", time.Time{}, fmt.Errorf("session mint request: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + + // Step-up approval: 202 means approval required + if resp.StatusCode == http.StatusAccepted { + var pending struct { + Status string `json:"status"` + ApprovalID string `json:"approval_id"` + ApproveCmd string `json:"approve_command"` + ExpiresAt string `json:"expires_at"` + } + if err := json.Unmarshal(respBody, &pending); err == nil && pending.Status == "approval_required" { + return pollForApproval(pending.ApprovalID, pending.ExpiresAt, pending.ApproveCmd) + } + } + + if resp.StatusCode != 200 { + var errResp struct { + Error string `json:"error"` + Code string `json:"code"` + Detail string `json:"detail"` + Remediation string `json:"remediation"` + } + if json.Unmarshal(respBody, &errResp) == nil && errResp.Code != "" { + return "", time.Time{}, fmt.Errorf("[%s] %s\n hint: %s", errResp.Code, errResp.Detail, errResp.Remediation) + } + if json.Unmarshal(respBody, &errResp) == nil && errResp.Error != "" { + return "", time.Time{}, fmt.Errorf("session mint: %s", errResp.Error) + } + return "", time.Time{}, fmt.Errorf("session mint: HTTP %d", resp.StatusCode) + } + + var result struct { + SessionToken string `json:"session_token"` + ExpiresAt string `json:"expires_at"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return "", time.Time{}, fmt.Errorf("parsing mint response: %w", err) + } + exp, err := time.Parse(time.RFC3339, result.ExpiresAt) + if err != nil { + return "", time.Time{}, fmt.Errorf("parsing expires_at: %w", err) + } + return result.SessionToken, exp, nil +} + +// autoMintSession checks PHOENIX_ROLE and auto-mints a session token. +func autoMintSession() error { + role := os.Getenv("PHOENIX_ROLE") + if role == "" { + return fmt.Errorf("PHOENIX_ROLE not set") + } + + // Check cache first + if cached := getCachedSessionToken(role, 5*time.Minute); cached != "" { + tokenMu.Lock() + token = cached + tokenMu.Unlock() + return nil + } + + // Need bootstrap auth to mint + tokenMu.RLock() + noToken := token == "" + tokenMu.RUnlock() + if noToken { + if os.Getenv("PHOENIX_CLIENT_CERT") == "" || os.Getenv("PHOENIX_CLIENT_KEY") == "" { + return fmt.Errorf("PHOENIX_ROLE=%s set but no bootstrap auth available (set PHOENIX_TOKEN or mTLS certs)", role) + } + } + + // Load or generate seal key + _, sealPubB64, err := ensureSealKey(role) + if err != nil { + // Non-fatal: mint without seal key + fmt.Fprintf(os.Stderr, "warning: seal key unavailable: %v\n", err) + sealPubB64 = "" + } + + sessionToken, expiresAt, err := mintSessionViaAPI(role, sealPubB64) + if err != nil { + return err + } + + cacheSessionToken(role, sessionToken, expiresAt) + tokenMu.Lock() + token = sessionToken + tokenMu.Unlock() + return nil +} + +// renewSessionViaAPI calls POST /v1/session/renew with the current session token. +func renewSessionViaAPI() (string, time.Time, error) { + resp, err := apiRequest("POST", "/v1/session/renew", strings.NewReader("{}")) + if err != nil { + return "", time.Time{}, fmt.Errorf("session renew request: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + var errResp struct { + Error string `json:"error"` + Code string `json:"code"` + Detail string `json:"detail"` + Remediation string `json:"remediation"` + } + if json.Unmarshal(respBody, &errResp) == nil && errResp.Code != "" { + return "", time.Time{}, fmt.Errorf("[%s] %s", errResp.Code, errResp.Detail) + } + return "", time.Time{}, fmt.Errorf("session renew: HTTP %d", resp.StatusCode) + } + + var result struct { + SessionToken string `json:"session_token"` + ExpiresAt string `json:"expires_at"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return "", time.Time{}, fmt.Errorf("parsing renew response: %w", err) + } + exp, err := time.Parse(time.RFC3339, result.ExpiresAt) + if err != nil { + return "", time.Time{}, fmt.Errorf("parsing expires_at: %w", err) + } + return result.SessionToken, exp, nil +} + +// renewSessionIfNeeded checks if the current session token is nearing expiry +// and renews it if so (within 10 minutes of expiry). +func renewSessionIfNeeded() { + if !strings.HasPrefix(token, "phxs_") { + return + } + role := os.Getenv("PHOENIX_ROLE") + if role == "" { + return + } + + // Check if cached token is nearing expiry + cache, _ := loadTokenCache() + entry, ok := cache[sessionCacheKey(role)] + if !ok { + return + } + if time.Until(entry.ExpiresAt) > 10*time.Minute { + return // still plenty of time + } + + newToken, newExpiry, err := renewSessionViaAPI() + if err != nil { + fmt.Fprintf(os.Stderr, "warning: session renewal failed: %v\n", err) + return + } + + cacheSessionToken(role, newToken, newExpiry) + tokenMu.Lock() + token = newToken + tokenMu.Unlock() +} + // attestViaSocket connects to the agent socket and returns peer info. func attestViaSocket(socketPath string, agentName string) (peerUID int, binaryHash string, err error) { conn, err := net.Dial("unix", socketPath) @@ -2479,12 +2800,335 @@ func cmdEmergencyGet(args []string) error { } func handleError(resp *http.Response) error { - var errResp struct { - Error string `json:"error"` + body, _ := io.ReadAll(resp.Body) + + var structured struct { + Error string `json:"error"` + Code string `json:"code"` + Detail string `json:"detail"` + Remediation string `json:"remediation"` } - json.NewDecoder(resp.Body).Decode(&errResp) - if errResp.Error != "" { - return fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error) + if json.Unmarshal(body, &structured) == nil && structured.Code != "" { + msg := fmt.Sprintf("HTTP %d [%s]: %s", resp.StatusCode, structured.Code, structured.Detail) + if structured.Remediation != "" { + msg += "\n hint: " + structured.Remediation + } + return fmt.Errorf("%s", msg) + } + + if json.Unmarshal(body, &structured) == nil && structured.Error != "" { + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, structured.Error) } return fmt.Errorf("HTTP %d", resp.StatusCode) } + +// detectTTY returns the TTY path for the current process, or empty string. +func detectTTY() string { + link, err := os.Readlink("/proc/self/fd/0") + if err != nil { + return "" + } + if strings.HasPrefix(link, "/dev/pts/") || strings.HasPrefix(link, "/dev/tty") { + return link + } + return "" +} + +// pollForApproval polls an approval status endpoint until approved, denied, or expired. +func pollForApproval(approvalID, expiresAtStr, approveCmd string) (string, time.Time, error) { + fmt.Fprintf(os.Stderr, "Step-up approval required. Run:\n %s\n\nWaiting for approval...\n", approveCmd) + + deadline := time.Now().Add(10 * time.Minute) // fallback + if exp, err := time.Parse(time.RFC3339, expiresAtStr); err == nil { + deadline = exp.Add(5 * time.Second) // small grace past server expiry + } + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for range ticker.C { + if time.Now().After(deadline) { + return "", time.Time{}, fmt.Errorf("[APPROVAL_EXPIRED] approval window has expired") + } + + resp, err := apiRequest("GET", "/v1/approval/"+approvalID, nil) + if err != nil { + fmt.Fprintf(os.Stderr, " poll error: %v\n", err) + continue + } + respBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var status struct { + Status string `json:"status"` + SessionToken string `json:"session_token"` + SessionExpiry string `json:"session_expiry"` + } + if err := json.Unmarshal(respBody, &status); err != nil { + continue + } + + switch status.Status { + case "approved": + fmt.Fprintf(os.Stderr, "Approved.\n") + exp, _ := time.Parse(time.RFC3339, status.SessionExpiry) + return status.SessionToken, exp, nil + case "denied": + return "", time.Time{}, fmt.Errorf("[APPROVAL_DENIED] step-up approval was denied") + case "expired": + return "", time.Time{}, fmt.Errorf("[APPROVAL_EXPIRED] approval window expired before human approval") + case "pending": + // continue polling + } + } + return "", time.Time{}, fmt.Errorf("[APPROVAL_EXPIRED] approval poll ended unexpectedly") +} + +// cmdApprove handles the "phoenix approve " command. +func cmdSessionList(args []string) error { + if err := requireAuth(); err != nil { + return err + } + + queryParams := "" + for i := 0; i < len(args); i++ { + switch { + case (args[i] == "--role" || args[i] == "-r") && i+1 < len(args): + if queryParams == "" { + queryParams = "?" + } else { + queryParams += "&" + } + queryParams += "role=" + url.QueryEscape(args[i+1]) + i++ + case (args[i] == "--agent" || args[i] == "-a") && i+1 < len(args): + if queryParams == "" { + queryParams = "?" + } else { + queryParams += "&" + } + queryParams += "agent=" + url.QueryEscape(args[i+1]) + i++ + } + } + + resp, err := apiRequest("GET", "/v1/sessions"+queryParams, nil) + if err != nil { + return fmt.Errorf("listing sessions: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return handleError(resp) + } + + var result struct { + Sessions []struct { + SessionID string `json:"session_id"` + Role string `json:"role"` + Agent string `json:"agent"` + BootstrapMethod string `json:"bootstrap_method"` + SourceIP string `json:"source_ip"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at"` + } `json:"sessions"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + + if len(result.Sessions) == 0 { + fmt.Println("No active sessions.") + return nil + } + + fmt.Printf("%-36s %-10s %-12s %-20s %-20s %-15s\n", + "SESSION_ID", "ROLE", "AGENT", "CREATED", "EXPIRES", "SOURCE_IP") + for _, s := range result.Sessions { + fmt.Printf("%-36s %-10s %-12s %-20s %-20s %-15s\n", + s.SessionID, s.Role, s.Agent, s.CreatedAt, s.ExpiresAt, s.SourceIP) + } + return nil +} + +func cmdSessionInfo(args []string) error { + if len(args) < 1 { + return fmt.Errorf("usage: phoenix sessions info ") + } + if err := requireAuth(); err != nil { + return err + } + + sessionID := args[0] + resp, err := apiRequest("GET", "/v1/sessions/"+sessionID, nil) + if err != nil { + return fmt.Errorf("fetching session: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return handleError(resp) + } + + var sess struct { + SessionID string `json:"session_id"` + Role string `json:"role"` + Agent string `json:"agent"` + Namespaces []string `json:"namespaces"` + Actions []string `json:"actions"` + BootstrapMethod string `json:"bootstrap_method"` + SourceIP string `json:"source_ip"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at"` + Revoked bool `json:"revoked"` + } + if err := json.NewDecoder(resp.Body).Decode(&sess); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + + fmt.Printf("Session ID: %s\n", sess.SessionID) + fmt.Printf("Role: %s\n", sess.Role) + fmt.Printf("Agent: %s\n", sess.Agent) + fmt.Printf("Namespaces: %s\n", strings.Join(sess.Namespaces, ", ")) + fmt.Printf("Actions: %s\n", strings.Join(sess.Actions, ", ")) + fmt.Printf("Bootstrap Method: %s\n", sess.BootstrapMethod) + fmt.Printf("Source IP: %s\n", sess.SourceIP) + fmt.Printf("Created At: %s\n", sess.CreatedAt) + fmt.Printf("Expires At: %s\n", sess.ExpiresAt) + fmt.Printf("Revoked: %v\n", sess.Revoked) + return nil +} + +func cmdSessionRevoke(args []string) error { + if len(args) < 1 { + return fmt.Errorf("usage: phoenix sessions revoke ") + } + if err := requireAuth(); err != nil { + return err + } + + sessionID := args[0] + resp, err := apiRequest("POST", "/v1/sessions/"+sessionID+"/revoke", strings.NewReader("{}")) + if err != nil { + return fmt.Errorf("revoking session: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return handleError(resp) + } + + var result struct { + Status string `json:"status"` + SessionID string `json:"session_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + + fmt.Printf("Session %s revoked.\n", result.SessionID) + return nil +} + +func cmdApprove(args []string) error { + if len(args) < 1 { + return fmt.Errorf("usage: phoenix approve ") + } + approvalID := args[0] + + if err := requireAuth(); err != nil { + return err + } + + // Fetch approval details + resp, err := apiRequest("GET", "/v1/approval/"+approvalID, nil) + if err != nil { + return fmt.Errorf("fetching approval: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return handleError(resp) + } + + var details struct { + ID string `json:"id"` + Role string `json:"role"` + Agent string `json:"agent"` + Status string `json:"status"` + ExpiresAt string `json:"expires_at"` + } + if err := json.NewDecoder(resp.Body).Decode(&details); err != nil { + return fmt.Errorf("decoding approval: %w", err) + } + + if details.Status != "pending" { + return fmt.Errorf("approval %s is already %s", approvalID, details.Status) + } + + // Prompt for confirmation via /dev/tty + fmt.Fprintf(os.Stderr, "\nApprove session for role '%s'?\n", details.Role) + fmt.Fprintf(os.Stderr, " Requested by: agent %q\n", details.Agent) + fmt.Fprintf(os.Stderr, " Expires at: %s\n", details.ExpiresAt) + fmt.Fprintf(os.Stderr, "\nApprove? [y/N]: ") + + answer, err := readFromTTY() + if err != nil { + return fmt.Errorf("reading confirmation: %w", err) + } + + answer = strings.TrimSpace(strings.ToLower(answer)) + + myTTY := detectTTY() + + if answer == "y" || answer == "yes" { + body, _ := json.Marshal(map[string]string{"approver_tty": myTTY}) + resp, err := apiRequest("POST", "/v1/approval/"+approvalID+"/approve", strings.NewReader(string(body))) + if err != nil { + return fmt.Errorf("approve request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return handleError(resp) + } + + var result struct { + SameTTYWarning bool `json:"same_tty_warning"` + } + respBody, _ := io.ReadAll(resp.Body) + json.Unmarshal(respBody, &result) + + if result.SameTTYWarning { + fmt.Fprintf(os.Stderr, "WARNING: approver and requester are on the same TTY — this may indicate the same operator is both requesting and approving.\n") + } + fmt.Fprintln(os.Stderr, "Session approved.") + return nil + } + + // Deny + resp2, err := apiRequest("POST", "/v1/approval/"+approvalID+"/deny", strings.NewReader("{}")) + if err != nil { + return fmt.Errorf("deny request: %w", err) + } + defer resp2.Body.Close() + if resp2.StatusCode != 200 { + return handleError(resp2) + } + fmt.Fprintln(os.Stderr, "Session denied.") + return nil +} + +// readFromTTY reads a line from /dev/tty (works even when stdin is piped). +func readFromTTY() (string, error) { + tty, err := os.Open("/dev/tty") + if err != nil { + // Fallback to stdin + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + return scanner.Text(), nil + } + return "", fmt.Errorf("no input available") + } + defer tty.Close() + scanner := bufio.NewScanner(tty) + if scanner.Scan() { + return scanner.Text(), nil + } + return "", fmt.Errorf("no input from /dev/tty") +} diff --git a/cmd/phoenix/mcp.go b/cmd/phoenix/mcp.go index bd3e102..f2daaf3 100644 --- a/cmd/phoenix/mcp.go +++ b/cmd/phoenix/mcp.go @@ -32,6 +32,7 @@ import ( "log" "os" "strings" + "time" "github.com/phoenixsec/phoenix/internal/crypto" "github.com/phoenixsec/phoenix/internal/version" @@ -147,6 +148,31 @@ var mcpBaseTools = []mcpTool{ }, } +var mcpSessionTools = []mcpTool{ + { + Name: "phoenix_session_list", + Description: "List active Phoenix sessions. Returns session IDs, roles, agents, and expiry times. Agents see their own sessions; admins see all.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": {} + }`), + }, + { + Name: "phoenix_session_revoke", + Description: "Revoke a Phoenix session by its ID. The session token becomes invalid immediately.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID to revoke (e.g. 'ses_...')" + } + }, + "required": ["session_id"] + }`), + }, +} + var mcpUnsealTool = mcpTool{ Name: "phoenix_unseal", Description: "Decrypt a sealed secret value. The decrypted value will be visible in this conversation. Only works when allow_unseal policy is set for the secret's path.", @@ -162,15 +188,15 @@ var mcpUnsealTool = mcpTool{ }`), } -// mcpGetTools returns the tool list, including phoenix_unseal only when sealed mode is active. +// mcpGetTools returns the tool list, including optional tools based on config. func mcpGetTools() []mcpTool { + tools := make([]mcpTool, len(mcpBaseTools)) + copy(tools, mcpBaseTools) + tools = append(tools, mcpSessionTools...) if mcpSealPrivKey != nil { - tools := make([]mcpTool, len(mcpBaseTools)+1) - copy(tools, mcpBaseTools) - tools[len(mcpBaseTools)] = mcpUnsealTool - return tools + tools = append(tools, mcpUnsealTool) } - return mcpBaseTools + return tools } // mcpHandleRequest processes a single JSON-RPC request and writes the @@ -231,6 +257,12 @@ func cmdMCP(args []string) error { if mcpSealPrivKey != nil { logger.Println("sealed mode enabled") } + + // Start background session renewal if using a session token + if os.Getenv("PHOENIX_ROLE") != "" && strings.HasPrefix(token, "phxs_") { + go sessionRenewalLoop(logger) + } + logger.Println("server starting (stdio)") dec := json.NewDecoder(os.Stdin) @@ -265,6 +297,10 @@ func mcpDispatchTool(name string, args json.RawMessage, logger *log.Logger) (str return mcpToolList(args, logger) case "phoenix_unseal": return mcpToolUnseal(args, logger) + case "phoenix_session_list": + return mcpToolSessionList(args, logger) + case "phoenix_session_revoke": + return mcpToolSessionRevoke(args, logger) default: return fmt.Sprintf("Unknown tool: %s", name), true } @@ -300,8 +336,14 @@ func mcpToolResolve(args json.RawMessage, logger *log.Logger) (string, bool) { } defer resp.Body.Close() + if resp.StatusCode == 202 { + respBody, _ := io.ReadAll(resp.Body) + return mcpFormatApprovalRequired(respBody), false + } + if resp.StatusCode != 200 { - return fmt.Sprintf("Server error: HTTP %d", resp.StatusCode), true + respBody, _ := io.ReadAll(resp.Body) + return mcpFormatDenial(respBody, resp.StatusCode), true } // Sealed mode: return opaque PHOENIX_SEALED: tokens @@ -404,15 +446,14 @@ func mcpToolGet(args json.RawMessage, logger *log.Logger) (string, bool) { } defer resp.Body.Close() + if resp.StatusCode == 202 { + body, _ := io.ReadAll(resp.Body) + return mcpFormatApprovalRequired(body), false + } + if resp.StatusCode != 200 { - var errResp struct { - Error string `json:"error"` - } - json.NewDecoder(resp.Body).Decode(&errResp) - if errResp.Error != "" { - return fmt.Sprintf("%s: %s", params.Path, errResp.Error), true - } - return fmt.Sprintf("%s: HTTP %d", params.Path, resp.StatusCode), true + body, _ := io.ReadAll(resp.Body) + return fmt.Sprintf("%s: %s", params.Path, mcpFormatDenial(body, resp.StatusCode)), true } // Sealed mode: return opaque PHOENIX_SEALED: token @@ -474,15 +515,14 @@ func mcpToolList(args json.RawMessage, logger *log.Logger) (string, bool) { } defer resp.Body.Close() + if resp.StatusCode == 202 { + respBody, _ := io.ReadAll(resp.Body) + return mcpFormatApprovalRequired(respBody), false + } + if resp.StatusCode != 200 { - var errResp struct { - Error string `json:"error"` - } - json.NewDecoder(resp.Body).Decode(&errResp) - if errResp.Error != "" { - return errResp.Error, true - } - return fmt.Sprintf("HTTP %d", resp.StatusCode), true + respBody, _ := io.ReadAll(resp.Body) + return mcpFormatDenial(respBody, resp.StatusCode), true } var result struct { @@ -567,6 +607,129 @@ func mcpCheckAllowUnseal(path string) (bool, error) { return result.Allowed, nil } +// sessionRenewalLoop runs in the background during MCP server mode, +// periodically renewing the session token before it expires. +func sessionRenewalLoop(logger *log.Logger) { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for range ticker.C { + renewSessionIfNeeded() + if logger != nil { + logger.Println("session renewal check complete") + } + } +} + +// mcpFormatDenial parses a structured denial response body and returns +// a formatted error string for the agent. +func mcpFormatDenial(body []byte, statusCode int) string { + var denial struct { + Error string `json:"error"` + Code string `json:"code"` + Detail string `json:"detail"` + Remediation string `json:"remediation"` + } + if json.Unmarshal(body, &denial) == nil && denial.Code != "" { + msg := fmt.Sprintf("[%s] %s", denial.Code, denial.Detail) + if denial.Remediation != "" { + msg += "\nhint: " + denial.Remediation + } + return msg + } + if json.Unmarshal(body, &denial) == nil && denial.Error != "" { + return denial.Error + } + return fmt.Sprintf("HTTP %d", statusCode) +} + +// mcpFormatApprovalRequired parses a 202 approval-required response +// and returns a message suitable for an agent to present to the human. +func mcpFormatApprovalRequired(body []byte) string { + var pending struct { + Status string `json:"status"` + ApproveCmd string `json:"approve_command"` + ExpiresAt string `json:"expires_at"` + } + if json.Unmarshal(body, &pending) == nil && pending.Status == "approval_required" { + msg := "[APPROVAL_REQUIRED] This operation requires human approval." + if pending.ApproveCmd != "" { + msg += "\nRun: " + pending.ApproveCmd + } + if pending.ExpiresAt != "" { + msg += "\nExpires: " + pending.ExpiresAt + } + return msg + } + return "[APPROVAL_REQUIRED] This operation requires human approval." +} + +// mcpToolSessionList handles the phoenix_session_list tool. +func mcpToolSessionList(args json.RawMessage, logger *log.Logger) (string, bool) { + resp, err := apiRequest("GET", "/v1/sessions", nil) + if err != nil { + return fmt.Sprintf("Request failed: %v", err), true + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return mcpFormatDenial(body, resp.StatusCode), true + } + + var result struct { + Sessions []struct { + SessionID string `json:"session_id"` + Role string `json:"role"` + Agent string `json:"agent"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at"` + SourceIP string `json:"source_ip"` + } `json:"sessions"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Sprintf("Failed to decode response: %v", err), true + } + + if len(result.Sessions) == 0 { + return "No active sessions.", false + } + + var lines []string + for _, s := range result.Sessions { + lines = append(lines, fmt.Sprintf("%s role=%s agent=%s expires=%s ip=%s", + s.SessionID, s.Role, s.Agent, s.ExpiresAt, s.SourceIP)) + } + logger.Printf("listed %d sessions", len(result.Sessions)) + return strings.Join(lines, "\n"), false +} + +// mcpToolSessionRevoke handles the phoenix_session_revoke tool. +func mcpToolSessionRevoke(args json.RawMessage, logger *log.Logger) (string, bool) { + var params struct { + SessionID string `json:"session_id"` + } + if err := json.Unmarshal(args, ¶ms); err != nil { + return fmt.Sprintf("Invalid arguments: %v", err), true + } + if params.SessionID == "" { + return "session_id is required", true + } + + resp, err := apiRequest("POST", "/v1/sessions/"+params.SessionID+"/revoke", strings.NewReader("{}")) + if err != nil { + return fmt.Sprintf("Request failed: %v", err), true + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return mcpFormatDenial(body, resp.StatusCode), true + } + + logger.Printf("revoked session %s", params.SessionID) + return fmt.Sprintf("Session %s revoked.", params.SessionID), false +} + // mcpSendResult sends a successful JSON-RPC response. func mcpSendResult(enc *json.Encoder, id json.RawMessage, result interface{}) { if err := enc.Encode(mcpResponse{JSONRPC: "2.0", ID: id, Result: result}); err != nil { diff --git a/cmd/phoenix/mcp_seal_test.go b/cmd/phoenix/mcp_seal_test.go index f33a2f4..ecee93f 100644 --- a/cmd/phoenix/mcp_seal_test.go +++ b/cmd/phoenix/mcp_seal_test.go @@ -50,8 +50,8 @@ func TestMCPToolsListWithSealKey(t *testing.T) { var result mcpListToolsResult json.Unmarshal(b, &result) - if len(result.Tools) != 4 { - t.Fatalf("expected 4 tools (incl. unseal), got %d", len(result.Tools)) + if len(result.Tools) != 6 { + t.Fatalf("expected 6 tools (incl. unseal + session), got %d", len(result.Tools)) } names := map[string]bool{} @@ -74,8 +74,8 @@ func TestMCPToolsListWithoutSealKey(t *testing.T) { var result mcpListToolsResult json.Unmarshal(b, &result) - if len(result.Tools) != 3 { - t.Fatalf("expected 3 tools (no unseal), got %d", len(result.Tools)) + if len(result.Tools) != 5 { + t.Fatalf("expected 5 tools (no unseal), got %d", len(result.Tools)) } for _, tool := range result.Tools { diff --git a/cmd/phoenix/mcp_test.go b/cmd/phoenix/mcp_test.go index a858001..d83476b 100644 --- a/cmd/phoenix/mcp_test.go +++ b/cmd/phoenix/mcp_test.go @@ -139,8 +139,8 @@ func TestMCPToolsList(t *testing.T) { t.Fatalf("failed to decode result: %v", err) } - if len(result.Tools) != 3 { - t.Fatalf("expected 3 tools, got %d", len(result.Tools)) + if len(result.Tools) != 5 { + t.Fatalf("expected 5 tools, got %d", len(result.Tools)) } names := map[string]bool{} diff --git a/config.example.json b/config.example.json index 4e9a684..46f68e9 100644 --- a/config.example.json +++ b/config.example.json @@ -1,11 +1,11 @@ { "server": { - "listen": "0.0.0.0:9090" + "listen": "127.0.0.1:9090" }, "store": { "backend": "file", - "path": "/data/store.json", - "master_key": "/data/master.key" + "path": "/data/phoenix/store.json", + "master_key": "/data/phoenix/master.key" }, "crypto": { "provider": "file" @@ -20,13 +20,13 @@ } }, "acl": { - "path": "/data/acl.json" + "path": "/data/phoenix/acl.json" }, "audit": { - "path": "/data/audit.log" + "path": "/data/phoenix/audit.log" }, "policy": { - "path": "/data/policy.json" + "path": "/data/phoenix/policy.json" }, "attestation": { "nonce": { @@ -42,8 +42,48 @@ "socket_path": "/tmp/phoenix-agent.sock" } }, + "session": { + "enabled": false, + "ttl": "1h", + "roles": { + "dev": { + "namespaces": [ + "dev/*", + "staging/*" + ], + "actions": [ + "list", + "read_value" + ], + "bootstrap_trust": [ + "bearer" + ] + }, + "deploy": { + "namespaces": [ + "prod/*" + ], + "actions": [ + "list", + "read_value" + ], + "bootstrap_trust": [ + "mtls" + ], + "require_seal_key": true, + "step_up": true, + "step_up_ttl": "15m" + } + } + }, + "dashboard": { + "enabled": false, + "password_hash": "", + "session_ttl": "4h" + }, "onepassword": { "vault": "Engineering", - "service_account_token_env": "OP_SERVICE_ACCOUNT_TOKEN" + "service_account_token_env": "OP_SERVICE_ACCOUNT_TOKEN", + "cache_ttl": "60s" } } diff --git a/docs/admin-token-lifecycle.md b/docs/admin-token-lifecycle.md index acb73dd..cc0a5f2 100644 --- a/docs/admin-token-lifecycle.md +++ b/docs/admin-token-lifecycle.md @@ -33,15 +33,21 @@ Use the admin token only for setup tasks: - issue mTLS certs (`phoenix cert issue ...`) - configure policy/attestation -Example bootstrap flow: +Example bootstrap flow (local server, default config): ```bash -export PHOENIX_SERVER="https://localhost:9090" +export PHOENIX_SERVER="http://127.0.0.1:9090" export PHOENIX_TOKEN="" -export PHOENIX_CA_CERT="/data/phoenix/ca.crt" phoenix agent create app-runtime -t "runtime-token" --acl "myapp/*:read" phoenix agent create app-deployer -t "deploy-token" --acl "myapp/*:read,write" +``` + +If mTLS is enabled (required for `phoenix cert issue`): + +```bash +export PHOENIX_SERVER="https://phoenix.home:9090" +export PHOENIX_CA_CERT="/data/phoenix/ca.crt" phoenix cert issue app-runtime -o /etc/phoenix/certs ``` diff --git a/docs/api-reference-index.md b/docs/api-reference-index.md index f5cf6ac..d1fec67 100644 --- a/docs/api-reference-index.md +++ b/docs/api-reference-index.md @@ -44,10 +44,11 @@ Query params: | Method | Path | Purpose | |---|---|---| -| POST | `/v1/agents` | Create agent + permissions (admin-only) | +| POST | `/v1/agents` | Create or update agent + permissions (admin-only) | | GET | `/v1/agents` | List agent names (admin-only) | +| DELETE | `/v1/agents/{name}` | Delete an agent (admin-only) | -`POST /v1/agents` body: +`POST /v1/agents` body (use `?force=true` to update an existing agent): - `name` (required) - `token` (required) - `permissions` (array of `{path, actions[]}`; validated server-side) @@ -94,6 +95,38 @@ Response: { "path": "myapp/key", "check": "allow_unseal", "allowed": true } ``` +## Session identity + +| Method | Path | Purpose | +|---|---|---| +| POST | `/v1/session/mint` | Mint session token for a role | +| POST | `/v1/session/renew` | Renew current session token | +| GET | `/v1/sessions` | List active sessions | +| GET | `/v1/sessions/{id}` | Get session details | +| POST | `/v1/sessions/{id}/revoke` | Revoke a session | + +`POST /v1/session/mint` body: +- `role` (required): role name to mint +- `seal_public_key` (optional): base64-encoded X25519 public key + +`GET /v1/sessions` query params: +- `role` — filter by role name +- `agent` — filter by agent name (admin only) + +Session-token callers see only their own session. Bearer/mTLS callers see +sessions for their agent. Admins see all. + +## Step-up approvals + +| Method | Path | Purpose | +|---|---|---| +| GET | `/v1/approvals` | List pending approvals (admin-only) | +| GET | `/v1/approval/{id}` | Get approval status | +| POST | `/v1/approval/{id}/approve` | Approve step-up request | +| POST | `/v1/approval/{id}/deny` | Deny step-up request | + +See [Session Identity](session-identity.md) for the approval workflow. + ## Sealed responses When a request includes the `X-Phoenix-Seal-Key` header (base64-encoded @@ -112,6 +145,16 @@ All secret-returning responses include `Cache-Control: no-store`. See [Sealed Responses](sealed-responses.md) for the full guide. +## Operator dashboard + +The dashboard is served at `/dashboard/` and is **not** part of the `/v1/` API. +It uses cookie-based auth (not bearer/mTLS) and returns HTML (not JSON). + +Dashboard routes are only registered when `dashboard.enabled` is `true` in +config. They do not appear in the API mux otherwise. + +See [Dashboard](dashboard.md) for the full reference. + ## Common error shape Most failures return: @@ -122,3 +165,34 @@ Most failures return: Common status codes: `400`, `401`, `403`, `404`, `405`, `500`, `501`. +## Structured denials + +Session and access control failures return machine-readable denial responses: + +```json +{ + "error": "access_denied", + "code": "SCOPE_EXCEEDED", + "detail": "path \"prod/key\" is outside session scope for role \"dev\"", + "remediation": "request a session with a role that includes this namespace" +} +``` + +Denial codes: + +| Code | HTTP | Meaning | +|------|------|---------| +| `SESSION_EXPIRED` | 401 | Session TTL elapsed | +| `SESSION_REVOKED` | 401 | Session was explicitly revoked | +| `SESSION_INVALID` | 401 | Token malformed or signature invalid | +| `SCOPE_EXCEEDED` | 403 | Path outside role's namespace scope | +| `ACTION_DENIED` | 403 | Action not permitted by role | +| `APPROVAL_REQUIRED` | 202 | Step-up approval needed | +| `BOOTSTRAP_FAILED` | 403 | Auth method not accepted for this role | +| `ROLE_NOT_FOUND` | 404 | Requested role does not exist | +| `ATTESTATION_FAILED` | 403 | Attestation requirements not met | +| `SEAL_KEY_REQUIRED` | 403 | Role requires seal key at mint | +| `SEAL_KEY_MISMATCH` | 403 | Seal key doesn't match session binding | +| `SESSION_REQUIRED` | 400 | Operation requires a session token | +| `ADMIN_AUTH_REQUIRED` | 403 | Session tokens cannot perform this action | + diff --git a/docs/authentication.md b/docs/authentication.md index 652ac86..85b4d23 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -47,6 +47,26 @@ When `PHOENIX_SEAL_KEY` points to a local private key file, Phoenix clients can: This is especially important for MCP and multi-agent same-host workflows. See [Sealed Responses](sealed-responses.md) and [Multi-Agent Setup](multi-agent-setup.md). +## Session identity (recommended for agents) + +Session tokens replace static bearer tokens with short-lived, scoped credentials. +An agent authenticates once with a bootstrap token and receives a session token +bound to a named role. + +```bash +export PHOENIX_TOKEN="bootstrap-token" +export PHOENIX_ROLE="dev" +phoenix get dev/api-key # auto-mints session, then reads secret +``` + +Session tokens use a `phxs_` prefix and are scoped to specific namespaces and +actions. They auto-renew and can be revoked individually. + +Auth priority order: session token > mTLS > short-lived token > bearer. + +See [Session Identity](session-identity.md) for full details on roles, +step-up approval, and configuration. + ## CLI environment variables | Variable | Description | @@ -57,12 +77,29 @@ See [Sealed Responses](sealed-responses.md) and [Multi-Agent Setup](multi-agent- | `PHOENIX_CLIENT_CERT` | Client certificate for mTLS | | `PHOENIX_CLIENT_KEY` | Client key for mTLS | | `PHOENIX_SEAL_KEY` | Seal private key file path | +| `PHOENIX_ROLE` | Role name for auto-mint session identity | | `PHOENIX_POLICY` | Policy file path for local `phoenix policy` commands | | `PHOENIX_OP_TOKEN_ENV` | Import-only env var name holding 1Password token | +## Dashboard auth (separate surface) + +The operator dashboard (`/dashboard/`) uses its own authentication — it does +**not** use bearer tokens, mTLS, or session tokens. Instead, it uses a shared +password or PIN with cookie-based sessions. + +This is intentional: the dashboard is a human operator interface, not an agent +interface. The API auth model (bearer, mTLS, session tokens) is designed for +programmatic access with per-agent identity. The dashboard auth model is +designed for browser access with shared-credential simplicity. + +See [Dashboard](dashboard.md) for the full security model, deployment +requirements, and threat analysis. + ## Related docs - [Getting Started](getting-started.md) +- [Session Identity](session-identity.md) +- [Dashboard](dashboard.md) - [Policy and Attestation](policy-and-attestation.md) - [Sealed Responses](sealed-responses.md) - [Admin Token Lifecycle](admin-token-lifecycle.md) diff --git a/docs/cli-usage.md b/docs/cli-usage.md index ef56dbf..bce1eed 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -29,16 +29,6 @@ phoenix resolve phoenix://myapp/db-password phoenix resolve phoenix://myapp/db-password phoenix://myapp/api-key ``` -## Batch resolution API - -```bash -curl -X POST https://localhost:9090/v1/resolve \ - -H "Authorization: Bearer $TOKEN" \ - -d '{"refs": ["phoenix://myapp/db-password", "phoenix://myapp/api-key"]}' -``` - -Partial failures return per-ref errors without blocking successful resolutions. - ## Exec wrapper ```bash @@ -100,10 +90,139 @@ Options: phoenix export myapp/ --format env > .env ``` +## Session management + +```bash +# List active sessions (admin sees all, non-admin sees own) +phoenix sessions list +phoenix sessions list --role dev +phoenix sessions list --agent deployer + +# Show session details +phoenix sessions info ses_abc123def456 + +# Revoke a session (immediate effect) +phoenix sessions revoke ses_abc123def456 + +# Approve a step-up session request +phoenix approve apr_xyz789 +``` + +When `PHOENIX_ROLE` is set, the CLI auto-mints a session on the first request +and caches it for the session lifetime: + +```bash +export PHOENIX_ROLE=dev +phoenix get dev/api-key # mints session, then reads +phoenix get dev/db-pass # reuses cached session +``` + +See [Session Identity](session-identity.md) for full details. + +## Agent management + +```bash +# Create an agent with scoped ACL (admin only) +phoenix agent create deployer \ + -t "$(openssl rand -hex 32)" \ + --acl "myapp/*:read;staging/*:read,write" + +# Update an existing agent (re-create with --force) +phoenix agent create deployer \ + -t "$(openssl rand -hex 32)" \ + --acl "myapp/*:read,write" --force + +# List all agents (admin only) +phoenix agent list + +# Delete an agent (admin only) +phoenix agent delete deployer +``` + +## Certificate management + +Requires `auth.mtls.enabled: true` in the server config. + +```bash +# Issue a client certificate for an agent (admin only) +phoenix cert issue deployer -o /etc/phoenix/certs/ + +# Revoke a certificate by serial number (admin only) +phoenix cert revoke +``` + +## Sealed key pairs + +```bash +# Generate a seal key pair for an agent +phoenix keypair generate myagent -o /etc/phoenix/keys/ + +# The private key is written to .seal.key +# The public key is printed to stdout +``` + +Set `PHOENIX_SEAL_KEY` to the private key path to enable sealed mode. +See [Sealed Responses](sealed-responses.md). + +## Server status + +```bash +phoenix status +``` + +Shows server health, secret count, agent count, policy summary, and recent +audit activity. + +## Reference verification + +```bash +# Verify all phoenix:// references in a file are resolvable +phoenix verify config.yaml + +# Dry-run — checks access without resolving values +phoenix verify --dry-run config.yaml +``` + +## Emergency access + +Break-glass secret retrieval directly from disk when the server is down: + +```bash +phoenix emergency get myapp/db-password --data-dir /data/phoenix +``` + +Requires direct access to the data directory. Logs the access to the audit +trail with agent `emergency-local`. See [Key Management](key-management.md). + +## Master key management + +```bash +# Rotate the master encryption key +phoenix rotate-master + +# Add or change passphrase protection on the master key +phoenix-server --protect-key --config /data/phoenix/config.json +``` + +See [Key Management](key-management.md) for details. + +## Policy testing + +```bash +# Show attestation requirements for a path +phoenix policy show production/db-password + +# Dry-run an attestation check +phoenix policy test --agent deployer --ip 192.168.0.110 production/db-password +``` + +Requires `PHOENIX_POLICY` to point to the policy JSON file. + ## Related docs - [Getting Started](getting-started.md) - [Authentication](authentication.md) +- [Session Identity](session-identity.md) - [Policy and Attestation](policy-and-attestation.md) - [Integrations](integrations.md) - [Sealed Responses](sealed-responses.md) diff --git a/docs/configuration.md b/docs/configuration.md index 16136c9..57569bc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,10 +22,99 @@ The server reads a JSON config file. `config.example.json` is a starter template | `attestation.token.ttl` | Token lifetime | `15m` | | `attestation.local_agent.enabled` | Enable local Unix-socket attestation agent | `false` | | `attestation.local_agent.socket_path` | Unix socket path when enabled | — | +| `session.enabled` | Enable session identity | `false` | +| `session.ttl` | Default session lifetime | `1h` | +| `session.roles` | Named role definitions (see below) | — | | `onepassword.vault` | 1Password vault name | — | | `onepassword.service_account_token_env` | Token env var name | `OP_SERVICE_ACCOUNT_TOKEN` | | `onepassword.cache_ttl` | Runtime read/list cache duration | `60s` | +## Session identity + +Session identity replaces static tokens with short-lived, role-scoped sessions. +See [Session Identity](session-identity.md) for the full guide. + +```json +{ + "session": { + "enabled": true, + "ttl": "1h", + "roles": { + "dev": { + "namespaces": ["dev/*", "staging/*"], + "actions": ["list", "read_value"], + "bootstrap_trust": ["bearer"] + }, + "deploy": { + "namespaces": ["prod/*"], + "actions": ["list", "read_value"], + "bootstrap_trust": ["mtls"], + "require_seal_key": true, + "step_up": true, + "step_up_ttl": "15m" + } + } + } +} +``` + +Each role defines: `namespaces` (required), `bootstrap_trust` (required), +`actions` (default: list + read_value), and optional fields for seal key +requirements, attestation, and step-up approval. + +## Dashboard + +The operator dashboard provides a browser-based UI for approvals, sessions, +audit, and role inspection. All assets are embedded — no CDN or build step. + +Generate a password hash first: + +```bash +phoenix-server --hash-password +# Enter dashboard password: **** +# Confirm dashboard password: **** +# $2a$10$... +``` + +Then add it to config: + +```json +{ + "dashboard": { + "enabled": true, + "password_hash": "$2a$10$...", + "session_ttl": "4h" + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `dashboard.enabled` | `bool` | Enable the dashboard at `/dashboard/` | +| `dashboard.password_hash` | `string` | bcrypt hash of operator password (generate with `phoenix-server --hash-password`) | +| `dashboard.pin` | `string` | Alternative: login PIN (constant-time compare, loopback only) | +| `dashboard.session_ttl` | `string` | Dashboard session lifetime (default `4h`) | +| `dashboard.allow_multi_login` | `bool` | Allow concurrent dashboard sessions (default `false`) | + +Either `password_hash` or `pin` is required when the dashboard is enabled. +The password is stored as a bcrypt hash — the plaintext never appears in config. +Validation rejects non-bcrypt values in `password_hash` to prevent accidental +plaintext storage. + +The dashboard uses cookie-based auth with HMAC-signed tokens and CSRF protection. +Login attempts are rate-limited with exponential backoff (5 failures before lockout, +up to 60s delay, per source IP). Failed login attempts are logged to the audit trail. + +**Transport security:** When the server runs with mTLS enabled or behind a TLS +reverse proxy (detected via `X-Forwarded-Proto: https`), the session cookie is +set with `Secure` flag automatically. For production use: + +- Run behind a TLS reverse proxy (e.g., Nginx Proxy Manager), or +- Use the built-in mTLS mode (`auth.mtls.enabled: true`), or +- Restrict to loopback access only (`server.listen: "127.0.0.1:9090"`) + +Do not expose the dashboard over plain HTTP on a network you do not control. + ## 1Password runtime backend (broker mode, read-only) Phoenix can broker access to secrets stored in 1Password: @@ -48,6 +137,26 @@ Behavior: - `set/delete` are blocked - path mapping: `phoenix://myapp/api-key` -> `op://Engineering/myapp/api-key` +## Transport security + +The default configuration starts the server on plain HTTP at `127.0.0.1:9090`. +This is safe for local-only use — the server is not reachable from the network. + +`phoenix-server --init` generates a CA and server certificate, but does **not** +enable TLS by default. To enable TLS, set `auth.mtls.enabled: true` in the +config. This activates server-side TLS using the generated certificate. You can +set `auth.mtls.require: false` to accept TLS connections without requiring +client certificates (agents can still use bearer tokens). + +| Deployment | Config | Result | +|-----------|--------|--------| +| Local only | Default (`127.0.0.1`, mTLS disabled) | Plain HTTP on loopback — safe | +| LAN / remote | `0.0.0.0`, `auth.mtls.enabled: true` | TLS with optional client certs | +| Production | `0.0.0.0`, `auth.mtls.enabled: true`, `require: true` | Full mTLS required | +| Behind reverse proxy | `127.0.0.1`, proxy terminates TLS | Plain HTTP on loopback, TLS to clients | + +See [LAN Deployment](lan-deployment.md) for the full multi-host setup. + ## Architecture summary ```text @@ -72,11 +181,9 @@ Authentication flow: - secret access - audit log write -## Docker (planned) +## Docker -> Docker images are not yet published. The examples below show the intended usage -> for when `phoenixsecdev/phoenix` is available on Docker Hub. For now, build from -> source or use the install script. +Docker images are published to Docker Hub as `phoenixsecdev/phoenix`. ```bash docker pull phoenixsecdev/phoenix:latest @@ -115,5 +222,7 @@ mapping to work, set `server.listen` to `0.0.0.0:9090` in the config. - [Getting Started](getting-started.md) - [Authentication](authentication.md) +- [Session Identity](session-identity.md) +- [Dashboard](dashboard.md) — operator UI deployment and security model - [Key Management](key-management.md) - [API Reference Index](api-reference-index.md) diff --git a/docs/dashboard.md b/docs/dashboard.md new file mode 100644 index 0000000..65f7912 --- /dev/null +++ b/docs/dashboard.md @@ -0,0 +1,268 @@ +# Phoenix — Operator Dashboard + +The operator dashboard is a browser-based web UI for managing approvals, +sessions, audit, and roles. It is designed for human operators — not agents. + +All HTML, CSS, JS, and SVG are embedded in the binary via `go:embed`. There +are no external dependencies, no CDN loads, and no build step. + +## When to use it + +- Approving or denying step-up requests from a phone or browser instead of CLI +- Reviewing active sessions and revoking compromised ones +- Tailing the audit log without SSH access to the server +- Inspecting role configuration at a glance + +The dashboard is optional. Everything it does can also be done via the CLI +(`phoenix approve`, `phoenix sessions list`, etc.) or the API. + +## Security model + +The dashboard is a **separate auth surface** from the Phoenix API. It does +not use bearer tokens, mTLS, or session tokens. Instead: + +- **Cookie auth**: HMAC-signed JSON payload with expiry, CSRF token, and + session nonce. `HttpOnly`, `SameSite=Strict`, and `Secure` (when TLS detected). +- **Password or PIN**: Password stored as bcrypt hash in config. PIN uses + constant-time comparison. Only one is needed. +- **Single active session**: By default, only one dashboard session exists + at a time. A new login is rejected if a session is already active. A + Force Login option re-authenticates and invalidates the prior session. + Configurable via `allow_multi_login`. +- **Rate limiting**: Exponential backoff per source IP after 5 failed + attempts (1s base, 60s cap). Rate-limited and failed attempts are + logged to the audit trail. +- **CSRF**: Every mutation (approve, deny, revoke, logout) requires a + token from the signed cookie. Validated with `hmac.Equal`. +- **No API proxy**: The dashboard reads stores directly via a `Deps` + struct. It never calls the `/v1/` API endpoints, so API auth + (bearer/mTLS) is not involved. + +### What the dashboard can do + +| Action | Risk level | Notes | +|--------|-----------|-------| +| View secrets count | Low | Count only, never values | +| View agent names | Low | Names only, no tokens | +| View active sessions | Medium | Exposes agent names, roles, IPs | +| Revoke sessions | Medium | Immediate effect, audited | +| View pending approvals | Medium | Exposes agent names, roles, IPs, namespaces | +| Approve step-up requests | **High** | Mints a session token for an agent | +| Deny step-up requests | Medium | Blocks an agent's session request | +| View audit log | Medium | Full access to audit trail | +| View role config | Low | Read-only | + +Approval is the highest-risk action. It runs the same safety checks as +the API (`approval.ValidateForMint`): role existence, step-up still +enabled, bootstrap trust, attestation requirements, and seal key. + +### What the dashboard cannot do + +- Read or write secret values +- Create or modify agents +- Issue or revoke certificates +- Change server configuration +- Modify ACLs or policies + +## Transport security + +The dashboard session cookie carries admin-equivalent access to session +and approval management. Protecting it in transit is critical. + +### Recommended: TLS reverse proxy + +The safest general deployment is behind a TLS-terminating reverse proxy +(Nginx, Caddy, NPM, Traefik): + +``` +Browser --[HTTPS]--> Reverse Proxy --[HTTP]--> Phoenix (127.0.0.1:9090) +``` + +The proxy sets `X-Forwarded-Proto: https`, which Phoenix detects to +enable the `Secure` cookie flag. The server itself can bind to loopback. + +### Alternative: mTLS mode + +If Phoenix runs with `auth.mtls.enabled: true`, TLS is native and the +cookie gets `Secure` automatically via `r.TLS != nil`. + +### Alternative: loopback only + +If `server.listen` is `127.0.0.1:9090` (the default), the dashboard is +only reachable from the server host itself. This is safe for local-only +operator access. Use SSH port forwarding for remote browser access: + +```bash +ssh -L 9090:127.0.0.1:9090 user@phoenix-host +# Then open http://127.0.0.1:9090/dashboard/ in your browser +``` + +### Not acceptable + +Do not expose the dashboard over plain HTTP on a network you do not +fully control. The session cookie would be transmitted in cleartext, +making session hijacking trivial. + +## Configuration + +Generate a password hash: + +```bash +phoenix-server --hash-password +# Enter dashboard password: **** +# Confirm dashboard password: **** +# $2a$10$... +``` + +Add the hash to your server config: + +```json +{ + "dashboard": { + "enabled": true, + "password_hash": "$2a$10$...", + "session_ttl": "4h" + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `enabled` | `bool` | Enable the dashboard at `/dashboard/` | +| `password_hash` | `string` | bcrypt hash of operator password | +| `pin` | `string` | Alternative: numeric or short PIN (constant-time compare) | +| `session_ttl` | `string` | Browser session lifetime (default `4h`) | +| `allow_multi_login` | `bool` | Allow concurrent dashboard sessions (default `false`) | + +Either `password_hash` or `pin` is required. Use `password_hash` for +production deployments. `pin` is acceptable for local/loopback-only use +where convenience matters more than brute-force resistance. + +By default, only one dashboard session is active at a time. A second +login attempt is rejected with a message to log out from the other +device first. If the other session is orphaned (browser closed without +logging out), a **Force Login** button appears that re-authenticates +and takes over, invalidating the old session. Set `allow_multi_login` +to `true` if concurrent operator sessions are needed. + +The password is stored as a bcrypt hash — the plaintext never appears +in config. Config validation rejects non-bcrypt values in `password_hash` +to prevent accidental plaintext storage. An agent reading the config file +sees only the hash, which cannot be used to authenticate. + +## Pages + +### Overview (`/dashboard/`) + +Status cards showing secrets count, agent count, active sessions, and +pending approvals. Server uptime and session-enabled status. Table of +the 10 most recent audit entries. + +### Approvals (`/dashboard/approvals`) + +Each pending approval is a card showing: role name, agent name, source +IP, bootstrap method, certificate fingerprint (truncated), namespaces, +requester TTY, created/expires timestamps with countdown. + +Approve and Deny buttons with JavaScript confirmation dialogs. Both +submit POST forms with CSRF tokens. + +Below: recently resolved approvals (collapsed by default) as a table +with status badges. + +### Sessions (`/dashboard/sessions`) + +Table of active sessions: ID (truncated), role, agent, source IP, +bootstrap method, created time, TTL remaining (color-coded), and a +Revoke button. + +Filter bar: role dropdown and agent text search. Filters are query +params handled server-side. + +### Audit (`/dashboard/audit`) + +Table of audit entries: timestamp, agent, action, path, status (green/red +dot), IP, session ID, reason. Filters for agent, status (allowed/denied), +and result limit (50/100/500). + +The audit page auto-refreshes every 30 seconds via client-side fetch. + +### Roles (`/dashboard/roles`) + +Read-only cards for each configured role: name, namespace pills, action +pills, bootstrap trust pills, and flags for step-up, seal key, max TTL, +and attestation requirements. + +## Audit trail + +All dashboard actions are logged to the same audit trail as API actions. + +| Action | Status | Agent | When | +|--------|--------|-------|------| +| `dashboard.login` | allowed | `dashboard@` | Successful login | +| `dashboard.login` | denied | `dashboard` | Failed login (invalid credentials) | +| `dashboard.login` | denied | `dashboard` | Rate-limited login attempt | +| `dashboard.login` | denied | `dashboard` | Blocked by active session | +| `dashboard.force_login` | allowed | `dashboard@` | Force login (took over existing session) | +| `dashboard.force_login` | denied | `dashboard` | Force login with wrong credentials | +| `dashboard.logout` | allowed | `dashboard@` | Explicit logout | +| `dashboard.auth` | denied | `dashboard` | Expired, invalid, or superseded cookie | +| `dashboard.csrf` | denied | `dashboard` | CSRF token mismatch on a POST action | +| `approval.approved` | allowed | `dashboard@` | Step-up request approved | +| `approval.denied` | allowed | `dashboard@` | Step-up request denied | +| `session.revoke` | allowed | `dashboard@` | Session revoked | + +Post-login actions use `dashboard@` for per-operator forensic +distinction. Pre-login events (failures, rate limiting, expired cookies) +use `dashboard` since there is no authenticated identity. + +Missing-cookie redirects (e.g., a first visit to `/dashboard/`) are not +logged — they are normal navigation, not security events. Only present- +but-invalid cookies generate `dashboard.auth` denied entries. + +## Deployment checklist + +Use this checklist when enabling the dashboard on an existing Phoenix server. + +1. Choose transport mode: + - [ ] TLS reverse proxy (recommended), or + - [ ] Native mTLS (`auth.mtls.enabled: true`), or + - [ ] Loopback only (`server.listen: "127.0.0.1:..."`) + +2. Choose credential mode: + - [ ] Password (recommended for production) + - [ ] PIN (acceptable for loopback-only) + +3. Generate password hash: `phoenix-server --hash-password` + +4. Add config: + ```json + { "dashboard": { "enabled": true, "password_hash": "$2a$10$..." } } + ``` + +5. Protect config file: `chmod 600 /data/phoenix/config.json` + +6. Restart server, verify log line: `Dashboard: enabled at /dashboard/` + +7. Open browser, verify login page loads at `/dashboard/login` + +8. Login, verify all pages render (overview, approvals, sessions, audit, roles) + +9. Test approval flow end-to-end if step-up roles are configured: + - Trigger a step-up mint (agent sends `POST /v1/session/mint` for a step-up role) + - Approve from dashboard + - Verify session appears in sessions list + - Verify audit entries for the approval + +10. Verify audit trail shows `dashboard@` entries + +11. From a different machine, verify the dashboard is **not** reachable + over plain HTTP (unless you explicitly chose loopback-only) + +## Related docs + +- [Configuration](configuration.md) — config field reference +- [Session Identity](session-identity.md) — step-up approval workflow +- [Authentication](authentication.md) — API auth model (separate from dashboard) +- [Threat Model](threat-model.md) — dashboard attack surface +- [LAN Deployment](lan-deployment.md) — multi-host setup diff --git a/docs/getting-started.md b/docs/getting-started.md index fcba92e..0f06b7e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,17 +52,17 @@ This generates: ### Deploying on a LAN? -The default server cert only covers `localhost` and `127.0.0.1`. If other machines need to reach this server, you need a cert that includes the server's LAN IP or hostname. +The default config listens on `127.0.0.1` (loopback only) over plain HTTP. +If other machines need to reach this server: -```bash -# Edit config.json: set server.listen to "0.0.0.0:9090" -# Then re-issue a server cert with the correct SANs: -phoenix cert issue phoenix-server -o /data/phoenix/ -``` +1. Set `server.listen` to `0.0.0.0:9090` and `auth.mtls.enabled` to `true` +2. Re-issue the server cert with your LAN IP: + ```bash + phoenix-server --reissue-cert --san 192.168.1.10 --config /data/phoenix/config.json + ``` +3. Restart the server -Or put Phoenix behind a reverse proxy that terminates TLS. - -For full multi-host deployment, see [LAN Deployment](lan-deployment.md). +For the full multi-host setup walkthrough, see [LAN Deployment](lan-deployment.md). ## Verify file permissions @@ -77,40 +77,70 @@ chmod 600 /data/phoenix/master.key /data/phoenix/ca.key /data/phoenix/server.key ./bin/phoenix-server --config /data/phoenix/config.json ``` -Example startup output: +Example startup output (default config, plain HTTP on loopback): ```text Phoenix server starting on 127.0.0.1:9090 Store: /data/phoenix/store.json (0 secrets) - Key provider: file ACL: /data/phoenix/acl.json (1 agents) Audit: /data/phoenix/audit.log - mTLS: enabled (require=false) - Bearer: true ``` > **Binding to all interfaces:** The default listen address is `127.0.0.1:9090`. -> To accept connections from other hosts, set `server.listen` to `0.0.0.0:9090`. -> Only do this with authentication and network controls in place. +> To accept connections from other hosts, enable TLS and set `server.listen` +> to `0.0.0.0:9090`. See [LAN Deployment](lan-deployment.md). ## First secret operations ```bash -export PHOENIX_SERVER="https://localhost:9090" +export PHOENIX_SERVER="http://127.0.0.1:9090" export PHOENIX_TOKEN="" -export PHOENIX_CA_CERT="/data/phoenix/ca.crt" phoenix set myapp/api-key -v "sk-live-abc123" -d "Stripe API key" phoenix get myapp/api-key ``` +> **Note:** The default config starts the server on plain HTTP at `127.0.0.1:9090`. +> This is safe for local-only use — the server is not reachable from the network. +> For LAN or production deployment, enable TLS first. See [LAN Deployment](lan-deployment.md). + For the full command reference (`set`, `get`, `list`, `delete`, `resolve`, `exec`, `import`, `export`, `audit`), see [CLI Usage](cli-usage.md). +## Enable the operator dashboard (optional) + +The dashboard provides a browser-based UI for approvals, sessions, audit, and +roles. It is especially useful for step-up approval workflows where a human +needs to approve agent access from a phone or browser. + +Generate a password hash and add to your config: + +```bash +phoenix-server --hash-password +``` + +```json +{ + "dashboard": { + "enabled": true, + "password_hash": "$2a$10$..." + } +} +``` + +After restarting, open `http://127.0.0.1:9090/dashboard/` in a browser. + +**Important:** The dashboard is safe over plain HTTP only when the server +listens on loopback (`127.0.0.1`). If the server is reachable over a +network, put it behind a TLS reverse proxy or enable mTLS first. Do not +serve the dashboard over plain HTTP on a network you do not control. +See [Dashboard](dashboard.md) for the full deployment guide and security model. + ## Next steps - [CLI Usage](cli-usage.md) — full command reference - [Authentication](authentication.md) — bearer tokens, mTLS, sealed key pairs +- [Dashboard](dashboard.md) — browser-based operator UI - [Policy and Attestation](policy-and-attestation.md) — per-path security rules - [LAN Deployment](lan-deployment.md) — multi-host setup - [Configuration](configuration.md) — server config reference and Docker diff --git a/docs/integrations.md b/docs/integrations.md index ef3d29c..9c0b8a4 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -45,9 +45,15 @@ Tool identity headers let policy control which MCP tools may access which paths. Phoenix includes a reusable skill at `phoenix-skill/SKILL.md` for command-driven integration without running MCP mode. -## OpenClaw exec backend +## OpenClaw -Configure Phoenix as an external secrets provider through OpenClaw's exec backend: +OpenClaw's own threat model flags plaintext credential storage and credential +harvesting by skills as open, high-severity risks. Phoenix closes that gap +through the exec backend: OpenClaw resolves secrets at runtime via `phoenix resolve`, +so raw values never appear in OpenClaw's environment, config files, or skill +execution context. + +### Exec backend config ```json { @@ -63,7 +69,7 @@ Configure Phoenix as an external secrets provider through OpenClaw's exec backen } ``` -Use SecretRefs backed by Phoenix: +Use SecretRefs backed by Phoenix in your agent config: ```yaml api_keys: @@ -89,12 +95,86 @@ phoenix verify --dry-run gateway-config.yaml phoenix policy test --agent openclaw --ip 10.0.0.5 myapp/openai-key ``` -## Python SDK +### Containerized deployment (Docker Compose) -```bash -pip install phoenix-secrets +In a Docker or Compose setup, Phoenix runs as a sidecar or network-adjacent service. + +```yaml +services: + phoenix: + image: phoenixsecdev/phoenix:latest + volumes: + - phoenix-data:/data/phoenix + # No host port needed — OpenClaw reaches Phoenix via + # the Compose network using the service name "phoenix" + + openclaw: + image: ghcr.io/openclaw/openclaw:latest + environment: + PHOENIX_SERVER: "http://phoenix:9090" + PHOENIX_TOKEN: "${OPENCLAW_PHOENIX_TOKEN}" + volumes: + - ./openclaw-config:/config + depends_on: + - phoenix + +volumes: + phoenix-data: ``` +For mTLS instead of bearer tokens, mount certs into the OpenClaw container +and set `PHOENIX_CA_CERT`, `PHOENIX_CLIENT_CERT`, `PHOENIX_CLIENT_KEY`. + +### Current limitations + +The exec backend integration works today but requires operator setup: +the Phoenix CLI binary must be available in the OpenClaw container's PATH, and +credentials (token or mTLS certs) must be configured for the OpenClaw process. +A dedicated OpenClaw plugin that handles this automatically is in active +development. Until then, the exec backend is the supported path — fully +functional, but requires explicit wiring in your Compose or deployment +config rather than a one-line plugin install. + +## Go SDK + +The Go SDK is included in the repository at `sdk/go/phoenix/`. + +```go +import "github.com/phoenixsec/phoenix/sdk/go/phoenix" + +// Basic client +client := phoenix.New("https://phoenix:9090", "token") +val, err := client.Resolve("phoenix://myapp/api-key") +vals, err := client.ResolveBatch([]string{ + "phoenix://myapp/openai-key", + "phoenix://myapp/db-password", +}) + +// Session identity +client, err := phoenix.NewWithRole("https://phoenix:9090", "bootstrap-token", "dev") +// Auto-mints a session; auto-renews before expiry + +// Sealed mode +client.SetSealKey("/path/to/agent.seal.key") + +// Session management +sessions, _ := client.ListSessions() +client.RevokeSession("ses_abc123") + +// Error classification +var perr *phoenix.Error +if errors.As(err, &perr) { + if perr.IsSessionExpired() { /* re-mint */ } + if perr.IsScopeExceeded() { /* wrong role */ } + if perr.IsApprovalRequired() { /* needs human */ } +} +``` + +## Python SDK + +> **Note:** The `phoenix-secrets` package is not yet published to PyPI. +> For now, use the source at `sdk/python/` or call the API directly. + ```python from phoenix_secrets import PhoenixClient @@ -111,12 +191,12 @@ client.health() ## Direct API ```bash -curl -X POST https://phoenix:9090/v1/resolve \ - -H "Authorization: Bearer $TOKEN" \ +curl -X POST $PHOENIX_SERVER/v1/resolve \ + -H "Authorization: Bearer $PHOENIX_TOKEN" \ -d '{"refs": ["phoenix://myapp/api-key"]}' -curl https://phoenix:9090/v1/secrets/myapp/api-key \ - -H "Authorization: Bearer $TOKEN" +curl $PHOENIX_SERVER/v1/secrets/myapp/api-key \ + -H "Authorization: Bearer $PHOENIX_TOKEN" ``` ## Related docs diff --git a/docs/lan-deployment.md b/docs/lan-deployment.md index 302696d..818e759 100644 --- a/docs/lan-deployment.md +++ b/docs/lan-deployment.md @@ -39,36 +39,59 @@ phoenix-server --init /data/phoenix Save the admin token. You will need it for the next steps. -### Bind to the LAN interface +### Configure for LAN access -Edit `/data/phoenix/config.json`: +Edit `/data/phoenix/config.json` to bind to all interfaces and enable TLS: ```json { "server": { "listen": "0.0.0.0:9090" + }, + "auth": { + "bearer": { + "enabled": true + }, + "mtls": { + "enabled": true, + "require": false + } } } ``` -### Issue a server cert with LAN SANs +Setting `auth.mtls.enabled: true` activates TLS using the server certificate +generated during init. Setting `require: false` means client certs are optional +at this stage — agents can still authenticate with bearer tokens. You can +tighten this later with `"require": true` once all agents have certs. + +### Re-issue the server certificate with LAN SANs -The default cert only covers `localhost`. Re-issue it with the server's LAN IP -and any hostnames clients will use: +The default server cert generated by `--init` only covers `localhost` and +`127.0.0.1`. If clients connect using the server's LAN IP or a hostname, +they will get a TLS error. Re-issue the cert with the correct SANs: ```bash -phoenix cert issue phoenix-server \ +phoenix-server --reissue-cert \ --san 192.168.1.10 \ --san phoenix.internal \ - -o /data/phoenix/ + --config /data/phoenix/config.json ``` +This loads the existing CA, generates a new server certificate covering +`localhost`, `127.0.0.1`, and all specified `--san` values, and writes it +to the paths in the config. You can add as many `--san` flags as needed +(IPs and hostnames are both supported). + ### Start the server ```bash phoenix-server --config /data/phoenix/config.json ``` +You should see `TLS: enabled` and `mTLS: enabled (require=false)` in the +startup output. + ## 2. Create agent identities For each client machine or agent, create an identity with scoped ACLs. Still on @@ -208,7 +231,39 @@ to the server config, and restart. See [Policy and Attestation](policy-and-attestation.md) for the full reference on available policy fields and testing with `phoenix policy test`. -## 8. Verify the deployment +## 8. Enable operator dashboard (optional) + +If you want browser-based approval and session management, enable the dashboard. +Since this is a LAN deployment with the server on `0.0.0.0`, you **must** use +TLS — either native mTLS (already configured above) or a reverse proxy. + +Generate a password hash and add to the server config: + +```bash +phoenix-server --hash-password +``` + +```json +{ + "dashboard": { + "enabled": true, + "password_hash": "$2a$10$..." + } +} +``` + +Restart the server. With mTLS enabled, the `Secure` cookie flag is set +automatically. Access the dashboard at `https://192.168.1.10:9090/dashboard/`. + +If you are using a reverse proxy instead of native mTLS, ensure the proxy +sets `X-Forwarded-Proto: https` so Phoenix enables the `Secure` flag. + +**Do not** enable the dashboard on a LAN deployment without TLS. The session +cookie would be transmitted in cleartext on the network. + +See [Dashboard](dashboard.md) for the full security model and deployment checklist. + +## 9. Verify the deployment From each client machine: @@ -253,6 +308,7 @@ phoenix audit --last 20 - [Getting Started](getting-started.md) - [Authentication](authentication.md) +- [Dashboard](dashboard.md) — browser-based operator UI - [Multi-Agent Setup](multi-agent-setup.md) (same-host multi-agent isolation) - [Policy and Attestation](policy-and-attestation.md) - [Configuration](configuration.md) diff --git a/docs/migration-env-to-phoenix.md b/docs/migration-env-to-phoenix.md index 24d95d3..e40e4ed 100644 --- a/docs/migration-env-to-phoenix.md +++ b/docs/migration-env-to-phoenix.md @@ -83,7 +83,7 @@ phoenix exec \ If your agent platform uses MCP over HTTP instead of local stdio, run Phoenix MCP in Streamable HTTP mode: ```bash -export PHOENIX_SERVER="https://localhost:9090" +export PHOENIX_SERVER="https://phoenix:9090" export PHOENIX_TOKEN="" export PHOENIX_MCP_TOKEN="" phoenix mcp-server --http 127.0.0.1:8080 diff --git a/docs/reference-only-enforcement-design.md b/docs/reference-only-enforcement-design.md index 346cef8..20d5e78 100644 --- a/docs/reference-only-enforcement-design.md +++ b/docs/reference-only-enforcement-design.md @@ -1,6 +1,7 @@ -# Reference-Only Enforcement Design (WIP) +# Reference-Only Enforcement Design (RFC) -Status: draft design for policy-driven controls that restrict plaintext-return paths. +Status: design RFC for planned policy-driven controls that restrict plaintext-return paths. +This feature is not yet implemented. See [Roadmap](roadmap.md) for timeline. ## Problem diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 7345405..dc7bc12 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -5,7 +5,7 @@ - Clean working tree on `main` - CI passing - Version string correct (`internal/version/version.go`) -- Confirm release tag (example: `v0.10.3`) +- Confirm release tag (example: `v0.13.3`) ## 2) Tag and Push diff --git a/docs/roadmap.md b/docs/roadmap.md index d9a25c5..785d9e8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -3,44 +3,56 @@ > This roadmap is directional and may change based on user feedback. > It is not a contractual delivery commitment. -## In Progress - -### 1) Reference-Only Enforcement (Agent Context Design Mode) - -Primary near-term patch: - -- enforce the reference-only design in Agent Context Design mode -- remove behavior/documentation drift so runtime behavior and docs match -- preserve explicit, auditable secret-resolution boundaries -- update examples/skill guidance to prefer `phoenix exec` reference workflows by default +## Shipped (v0.13) + +- Encrypted secret storage (AES-256-GCM envelope encryption) +- Per-agent ACL with namespace isolation and glob matching +- Bearer token and mTLS authentication +- Sealed responses (per-agent X25519 transport encryption) +- MCP server — stdio and Streamable HTTP transports +- `phoenix exec` with credential stripping +- Attestation policy engine (mTLS, IP binding, cert fingerprint, sealed requirements) +- Session identity — role-based, short-lived session tokens with auto-renewal +- Step-up approval — human-in-the-loop gating for sensitive roles +- Operator dashboard — browser-based approval, session, audit, and role management +- Go SDK with session support +- 1Password broker backend (read-only) +- Import from `.env` files and 1Password +- Emergency offline access (break-glass) +- Master key passphrase protection and rotation +- Internal CA with agent certificate lifecycle and CRL ## Planned (Near-Term) -### 2) HTTP MCP Server Integration +### Reference-Only Enforcement + +Policy-driven controls to restrict plaintext-return paths per identity and path. +Operators will be able to deny `get` and `resolve` operations for selected +namespaces, pushing agents toward `phoenix exec` as the only consumption path. -Planned for the upcoming release cycle: +See `docs/reference-only-enforcement-design.md` for the design RFC. -- merge the completed HTTP MCP server patch -- include docs and release notes for the new MCP transport path -- keep existing local MCP workflows supported +### Server Certificate SAN Management -### 3) Web UI (Read-Only First) +CLI support for re-issuing the server TLS certificate with custom SANs +(LAN IPs, hostnames), simplifying multi-host deployment without external +certificate tooling. -Initial direction for UI work: +### SDK Publishing -- start with read-only visibility (inventory, audit timeline, status) -- keep secret mutation flows in CLI/API paths for strong auditability -- expand capabilities after validating user demand and threat model fit +Python (`phoenix-secrets`) and TypeScript (`phoenix-secrets`) SDK packages +on PyPI and npm respectively. ## Exploring (Post-Launch) These are under consideration and will be prioritized by real-world usage: -- multi-user/team workflows -- advanced policy controls and simulation -- rotation automation and lifecycle UX improvements -- enterprise deployment/distribution options +- Multi-user/team workflows with per-operator identity +- Rotation automation and lifecycle UX improvements +- Advanced policy simulation and dry-run tooling +- Additional secret backends (Bitwarden, AWS Secrets Manager) +- Enterprise deployment and distribution options --- -Last updated: 2026-03-02 +Last updated: 2026-03-19 diff --git a/docs/sealed-responses.md b/docs/sealed-responses.md index 641688c..de6470b 100644 --- a/docs/sealed-responses.md +++ b/docs/sealed-responses.md @@ -106,9 +106,9 @@ val, err := client.Resolve("phoenix://myapp/api-key") ``` **Python:** -```bash -pip install phoenix-secrets[sealed] # installs PyNaCl -``` + +> `phoenix-secrets` is not yet published to PyPI. Use the source SDK +> at `sdk/python/` or the API directly for now. ```python from phoenix_secrets import PhoenixClient @@ -120,9 +120,9 @@ val = client.resolve("phoenix://myapp/api-key") ``` **TypeScript:** -```bash -npm install phoenix-secrets # tweetnacl is an optional dependency -``` + +> `phoenix-secrets` is not yet published to npm. Use the source SDK +> at `sdk/typescript/` or the API directly for now. ```javascript const { PhoenixClient } = require("phoenix-secrets"); diff --git a/docs/session-identity.md b/docs/session-identity.md new file mode 100644 index 0000000..d48ce22 --- /dev/null +++ b/docs/session-identity.md @@ -0,0 +1,279 @@ +# Phoenix — Session Identity + +Session identity replaces static bearer tokens with short-lived, scoped session +tokens for agent access. Agents authenticate once (bootstrap), receive a session +token bound to a named role, and use that token for all subsequent requests. + +## Concepts + +**Roles** define what an agent session can do. Each role specifies: +- Which namespaces the session can access +- Which actions are allowed (list, read, write, delete, admin) +- How the agent must authenticate to mint a session (bootstrap trust) +- Whether human approval is required before the session is granted + +**Sessions** are server-issued credentials with a fixed lifetime (default 1h). +They carry the role's scope and are bound to the minting agent's identity. +Sessions auto-renew if attestation still holds and can be explicitly revoked. + +**Bootstrap trust** determines which authentication methods can mint sessions +for a given role. Options: `bearer`, `mtls`, `local` (loopback), `token` +(short-lived attestation token). + +## Configuration + +Enable sessions in your server config: + +```json +{ + "session": { + "enabled": true, + "ttl": "1h", + "roles": { + "dev": { + "namespaces": ["dev/*", "staging/*"], + "actions": ["list", "read_value"], + "bootstrap_trust": ["bearer"] + }, + "deploy": { + "namespaces": ["prod/*"], + "actions": ["list", "read_value"], + "bootstrap_trust": ["mtls", "bearer"], + "require_seal_key": true, + "step_up": true, + "step_up_ttl": "15m" + } + } + } +} +``` + +### Role fields + +| Field | Type | Description | +|-------|------|-------------| +| `namespaces` | `[]string` | Required. Glob patterns for accessible paths (`dev/*`, `prod/**`) | +| `actions` | `[]string` | Allowed actions. Default: `["list", "read_value"]`. Options: `list`, `read_value`, `write`, `delete`, `admin` | +| `bootstrap_trust` | `[]string` | Required. Auth methods that can mint this role: `bearer`, `mtls`, `local`, `token` | +| `require_seal_key` | `bool` | Require seal public key at mint time | +| `max_ttl` | `string` | Per-role TTL override (e.g. `"30m"`) | +| `attestation` | `[]string` | Attestation requirements (e.g. `["source_ip", "cert_fingerprint"]`) | +| `step_up` | `bool` | Require human approval before minting | +| `step_up_ttl` | `string` | TTL for step-up sessions (e.g. `"15m"`) | + +## Agent bootstrap + +Set `PHOENIX_ROLE` to automatically mint a session on first request: + +```bash +export PHOENIX_TOKEN="bootstrap-token" +export PHOENIX_ROLE="dev" +phoenix get dev/api-key # auto-mints session, then reads secret +``` + +The bootstrap token is used once for minting. All subsequent requests use +the scoped session token. The CLI caches the session token locally and +auto-renews before expiry. + +## Session lifecycle + +### Minting + +``` +POST /v1/session/mint {"role": "dev"} +``` + +The server checks bootstrap trust, attestation, and role existence. +Returns a session token (`phxs_...` prefix) with role scope and expiry. + +### Renewal + +Sessions auto-renew when nearing expiry. On renewal, the server rechecks +bootstrap trust and attestation. If anything changed (cert expired, role +config updated), renewal fails and the agent must re-mint. + +### Revocation + +```bash +phoenix sessions revoke ses_abc123def456 +``` + +Takes effect immediately. The revoked token is rejected on the next request. + +### Expiry + +Sessions expire after their TTL. Expired tokens receive a structured +`SESSION_EXPIRED` denial with a hint to re-mint. + +## Step-up approval + +Roles with `step_up: true` require human approval before the session is granted. + +```bash +# Agent's request returns approval_required: +# "Run: phoenix approve apr_xyz789" + +# Human approves from another terminal: +phoenix approve apr_xyz789 +``` + +The approval request includes role, agent identity, and expiry. The human +sees full context before approving. Approvals expire if not acted on. + +### Dashboard approval + +When the dashboard is enabled, approvals can also be managed from the browser +at `/dashboard/approvals`. Each pending approval is shown as a card with full +context (role, agent, source IP, bootstrap method, namespaces, TTY, expiry +countdown). Approve or deny with one click. The same safety checks apply — +role config, bootstrap trust, attestation, and seal key are all re-verified +at approve time. + +## CLI commands + +```bash +# List active sessions (admin sees all, agents see own) +phoenix sessions list +phoenix sessions list --role dev --agent deployer + +# Show session details +phoenix sessions info ses_abc123def456 + +# Revoke a session +phoenix sessions revoke ses_abc123def456 + +# Approve a step-up request +phoenix approve apr_xyz789 +``` + +## SDK usage + +```go +// Create a client with automatic session minting +client, err := phoenix.NewWithRole(server, token, "dev") + +// Or mint manually +client := phoenix.New(server, token) +err := client.MintSession("dev") + +// Session operations +sessions, _ := client.ListSessions() +client.RevokeSession("ses_abc123") + +// Error classification +var perr *phoenix.Error +if errors.As(err, &perr) { + if perr.IsSessionExpired() { /* re-mint */ } + if perr.IsSessionRevoked() { /* session was killed */ } + if perr.IsScopeExceeded() { /* wrong role for this path */ } + if perr.IsApprovalRequired() { /* needs human approval */ } +} +``` + +Auto-renewal happens transparently when the session is within 5 minutes of expiry. + +## MCP integration + +Set `PHOENIX_ROLE` in your MCP server config: + +```json +{ + "mcpServers": { + "phoenix": { + "command": "phoenix", + "args": ["mcp-server"], + "env": { + "PHOENIX_SERVER": "https://phoenix.home:9090", + "PHOENIX_TOKEN": "bootstrap-token", + "PHOENIX_ROLE": "dev" + } + } + } +} +``` + +The MCP server auto-mints a session on startup and renews in the background. + +Available MCP tools: +- `phoenix_session_list` — list active sessions +- `phoenix_session_revoke` — revoke a session by ID + +When an MCP tool hits `APPROVAL_REQUIRED`, it returns a message the agent +can present to the human with the approve command. + +## Denial codes + +When a session request or scoped access is denied, the response includes a +machine-readable code: + +| Code | Meaning | +|------|---------| +| `SESSION_EXPIRED` | Session TTL elapsed, re-mint needed | +| `SESSION_REVOKED` | Session was explicitly revoked | +| `SESSION_INVALID` | Token is malformed or signature invalid | +| `SCOPE_EXCEEDED` | Path is outside the role's namespace scope | +| `ACTION_DENIED` | Action not permitted by the role | +| `APPROVAL_REQUIRED` | Step-up approval needed | +| `BOOTSTRAP_FAILED` | Auth method not in role's bootstrap_trust | +| `ROLE_NOT_FOUND` | Requested role does not exist | +| `ATTESTATION_FAILED` | Missing or insufficient attestation | +| `SEAL_KEY_REQUIRED` | Role requires a seal key but none was provided | +| `SEAL_KEY_MISMATCH` | Seal key doesn't match session binding | + +## Audit events + +All session lifecycle events are logged to the audit trail: + +| Action | Status | When | +|--------|--------|------| +| `session.mint.approved` | allowed | Session minted successfully | +| `session.mint` | denied | Mint failed (role_not_found, bootstrap_failed, attestation_failed) | +| `session.renewed` | allowed | Session renewed | +| `session.renew` | denied | Renewal failed | +| `session.revoke` | allowed | Session revoked | +| `session.auth` | denied | Expired or revoked token used for a request | +| `session.list` | allowed | Sessions listed | +| `session.info` | allowed | Session details viewed | +| `approval.created` | allowed | Step-up approval request created | +| `approval.approved` | allowed | Approval granted, session minted | +| `approval.denied` | allowed | Approval explicitly denied | +| `approval.approve` | denied | Approval re-check failed at approve time | + +## Access control + +- **Session tokens** can only inspect/revoke their own exact session +- **Bearer/mTLS callers** can see/revoke sessions belonging to their agent +- **Admins** (ACL `admin` on `sessions`) can see/revoke all sessions + +Session tokens never escalate to admin-level session management, even if +the underlying agent has admin ACL permissions. + +### Role scope vs ACL + +Session scope and ACL permissions are both checked on every request. A +session scoped to `dev/*` cannot access `prod/*` even if the underlying +agent's ACL grants `prod/*:read`. Access requires passing **both** the +role's namespace scope and the agent's ACL check. + +## Operational notes + +### In-memory state + +Active sessions and pending approvals are held in server memory. A server +restart clears all sessions and pending approvals. Agents with `PHOENIX_ROLE` +set will auto-mint a new session on their next request, so this is +transparent for most workflows. Step-up approvals that were pending at +restart time must be re-initiated. + +This is a deliberate design choice — it avoids persisting bearer-equivalent +tokens to disk and keeps the server stateless for simple deployments. +Persistent session storage may be added in a future release if demand +warrants it. + +## Related docs + +- [Authentication](authentication.md) +- [Configuration](configuration.md) +- [Dashboard](dashboard.md) — browser-based approval and session management +- [CLI Usage](cli-usage.md) +- [API Reference](api-reference-index.md) diff --git a/docs/threat-model.md b/docs/threat-model.md index a4a22ec..7afb86a 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -21,34 +21,130 @@ This document describes the core threats Phoenix is designed to mitigate, the tr - Secret values - Master key / wrapped DEKs -- Agent credentials (bearer tokens, mTLS certs, short-lived tokens) +- Agent credentials (bearer tokens, mTLS certs, short-lived tokens, session tokens) - ACL and attestation policy config - Audit log integrity +- Dashboard session cookies (when dashboard is enabled) ## Trust boundaries -1. **Client/agent process** → **Phoenix API** +1. **Client/agent process** → **Phoenix API** Authentication, ACL checks, and attestation are enforced here. -2. **Phoenix API** → **Secret backend** +2. **Phoenix API** → **Secret backend** File backend or external backend reads are mediated by Phoenix policy. -3. **Phoenix runtime** → **Storage and key files** +3. **Phoenix runtime** → **Storage and key files** File permissions and host hardening matter for confidentiality. +4. **Browser** → **Dashboard** (when enabled) + Cookie-based auth with separate credential. See "Dashboard attack surface" below. ## Threats Phoenix addresses -- **Prompt-injection-induced secret exfiltration** +- **Prompt-injection-induced secret exfiltration** Reference-first workflows (`phoenix://` + policy-gated resolution) reduce plaintext exposure in config/prompt paths. -- **Unauthorized cross-agent access** +- **Unauthorized cross-agent access** Per-agent ACL path rules enforce least privilege. -- **Credential replay / misuse** - mTLS, IP binding, cert pinning, nonce challenge, and short-lived tokens reduce replay value. -- **Operational leakage** +- **Credential replay / misuse** + mTLS, IP binding, cert pinning, nonce challenge, and short-lived tokens reduce replay value. Session tokens are scoped, time-limited, and individually revocable. +- **Operational leakage** Structured audit logs record allows/denies without writing secret values. -- **At-rest theft of store data** +- **At-rest theft of store data** AES-256-GCM envelope encryption protects persisted values. -- **Rotation failure corruption** +- **Rotation failure corruption** Two-phase key rotation + backup path supports recovery. +## Session identity threats + +Session tokens (`phxs_` prefix) introduce additional attack surface: + +- **Token theft**: Session tokens are bearer-like. If leaked, an attacker gains the + role's scope until expiry or revocation. Mitigated by short TTLs (default 1h), + per-session revocation, and binding to the minting agent's identity. +- **Privilege escalation via role config change**: Roles are re-checked at renewal + time. A role config change that widens scope does not retroactively expand active + sessions — the session keeps the scope it was minted with. +- **Step-up bypass**: Step-up approval is enforced server-side at mint time. An agent + cannot mint a step-up role without human approval. The approval re-checks role + config, bootstrap trust, attestation, and seal key requirements at approve time + to prevent stale-config attacks. +- **Session token on admin endpoints**: Session tokens are explicitly rejected for + admin operations (agent management, cert issuance, approval, revocation by other + agents). This prevents a scoped session from escalating to admin access even if + the underlying agent has admin ACL. + +## Dashboard attack surface + +When `dashboard.enabled` is `true`, the server exposes a browser-accessible admin +interface at `/dashboard/`. This is a materially different attack surface from the +API and must be evaluated separately. + +### What the dashboard exposes + +The dashboard can: view secret/agent/session counts, view and revoke active sessions, +approve and deny step-up requests (minting session tokens), view the full audit log, +and view role configuration. + +The dashboard **cannot**: read or write secret values, create or modify agents, issue +or revoke certificates, or change server configuration. + +Approval is the highest-risk action — it mints a session token for an agent. + +### Authentication model + +The dashboard uses a **separate auth surface** from the API: + +- Single shared password (bcrypt hash in config) or PIN (constant-time compare) +- Cookie-based sessions with HMAC-SHA256 signed payloads and per-session nonce +- Single active session by default (configurable via `allow_multi_login`) +- Force Login as escape hatch for orphaned sessions (re-authenticates, invalidates prior) +- CSRF tokens embedded in the cookie and validated on every POST (including logout) +- Exponential backoff rate limiting per source IP (5 attempts, then 1s–60s delay) +- Full audit trail: login success/failure, force login, logout, expired/superseded + cookie rejection, CSRF failures, and all mutations (approve, deny, revoke) + +This is simpler than the API's auth model by design: the dashboard targets human +operators, not programmatic access. The trade-off is that all dashboard users share +one credential and are distinguished only by source IP in the audit trail +(`dashboard@`). + +### Threats specific to the dashboard + +| Threat | Mitigation | Residual risk | +|--------|-----------|---------------| +| Brute-force login | Rate limiting with exponential backoff, audit logging of failures | PIN mode has smaller keyspace; use password for non-loopback deployments | +| Session cookie theft (network) | `Secure` flag set when TLS detected; `HttpOnly` prevents JS access; `SameSite=Strict` prevents CSRF via cross-origin requests | Plain HTTP on a shared network exposes the cookie — **do not do this** | +| Session cookie theft (XSS) | `HttpOnly` flag; no user-supplied content rendered unescaped; embedded static assets (no CDN/external JS) | XSS in Go `html/template` is unlikely but not impossible if templates are modified | +| CSRF | Token-per-session in signed cookie, validated on all POST actions | Relies on `SameSite=Strict` + token check | +| Unauthorized approval | Same `ValidateForMint` safety checks as the API; role, bootstrap, attestation, and seal key all re-verified | A compromised dashboard session can approve any pending request | +| Shared credential | All operators share one password/PIN; no per-user identity | Forensic distinction limited to source IP (`dashboard@`) | +| Config file credential exposure | Password stored as bcrypt hash in config (`password_hash`); plaintext rejected by validation; agent reading config gets only the hash | PIN mode stores plaintext PIN; use `password_hash` for non-loopback deployments | + +### Deployment requirements + +The dashboard **must** be deployed behind one of: + +1. **TLS reverse proxy** — proxy terminates TLS, sets `X-Forwarded-Proto: https`, + Phoenix detects this and sets `Secure` on the cookie. The server itself can + bind to loopback. This is the recommended approach. + +2. **Native mTLS** — `auth.mtls.enabled: true`. TLS is native, cookie gets `Secure` + via `r.TLS != nil`. + +3. **Loopback only** — `server.listen: "127.0.0.1:..."`. Dashboard is unreachable + from the network. Use SSH port forwarding for remote browser access. + +Exposing the dashboard over plain HTTP on a network you do not fully control is +**not acceptable**. The session cookie would transit in cleartext. + +### When to disable the dashboard + +Disable the dashboard (`"enabled": false`) when: + +- You have no step-up approval roles (the primary use case for browser access) +- All operators have CLI/SSH access to the server +- You want to minimize the server's attack surface +- You are running in a high-security environment where a shared-credential + browser surface is not acceptable + ## Attestation controls (defense in depth) Per-path policy can require combinations of: @@ -83,6 +179,7 @@ For the full sealed responses design, wire format, policy controls - Hardware side-channel attacks - Physical extraction from an already-compromised host - Full enterprise HSM/KMS assurance model +- Per-operator identity on the dashboard (shared credential model) ## Recommended operational controls @@ -91,3 +188,7 @@ For the full sealed responses design, wire format, policy controls - Enable nonce + short-lived tokens in higher-risk deployments. - Store data/key/audit files on restricted paths with strict permissions. - Monitor audit logs for denied access bursts and unusual path access. +- If the dashboard is enabled: use TLS, use a strong password (not PIN), + monitor for `dashboard.login` denied events in the audit trail, and + restrict network access to the server to trusted hosts. +- Disable the dashboard when it is not needed. diff --git a/internal/api/api.go b/internal/api/api.go index 0f65079..6f2aae4 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -19,12 +19,15 @@ import ( "time" "github.com/phoenixsec/phoenix/internal/acl" + "github.com/phoenixsec/phoenix/internal/approval" "github.com/phoenixsec/phoenix/internal/audit" "github.com/phoenixsec/phoenix/internal/ca" + "github.com/phoenixsec/phoenix/internal/config" "github.com/phoenixsec/phoenix/internal/crypto" "github.com/phoenixsec/phoenix/internal/nonce" "github.com/phoenixsec/phoenix/internal/policy" "github.com/phoenixsec/phoenix/internal/ref" + "github.com/phoenixsec/phoenix/internal/session" "github.com/phoenixsec/phoenix/internal/store" "github.com/phoenixsec/phoenix/internal/token" ) @@ -119,6 +122,9 @@ type Server struct { tokens *token.Issuer // nil when short-lived tokens are not configured startTime time.Time // server start time for uptime reporting authRL *rateLimiter + sessions *session.Store // nil when sessions are not configured + sessionRoles map[string]config.RoleConfig // nil when sessions are not configured + approvals *approval.Store // nil when step-up not configured mux *http.ServeMux } @@ -186,6 +192,29 @@ func (s *Server) SetTokenIssuer(ti *token.Issuer) { s.tokens = ti } +// SetSessionStore configures the session identity store. +func (s *Server) SetSessionStore(ss *session.Store) { + s.sessions = ss +} + +// SetSessionRoles configures the role definitions for session minting. +func (s *Server) SetSessionRoles(roles map[string]config.RoleConfig) { + s.sessionRoles = roles +} + +// SetApprovalStore configures the step-up approval store. +func (s *Server) SetApprovalStore(as *approval.Store) { + s.approvals = as +} + +// SetDashboard registers the dashboard handler under /dashboard/. +func (s *Server) SetDashboard(h http.Handler) { + s.mux.Handle("/dashboard/", h) + s.mux.HandleFunc("GET /dashboard", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/dashboard/", http.StatusMovedPermanently) + }) +} + // ServeHTTP implements http.Handler. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) @@ -199,6 +228,7 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /v1/audit", s.handleAuditQuery) s.mux.HandleFunc("POST /v1/agents", s.handleCreateAgent) s.mux.HandleFunc("GET /v1/agents", s.handleListAgents) + s.mux.HandleFunc("DELETE /v1/agents/", s.handleDeleteAgent) s.mux.HandleFunc("POST /v1/certs/issue", s.handleIssueCert) s.mux.HandleFunc("POST /v1/certs/revoke", s.handleRevokeCert) s.mux.HandleFunc("POST /v1/rotate-master", s.handleRotateMaster) @@ -210,6 +240,12 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /v1/keypair", s.handleGenerateKeyPair) s.mux.HandleFunc("GET /v1/agents/", s.handleAgentSubresource) s.mux.HandleFunc("GET /v1/policy/check", s.handlePolicyCheck) + s.mux.HandleFunc("POST /v1/session/mint", s.handleSessionMint) + s.mux.HandleFunc("POST /v1/session/renew", s.handleSessionRenew) + s.mux.HandleFunc("GET /v1/approvals", s.handleApprovalList) + s.mux.HandleFunc("/v1/approval/", s.handleApprovalRouter) + s.mux.HandleFunc("GET /v1/sessions", s.handleSessionList) + s.mux.HandleFunc("/v1/sessions/", s.handleSessionRouter) } // authInfo contains authentication result and attestation evidence. @@ -217,30 +253,93 @@ type authInfo struct { Agent string UsedMTLS bool UsedBearer bool + IsLocal bool // true when client IP is loopback CertFingerprint string // "sha256:" or empty TokenIssuedAt *time.Time // set when authenticated via short-lived token Process *policy.ProcessContext // set when token carries process attestation claims + + // Session identity (set when authenticated via session token) + UsedSession bool + SessionID string + SessionRole string + SessionNamespaces []string + SessionActions []string } -// authenticate identifies the calling agent from the request. -// It tries mTLS client certificate first (if CA is configured and client -// presented a cert), then falls back to bearer token authentication. -// Both paths are gated by their respective feature flags. -// Returns the agent name or an error. +// authenticate identifies the calling agent for admin/control-plane handlers. +// Session tokens are rejected here — scoped session credentials must not +// reach admin endpoints even if the underlying agent has admin ACL. +// Data-plane handlers use authenticateInfo() directly and enforce session +// scope/action checks themselves. func (s *Server) authenticate(r *http.Request) (string, error) { info, err := s.authenticateInfo(r) if err != nil { return "", err } + if info.UsedSession { + return "", &sessionAuthError{ + code: "ADMIN_AUTH_REQUIRED", + err: fmt.Errorf("session tokens cannot access admin endpoints"), + } + } return info.Agent, nil } // authenticateInfo identifies the calling agent and collects attestation // evidence from the request (auth method, cert fingerprint, etc.). +// +// Auth priority: session token > mTLS > short-lived token > bearer. +// Session tokens are checked first so that a renewal request carrying both +// a phxs_ token and a client certificate authenticates via the session. +// When a session is authenticated and a verified client cert is also present, +// the cert evidence (UsedMTLS, CertFingerprint) is layered onto the authInfo +// so that role attestation checks can inspect the current TLS state. func (s *Server) authenticateInfo(r *http.Request) (*authInfo, error) { ip := clientIP(r) + local := session.IsLoopback(ip) + + tok := extractToken(r) - // Try mTLS first: check for verified client certificate + // --- Session token authentication (phxs_ prefix) --- + // Checked first: session tokens are scoped credentials that agents use + // for all operations after bootstrap. If present, session auth is + // authoritative for identity; mTLS evidence is layered on as attestation. + if s.sessions != nil && tok != "" && strings.HasPrefix(tok, session.TokenPrefix) { + sess, err := s.sessions.Validate(tok) + if err != nil { + code := "SESSION_INVALID" + if errors.Is(err, session.ErrTokenExpired) { + code = "SESSION_EXPIRED" + } else if errors.Is(err, session.ErrSessionRevoked) { + code = "SESSION_REVOKED" + } + // Extract identity for audit even from expired/revoked tokens + if agent, sessID, known := s.sessions.ParseClaimsInsecure(tok); known { + s.logAudit(s.audit.LogSessionDenied(agent, "session.auth", "", ip, code, sessID)) + } + return nil, &sessionAuthError{code: code, err: err} + } + info := &authInfo{ + Agent: sess.Agent, + IsLocal: local, + UsedSession: true, + SessionID: sess.ID, + SessionRole: sess.Role, + SessionNamespaces: sess.Namespaces, + SessionActions: sess.Actions, + } + // Layer on mTLS attestation evidence from the current TLS connection. + // The session token is the auth method; the cert is attestation context. + if s.ca != nil && r.TLS != nil && len(r.TLS.PeerCertificates) > 0 { + if _, verifyErr := s.ca.VerifyClientCert(r.TLS.PeerCertificates); verifyErr == nil { + info.UsedMTLS = true + info.CertFingerprint = certFingerprint(r.TLS.PeerCertificates[0].Raw) + } + } + return info, nil + } + + // --- mTLS authentication --- // Rate limiting is NOT applied to mTLS — it uses cryptographic auth, not secrets. if s.ca != nil && r.TLS != nil && len(r.TLS.PeerCertificates) > 0 { agentName, err := s.ca.VerifyClientCert(r.TLS.PeerCertificates) @@ -250,6 +349,7 @@ func (s *Server) authenticateInfo(r *http.Request) (*authInfo, error) { return &authInfo{ Agent: agentName, UsedMTLS: true, + IsLocal: local, CertFingerprint: fp, }, nil } @@ -257,14 +357,14 @@ func (s *Server) authenticateInfo(r *http.Request) (*authInfo, error) { log.Printf("mTLS auth failed for %s: %v", clientIP(r), err) } - // Try short-lived token authentication - tok := extractToken(r) + // --- Short-lived token authentication --- if s.tokens != nil && tok != "" { claims, err := s.tokens.Validate(tok) if err == nil { iat := claims.IssuedAt info := &authInfo{ Agent: claims.Agent, + IsLocal: local, TokenIssuedAt: &iat, } // Propagate process attestation claims from the token @@ -282,7 +382,7 @@ func (s *Server) authenticateInfo(r *http.Request) (*authInfo, error) { // Not a valid short-lived token — fall through to bearer } - // Fall back to bearer token if enabled — rate limit applies here + // --- Bearer token authentication --- if err := s.authRL.check(ip); err != nil { return nil, err } @@ -301,6 +401,7 @@ func (s *Server) authenticateInfo(r *http.Request) (*authInfo, error) { return &authInfo{ Agent: agent, UsedBearer: true, + IsLocal: local, }, nil } @@ -332,6 +433,8 @@ func (s *Server) attestFull(r *http.Request, path string, info *authInfo, nonceV SignatureVerified: signatureVerified, TokenIssuedAt: info.TokenIssuedAt, SealKeyPresented: sealKeyValidated, + SessionID: info.SessionID, + SessionRole: info.SessionRole, } return s.policy.Evaluate(path, ctx) } @@ -384,6 +487,143 @@ func jsonError(w http.ResponseWriter, msg string, code int) { json.NewEncoder(w).Encode(map[string]string{"error": msg}) } +// jsonDenied sends a structured denial response with a machine-readable code. +func jsonDenied(w http.ResponseWriter, errMsg, code, detail, remediation string, httpCode int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(httpCode) + resp := map[string]string{ + "error": errMsg, + "code": code, + "detail": detail, + } + if remediation != "" { + resp["remediation"] = remediation + } + json.NewEncoder(w).Encode(resp) +} + +// sessionAuthError is returned by authenticateInfo when a session token is +// present but invalid (expired, revoked, etc.). It carries a structured +// denial code so handlers can return machine-readable errors. +type sessionAuthError struct { + code string // SESSION_EXPIRED, SESSION_REVOKED, SESSION_INVALID + err error +} + +func (e *sessionAuthError) Error() string { return e.err.Error() } + +// handleAuthError writes an appropriate error response for authentication failures. +// If the error is a session auth error, it returns a structured denial with the +// machine-readable code. Otherwise, it returns a generic 401. +func handleAuthError(w http.ResponseWriter, err error) { + var sae *sessionAuthError + if errors.As(err, &sae) { + jsonDenied(w, "access_denied", sae.code, + sae.err.Error(), + "mint a new session", + http.StatusUnauthorized) + return + } + jsonError(w, "unauthorized", http.StatusUnauthorized) +} + +// auditAllowed logs an allowed action, including session ID when a session was used. +func (s *Server) auditAllowed(info *authInfo, action, path, ip string) { + if info != nil && info.UsedSession { + s.logAudit(s.audit.LogSessionAllowed(info.Agent, action, path, ip, info.SessionID)) + } else { + agent := "" + if info != nil { + agent = info.Agent + } + s.logAudit(s.audit.LogAllowed(agent, action, path, ip)) + } +} + +// auditDenied logs a denied action, including session ID when a session was used. +func (s *Server) auditDenied(info *authInfo, action, path, ip, reason string) { + if info != nil && info.UsedSession { + s.logAudit(s.audit.LogSessionDenied(info.Agent, action, path, ip, reason, info.SessionID)) + } else { + agent := "" + if info != nil { + agent = info.Agent + } + s.logAudit(s.audit.LogDenied(agent, action, path, ip, reason)) + } +} + +// checkSessionScope verifies the request path is within session namespace scope. +// Returns true if access is allowed, false if denied (and writes the error response). +func (s *Server) checkSessionScope(w http.ResponseWriter, info *authInfo, path, ip string) bool { + if !info.UsedSession { + return true + } + if !session.PathInScope(path, info.SessionNamespaces) { + s.logAudit(s.audit.LogSessionDenied(info.Agent, "scope_check", path, ip, "session_scope", info.SessionID)) + jsonDenied(w, "access_denied", "SCOPE_EXCEEDED", + fmt.Sprintf("path %q is outside session scope for role %q", path, info.SessionRole), + "request a session with a role that includes this namespace", + http.StatusForbidden) + return false + } + return true +} + +// checkSessionAction verifies the requested action is allowed by the session role. +// Returns true if access is allowed, false if denied (and writes the error response). +func (s *Server) checkSessionAction(w http.ResponseWriter, info *authInfo, action, path, ip string) bool { + if !info.UsedSession { + return true + } + for _, a := range info.SessionActions { + if a == action || a == "admin" { + return true + } + } + s.logAudit(s.audit.LogSessionDenied(info.Agent, action, path, ip, "session_action", info.SessionID)) + jsonDenied(w, "access_denied", "ACTION_DENIED", + fmt.Sprintf("action %q is not permitted by session role %q", action, info.SessionRole), + "request a session with a role that includes this action", + http.StatusForbidden) + return false +} + +// checkSessionSealKey verifies the request seal key matches the session binding. +// Returns true if check passes, false if denied. +func (s *Server) checkSessionSealKey(w http.ResponseWriter, r *http.Request, info *authInfo) bool { + if !info.UsedSession || info.SessionID == "" { + return true + } + sess := s.sessions.Get(info.SessionID) + if sess == nil || sess.SealKeyFingerprint == "" { + return true + } + header := r.Header.Get("X-Phoenix-Seal-Key") + if header == "" { + // Seal key is bound but not presented -- deny + jsonDenied(w, "access_denied", "SEAL_KEY_MISMATCH", + "session has a bound seal key but no X-Phoenix-Seal-Key header was provided", + "include the seal key that was used when minting this session", + http.StatusBadRequest) + return false + } + pubKey, err := crypto.DecodeSealKey(header) + if err != nil { + jsonError(w, "malformed seal key header: "+err.Error(), http.StatusBadRequest) + return false + } + fp := session.SealKeyFingerprint(pubKey[:]) + if fp != sess.SealKeyFingerprint { + jsonDenied(w, "access_denied", "SEAL_KEY_MISMATCH", + "request seal key does not match session binding", + "use the same seal key that was provided at session mint", + http.StatusForbidden) + return false + } + return true +} + // jsonOK sends a JSON success response. func jsonOK(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") @@ -408,7 +648,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetSecret(w http.ResponseWriter, r *http.Request) { info, err := s.authenticateInfo(r) if err != nil { - jsonError(w, "unauthorized", http.StatusUnauthorized) + handleAuthError(w, err) return } agentName := info.Agent @@ -416,9 +656,21 @@ func (s *Server) handleGetSecret(w http.ResponseWriter, r *http.Request) { path := secretPath(r) ip := clientIP(r) + // Session scope enforcement (before ACL) + if path != "" && !strings.HasSuffix(path, "/") { + if !s.checkSessionScope(w, info, path, ip) { + return + } + } + + // Session seal key binding check + if !s.checkSessionSealKey(w, r, info) { + return + } + // Validate seal header early so attestation gets the correct state // for both list mode and single-secret reads. - sealKey, sealErr := s.validateSealHeader(r, agentName) + sealKey, sealErr := s.validateSealHeader(r, info) if sealErr != nil { jsonError(w, sealErr.Error(), http.StatusBadRequest) return @@ -427,6 +679,10 @@ func (s *Server) handleGetSecret(w http.ResponseWriter, r *http.Request) { // List mode: path is empty or ends with / if path == "" || strings.HasSuffix(path, "/") { + // Session action enforcement: list + if !s.checkSessionAction(w, info, "list", path, ip) { + return + } allPaths, err := s.backend.List(path) if err != nil { log.Printf("error listing secrets with prefix %q: %v", path, err) @@ -435,6 +691,10 @@ func (s *Server) handleGetSecret(w http.ResponseWriter, r *http.Request) { } var visible []string for _, p := range allPaths { + // Session scope filter: hide paths outside session namespace + if info.UsedSession && !session.PathInScope(p, info.SessionNamespaces) { + continue + } if s.acl.Authorize(agentName, p, acl.ActionList) != nil { continue } @@ -444,21 +704,26 @@ func (s *Server) handleGetSecret(w http.ResponseWriter, r *http.Request) { } visible = append(visible, p) } - s.logAudit(s.audit.LogAllowed(agentName, "list", path, ip)) + s.auditAllowed(info, "list", path, ip) jsonOK(w, map[string]interface{}{"paths": visible}) return } + // Session action enforcement: read_value + if !s.checkSessionAction(w, info, "read_value", path, ip) { + return + } + // Single secret read — requires read_value permission if err := s.acl.Authorize(agentName, path, acl.ActionReadValue); err != nil { - s.logAudit(s.audit.LogDenied(agentName, "read_value", path, ip, "acl")) + s.auditDenied(info, "read_value", path, ip, "acl") jsonError(w, "access denied: read_value permission required (use phoenix exec for context-free secret injection)", http.StatusForbidden) return } // Attestation policy check (with validated seal key state) if err := s.attestFull(r, path, info, false, false, sealKeyValidated); err != nil { - s.logAudit(s.audit.LogDenied(agentName, "read_value", path, ip, "attestation")) + s.auditDenied(info, "read_value", path, ip, "attestation") jsonError(w, "attestation required: "+err.Error(), http.StatusForbidden) return } @@ -478,7 +743,7 @@ func (s *Server) handleGetSecret(w http.ResponseWriter, r *http.Request) { return } - s.logAudit(s.audit.LogAllowed(agentName, "read_value", path, ip)) + s.auditAllowed(info, "read_value", path, ip) if sealKey != nil { env, err := crypto.SealValue(path, "", secret.Value, sealKey) if err != nil { @@ -509,7 +774,7 @@ type setSecretRequest struct { func (s *Server) handleSetSecret(w http.ResponseWriter, r *http.Request) { info, err := s.authenticateInfo(r) if err != nil { - jsonError(w, "unauthorized", http.StatusUnauthorized) + handleAuthError(w, err) return } agentName := info.Agent @@ -517,15 +782,23 @@ func (s *Server) handleSetSecret(w http.ResponseWriter, r *http.Request) { path := secretPath(r) ip := clientIP(r) + // Session scope and action enforcement + if !s.checkSessionScope(w, info, path, ip) { + return + } + if !s.checkSessionAction(w, info, "write", path, ip) { + return + } + if err := s.acl.Authorize(agentName, path, acl.ActionWrite); err != nil { - s.logAudit(s.audit.LogDenied(agentName, "write", path, ip, "acl")) + s.auditDenied(info, "write", path, ip, "acl") jsonError(w, "access denied", http.StatusForbidden) return } // Attestation policy check if err := s.attest(r, path, info, false); err != nil { - s.logAudit(s.audit.LogDenied(agentName, "write", path, ip, "attestation")) + s.auditDenied(info, "write", path, ip, "attestation") jsonError(w, "attestation required: "+err.Error(), http.StatusForbidden) return } @@ -557,14 +830,14 @@ func (s *Server) handleSetSecret(w http.ResponseWriter, r *http.Request) { return } - s.logAudit(s.audit.LogAllowed(agentName, "write", path, ip)) + s.auditAllowed(info, "write", path, ip) jsonOK(w, map[string]string{"status": "ok", "path": path}) } func (s *Server) handleDeleteSecret(w http.ResponseWriter, r *http.Request) { info, err := s.authenticateInfo(r) if err != nil { - jsonError(w, "unauthorized", http.StatusUnauthorized) + handleAuthError(w, err) return } agentName := info.Agent @@ -572,15 +845,23 @@ func (s *Server) handleDeleteSecret(w http.ResponseWriter, r *http.Request) { path := secretPath(r) ip := clientIP(r) + // Session scope and action enforcement + if !s.checkSessionScope(w, info, path, ip) { + return + } + if !s.checkSessionAction(w, info, "delete", path, ip) { + return + } + if err := s.acl.Authorize(agentName, path, acl.ActionDelete); err != nil { - s.logAudit(s.audit.LogDenied(agentName, "delete", path, ip, "acl")) + s.auditDenied(info, "delete", path, ip, "acl") jsonError(w, "access denied", http.StatusForbidden) return } // Attestation policy check if err := s.attest(r, path, info, false); err != nil { - s.logAudit(s.audit.LogDenied(agentName, "delete", path, ip, "attestation")) + s.auditDenied(info, "delete", path, ip, "attestation") jsonError(w, "attestation required: "+err.Error(), http.StatusForbidden) return } @@ -604,14 +885,14 @@ func (s *Server) handleDeleteSecret(w http.ResponseWriter, r *http.Request) { return } - s.logAudit(s.audit.LogAllowed(agentName, "delete", path, ip)) + s.auditAllowed(info, "delete", path, ip) jsonOK(w, map[string]string{"status": "ok", "path": path}) } func (s *Server) handleAuditQuery(w http.ResponseWriter, r *http.Request) { agentName, err := s.authenticate(r) if err != nil { - jsonError(w, "unauthorized", http.StatusUnauthorized) + handleAuthError(w, err) return } @@ -664,7 +945,7 @@ type createAgentRequest struct { func (s *Server) handleCreateAgent(w http.ResponseWriter, r *http.Request) { agentName, err := s.authenticate(r) if err != nil { - jsonError(w, "unauthorized", http.StatusUnauthorized) + handleAuthError(w, err) return } @@ -726,7 +1007,7 @@ func (s *Server) handleCreateAgent(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) { agentName, err := s.authenticate(r) if err != nil { - jsonError(w, "unauthorized", http.StatusUnauthorized) + handleAuthError(w, err) return } @@ -739,6 +1020,43 @@ func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) { jsonOK(w, map[string]interface{}{"agents": names}) } +func (s *Server) handleDeleteAgent(w http.ResponseWriter, r *http.Request) { + agentName, err := s.authenticate(r) + if err != nil { + handleAuthError(w, err) + return + } + + if err := s.acl.Authorize(agentName, "agents", acl.ActionAdmin); err != nil { + jsonError(w, "access denied: admin required", http.StatusForbidden) + return + } + + target := strings.TrimPrefix(r.URL.Path, "/v1/agents/") + if target == "" { + jsonError(w, "agent name required", http.StatusBadRequest) + return + } + + if target == agentName { + jsonError(w, "cannot delete your own agent identity", http.StatusBadRequest) + return + } + + if err := s.acl.RemoveAgent(target); err != nil { + if errors.Is(err, acl.ErrAgentNotFound) { + jsonError(w, "agent not found", http.StatusNotFound) + return + } + log.Printf("error deleting agent %q: %v", target, err) + jsonError(w, "internal error", http.StatusInternalServerError) + return + } + + s.logAudit(s.audit.LogAllowed(agentName, "delete-agent", target, clientIP(r))) + jsonOK(w, map[string]string{"status": "ok", "agent": target, "action": "deleted"}) +} + // issueCertRequest is the JSON body for certificate issuance. type issueCertRequest struct { AgentName string `json:"agent_name"` @@ -752,7 +1070,7 @@ func (s *Server) handleIssueCert(w http.ResponseWriter, r *http.Request) { agentName, err := s.authenticate(r) if err != nil { - jsonError(w, "unauthorized", http.StatusUnauthorized) + handleAuthError(w, err) return } @@ -794,7 +1112,7 @@ func (s *Server) handleIssueCert(w http.ResponseWriter, r *http.Request) { func (s *Server) handleRotateMaster(w http.ResponseWriter, r *http.Request) { agentName, err := s.authenticate(r) if err != nil { - jsonError(w, "unauthorized", http.StatusUnauthorized) + handleAuthError(w, err) return } @@ -881,7 +1199,7 @@ const signedResolveMaxSkew = 60 * time.Second func (s *Server) handleResolve(w http.ResponseWriter, r *http.Request) { info, err := s.authenticateInfo(r) if err != nil { - jsonError(w, "unauthorized", http.StatusUnauthorized) + handleAuthError(w, err) return } agentName := info.Agent @@ -891,7 +1209,7 @@ func (s *Server) handleResolve(w http.ResponseWriter, r *http.Request) { dryRun := r.URL.Query().Get("dry_run") == "true" // Validate seal key header early (before processing refs) - sealKey, sealErr := s.validateSealHeader(r, agentName) + sealKey, sealErr := s.validateSealHeader(r, info) if sealErr != nil { jsonError(w, sealErr.Error(), http.StatusBadRequest) return @@ -976,23 +1294,51 @@ func (s *Server) handleResolve(w http.ResponseWriter, r *http.Request) { sealedValues := make(map[string]*crypto.SealedEnvelope) errors := make(map[string]string) + // Session seal key binding check (once, before processing refs) + if !s.checkSessionSealKey(w, r, info) { + return + } + for _, refStr := range req.Refs { path, err := ref.Parse(refStr) if err != nil { - s.logAudit(s.audit.LogDenied(agentName, "resolve", refStr, ip, "malformed_ref")) + s.auditDenied(info, "resolve", refStr, ip, "malformed_ref") errors[refStr] = err.Error() continue } + // Session scope enforcement (per-ref) + if info.UsedSession && !session.PathInScope(path, info.SessionNamespaces) { + s.auditDenied(info, "resolve", path, ip, "session_scope") + errors[refStr] = "path outside session scope" + continue + } + + // Session action enforcement (per-ref) + if info.UsedSession { + allowed := false + for _, a := range info.SessionActions { + if a == "read_value" || a == "admin" { + allowed = true + break + } + } + if !allowed { + s.auditDenied(info, "resolve", path, ip, "session_action") + errors[refStr] = "action read_value not permitted by session role" + continue + } + } + if err := s.acl.Authorize(agentName, path, acl.ActionReadValue); err != nil { - s.logAudit(s.audit.LogDenied(agentName, "resolve", path, ip, "acl")) + s.auditDenied(info, "resolve", path, ip, "acl") errors[refStr] = "access denied: read_value permission required" continue } // Attestation policy check (per-ref) if err := s.attestFull(r, path, info, nonceValidated, signatureVerified, sealKey != nil); err != nil { - s.logAudit(s.audit.LogDenied(agentName, "resolve", path, ip, "attestation")) + s.auditDenied(info, "resolve", path, ip, "attestation") errors[refStr] = "attestation required" continue } @@ -1001,19 +1347,19 @@ func (s *Server) handleResolve(w http.ResponseWriter, r *http.Request) { // Dry-run: verify path exists without returning the secret value. if _, err := s.backend.Get(path); err != nil { if err == store.ErrSecretNotFound { - s.logAudit(s.audit.LogDenied(agentName, "dry-resolve", path, ip, "not_found")) + s.auditDenied(info, "dry-resolve", path, ip, "not_found") errors[refStr] = "secret not found" } else if err == store.ErrInvalidPath { - s.logAudit(s.audit.LogDenied(agentName, "dry-resolve", path, ip, "invalid_path")) + s.auditDenied(info, "dry-resolve", path, ip, "invalid_path") errors[refStr] = "invalid path" } else { - s.logAudit(s.audit.LogDenied(agentName, "dry-resolve", path, ip, "internal_error")) + s.auditDenied(info, "dry-resolve", path, ip, "internal_error") log.Printf("error dry-resolving %q for %s: %v", path, agentName, err) errors[refStr] = "internal error" } continue } - s.logAudit(s.audit.LogAllowed(agentName, "dry-resolve", path, ip)) + s.auditAllowed(info, "dry-resolve", path, ip) values[refStr] = "ok" continue } @@ -1021,20 +1367,20 @@ func (s *Server) handleResolve(w http.ResponseWriter, r *http.Request) { secret, err := s.backend.Get(path) if err != nil { if err == store.ErrSecretNotFound { - s.logAudit(s.audit.LogDenied(agentName, "resolve", path, ip, "not_found")) + s.auditDenied(info, "resolve", path, ip, "not_found") errors[refStr] = "secret not found" } else if err == store.ErrInvalidPath { - s.logAudit(s.audit.LogDenied(agentName, "resolve", path, ip, "invalid_path")) + s.auditDenied(info, "resolve", path, ip, "invalid_path") errors[refStr] = "invalid path" } else { - s.logAudit(s.audit.LogDenied(agentName, "resolve", path, ip, "internal_error")) + s.auditDenied(info, "resolve", path, ip, "internal_error") log.Printf("error resolving %q for %s: %v", path, agentName, err) errors[refStr] = "internal error" } continue } - s.logAudit(s.audit.LogAllowed(agentName, "resolve", path, ip)) + s.auditAllowed(info, "resolve", path, ip) if sealKey != nil { env, err := crypto.SealValue(path, refStr, secret.Value, sealKey) @@ -1074,7 +1420,7 @@ func (s *Server) handleChallenge(w http.ResponseWriter, r *http.Request) { // Authentication required to get a challenge _, err := s.authenticate(r) if err != nil { - jsonError(w, "unauthorized", http.StatusUnauthorized) + handleAuthError(w, err) return } @@ -1106,7 +1452,7 @@ func (s *Server) handleRevokeCert(w http.ResponseWriter, r *http.Request) { agentName, err := s.authenticate(r) if err != nil { - jsonError(w, "unauthorized", http.StatusUnauthorized) + handleAuthError(w, err) return } @@ -1157,7 +1503,7 @@ func (s *Server) handleRevokeCert(w http.ResponseWriter, r *http.Request) { func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { agentName, err := s.authenticate(r) if err != nil { - jsonError(w, "unauthorized", http.StatusUnauthorized) + handleAuthError(w, err) return } @@ -1255,7 +1601,7 @@ type mintTokenRequest struct { func (s *Server) handlePolicyCheck(w http.ResponseWriter, r *http.Request) { _, err := s.authenticate(r) if err != nil { - jsonError(w, "unauthorized", http.StatusUnauthorized) + handleAuthError(w, err) return } @@ -1296,7 +1642,7 @@ func (s *Server) handleMintToken(w http.ResponseWriter, r *http.Request) { agentName, err := s.authenticate(r) if err != nil { - jsonError(w, "unauthorized", http.StatusUnauthorized) + handleAuthError(w, err) return } @@ -1339,7 +1685,11 @@ func (s *Server) handleMintToken(w http.ResponseWriter, r *http.Request) { // registered public seal key. Returns the decoded 32-byte public key if valid, // or nil if no header is present. Returns an error if the header is present but // invalid or mismatched. -func (s *Server) validateSealHeader(r *http.Request, agentName string) (*[32]byte, error) { +// +// For session-authenticated requests, the ACL-registered seal key check is +// skipped. Session seal key binding is handled separately by checkSessionSealKey, +// which validates the header against the fingerprint bound at session mint time. +func (s *Server) validateSealHeader(r *http.Request, info *authInfo) (*[32]byte, error) { header := r.Header.Get("X-Phoenix-Seal-Key") if header == "" { return nil, nil @@ -1350,7 +1700,13 @@ func (s *Server) validateSealHeader(r *http.Request, agentName string) (*[32]byt return nil, fmt.Errorf("malformed seal key header: %w", err) } - registered, err := s.acl.GetAgentSealKey(agentName) + // Session-authenticated requests: seal key was already validated against + // the session's bound fingerprint by checkSessionSealKey. Skip ACL check. + if info.UsedSession { + return pubKey, nil + } + + registered, err := s.acl.GetAgentSealKey(info.Agent) if err != nil { return nil, fmt.Errorf("agent lookup failed: %w", err) } @@ -1376,7 +1732,7 @@ type generateKeyPairRequest struct { func (s *Server) handleGenerateKeyPair(w http.ResponseWriter, r *http.Request) { agentName, err := s.authenticate(r) if err != nil { - jsonError(w, "unauthorized", http.StatusUnauthorized) + handleAuthError(w, err) return } @@ -1464,7 +1820,7 @@ func (s *Server) handleAgentSubresource(w http.ResponseWriter, r *http.Request) func (s *Server) handleGetSealKey(w http.ResponseWriter, r *http.Request, agentTarget string) { agentName, err := s.authenticate(r) if err != nil { - jsonError(w, "unauthorized", http.StatusUnauthorized) + handleAuthError(w, err) return } @@ -1505,8 +1861,804 @@ func (s *Server) handleGetSealKey(w http.ResponseWriter, r *http.Request, agentT func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) { _, err := s.authenticateInfo(r) if err != nil { - jsonError(w, "unauthorized", http.StatusUnauthorized) + handleAuthError(w, err) return } jsonError(w, "proxy endpoint not yet implemented", http.StatusNotImplemented) } + +// sessionMintRequest is the JSON body for minting a session token. +type sessionMintRequest struct { + Role string `json:"role"` + SealPublicKey string `json:"seal_public_key,omitempty"` // base64-encoded 32-byte X25519 key + RequesterTTY string `json:"requester_tty,omitempty"` // best-effort TTY path for step-up warning +} + +func (s *Server) handleSessionMint(w http.ResponseWriter, r *http.Request) { + if s.sessions == nil || s.sessionRoles == nil { + jsonError(w, "session identity not enabled", http.StatusNotImplemented) + return + } + + // Authenticate the bootstrap request using existing auth channels + info, err := s.authenticateInfo(r) + if err != nil { + handleAuthError(w, err) + return + } + + ip := clientIP(r) + + r.Body = http.MaxBytesReader(w, r.Body, MaxRequestBodyBytes) + var req sessionMintRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.Role == "" { + jsonError(w, "role is required", http.StatusBadRequest) + return + } + + // Look up role + role, ok := s.sessionRoles[req.Role] + if !ok { + jsonDenied(w, "access_denied", "ROLE_NOT_FOUND", + fmt.Sprintf("role %q does not exist", req.Role), + "check available roles in server config", + http.StatusNotFound) + s.logAudit(s.audit.LogDenied(info.Agent, "session.mint", req.Role, ip, "role_not_found")) + return + } + + // Determine bootstrap method from auth info + bootstrapMethod := s.bootstrapMethod(info) + + // Check bootstrap trust + if !s.bootstrapAllowed(role.BootstrapTrust, bootstrapMethod, info.IsLocal) { + jsonDenied(w, "access_denied", "BOOTSTRAP_FAILED", + fmt.Sprintf("auth method %q is not in role's bootstrap_trust list", bootstrapMethod), + fmt.Sprintf("role %q accepts: %v", req.Role, role.BootstrapTrust), + http.StatusForbidden) + s.logAudit(s.audit.LogDenied(info.Agent, "session.mint", req.Role, ip, "bootstrap_failed")) + return + } + + // Check role attestation requirements + if len(role.Attestation) > 0 { + if reason := s.checkRoleAttestation(role.Attestation, info, ip); reason != "" { + jsonDenied(w, "access_denied", "ATTESTATION_FAILED", + reason, + fmt.Sprintf("role %q requires attestation: %v", req.Role, role.Attestation), + http.StatusForbidden) + s.logAudit(s.audit.LogDenied(info.Agent, "session.mint", req.Role, ip, "attestation_failed")) + return + } + } + + // Validate seal key if required + var sealPubKey []byte + if role.RequireSealKey { + if req.SealPublicKey == "" { + jsonDenied(w, "bad_request", "SEAL_KEY_REQUIRED", + fmt.Sprintf("role %q requires a seal public key", req.Role), + "include seal_public_key in the mint request", + http.StatusBadRequest) + return + } + } + if req.SealPublicKey != "" { + decoded, err := crypto.DecodeSealKey(req.SealPublicKey) + if err != nil { + jsonError(w, "invalid seal_public_key: "+err.Error(), http.StatusBadRequest) + return + } + sealPubKey = decoded[:] + } + + // Compute effective TTL + ttl := s.sessionTTL(role.MaxTTL) + + // Step-up approval: if the role requires it, create a pending approval instead of minting + if role.StepUp { + if s.approvals == nil { + jsonError(w, "step-up approval not configured", http.StatusNotImplemented) + return + } + approvalTimeout := s.parseStepUpTTL(role.StepUpTTL) + apr, aprErr := s.approvals.Create( + req.Role, info.Agent, sealPubKey, + role.Namespaces, role.Actions, + bootstrapMethod, info.CertFingerprint, ip, req.RequesterTTY, + ttl, approvalTimeout, + ) + if aprErr != nil { + log.Printf("approval create error: %v", aprErr) + jsonError(w, "internal error", http.StatusInternalServerError) + return + } + s.logAudit(s.audit.LogAllowed(info.Agent, "approval.created", req.Role, ip)) + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusAccepted) // 202 + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "approval_required", + "approval_id": apr.ID, + "code": "APPROVAL_REQUIRED", + "reason": fmt.Sprintf("role %q requires human approval", req.Role), + "approve_command": fmt.Sprintf("phoenix approve %s", apr.ID), + "expires_at": apr.ExpiresAt.Format(time.RFC3339), + }) + return + } + + namespaces := role.Namespaces + actions := role.Actions // nil -> store defaults to ["list", "read_value"] + + // Mint session + tokenStr, sess, err := s.sessions.Create(req.Role, info.Agent, sealPubKey, namespaces, actions, bootstrapMethod, info.CertFingerprint, ip, ttl) + if err != nil { + log.Printf("session mint error: %v", err) + jsonError(w, "internal error", http.StatusInternalServerError) + return + } + + // Audit log + s.logAudit(s.audit.LogAllowed(info.Agent, "session.mint.approved", req.Role, ip)) + + w.Header().Set("Cache-Control", "no-store") + jsonOK(w, map[string]interface{}{ + "session_id": sess.ID, + "session_token": tokenStr, + "role": sess.Role, + "expires_at": sess.ExpiresAt.Format(time.RFC3339), + "namespaces": sess.Namespaces, + "seal_key_fingerprint": sess.SealKeyFingerprint, + }) +} + +func (s *Server) handleSessionRenew(w http.ResponseWriter, r *http.Request) { + if s.sessions == nil || s.sessionRoles == nil { + jsonError(w, "session identity not enabled", http.StatusNotImplemented) + return + } + + info, err := s.authenticateInfo(r) + if err != nil { + handleAuthError(w, err) + return + } + + if !info.UsedSession { + jsonDenied(w, "bad_request", "SESSION_REQUIRED", + "only session tokens can be renewed", + "authenticate with a session token (phxs_ prefix)", + http.StatusBadRequest) + return + } + + ip := clientIP(r) + + // Re-check that the role still exists in config + role, ok := s.sessionRoles[info.SessionRole] + if !ok { + jsonDenied(w, "access_denied", "ROLE_NOT_FOUND", + fmt.Sprintf("role %q no longer exists in server config", info.SessionRole), + "contact your administrator", + http.StatusForbidden) + s.logAudit(s.audit.LogSessionDenied(info.Agent, "session.renew", info.SessionRole, ip, "role_not_found", info.SessionID)) + return + } + + // Re-check bootstrap trust: look up the original bootstrap method from the session + sess := s.sessions.Get(info.SessionID) + if sess == nil { + jsonDenied(w, "access_denied", "SESSION_EXPIRED", + "session no longer exists", + "mint a new session", + http.StatusUnauthorized) + return + } + + if !s.bootstrapAllowed(role.BootstrapTrust, sess.BootstrapMethod, session.IsLoopback(ip)) { + jsonDenied(w, "access_denied", "BOOTSTRAP_FAILED", + fmt.Sprintf("original bootstrap method %q is no longer trusted for role %q", sess.BootstrapMethod, info.SessionRole), + "mint a new session with a currently trusted auth method", + http.StatusForbidden) + s.logAudit(s.audit.LogSessionDenied(info.Agent, "session.renew", info.SessionRole, ip, "bootstrap_failed", info.SessionID)) + return + } + + // Re-check role attestation requirements against the CURRENT request. + // authenticateInfo() layers mTLS evidence onto session authInfo when a + // verified client cert is present on the TLS connection, so info already + // carries the real current-request attestation state. + if len(role.Attestation) > 0 { + if reason := s.checkRoleAttestation(role.Attestation, info, ip); reason != "" { + jsonDenied(w, "access_denied", "ATTESTATION_FAILED", + reason, + fmt.Sprintf("role %q requires attestation: %v", info.SessionRole, role.Attestation), + http.StatusForbidden) + s.logAudit(s.audit.LogSessionDenied(info.Agent, "session.renew", info.SessionRole, ip, "attestation_failed", info.SessionID)) + return + } + if slicesContains(role.Attestation, "cert_fingerprint") && sess.CertFingerprint != "" && info.CertFingerprint != sess.CertFingerprint { + jsonDenied(w, "access_denied", "ATTESTATION_FAILED", + "role requires the same client certificate fingerprint used when the session was minted", + "renew using the original client certificate or mint a new session", + http.StatusForbidden) + s.logAudit(s.audit.LogSessionDenied(info.Agent, "session.renew", info.SessionRole, ip, "cert_continuity_failed", info.SessionID)) + return + } + } + + ttl := s.sessionTTL(role.MaxTTL) + + tokenStr, renewedSess, err := s.sessions.Renew(info.SessionID, ttl) + if err != nil { + switch err { + case session.ErrSessionRevoked: + jsonDenied(w, "access_denied", "SESSION_REVOKED", + "session has been revoked", + "mint a new session", + http.StatusForbidden) + case session.ErrTokenExpired: + jsonDenied(w, "access_denied", "SESSION_EXPIRED", + "session has expired", + "mint a new session", + http.StatusUnauthorized) + default: + log.Printf("session renew error: %v", err) + jsonError(w, "internal error", http.StatusInternalServerError) + } + return + } + + s.logAudit(s.audit.LogSessionAllowed(info.Agent, "session.renewed", info.SessionRole, ip, info.SessionID)) + + w.Header().Set("Cache-Control", "no-store") + jsonOK(w, map[string]interface{}{ + "session_id": renewedSess.ID, + "session_token": tokenStr, + "role": renewedSess.Role, + "expires_at": renewedSess.ExpiresAt.Format(time.RFC3339), + "renewed": true, + }) +} + +// checkRoleAttestation verifies that the request satisfies the role's attestation +// requirements. Returns a non-empty reason string if any check fails. +func (s *Server) checkRoleAttestation(attestation []string, info *authInfo, ip string) string { + for _, req := range attestation { + switch req { + case "require_mtls": + if !info.UsedMTLS { + return "role requires mTLS authentication" + } + case "source_ip": + // source_ip attestation on a role means the caller must be local + if !info.IsLocal { + return "role requires local (loopback) access" + } + case "cert_fingerprint": + if info.CertFingerprint == "" { + return "role requires client certificate fingerprint" + } + case "require_sealed": + // Seal key is checked separately at mint time + } + } + return "" +} + +func slicesContains(items []string, want string) bool { + for _, item := range items { + if item == want { + return true + } + } + return false +} + +// bootstrapMethod returns the primary auth method string from authInfo. +func (s *Server) bootstrapMethod(info *authInfo) string { + if info.UsedMTLS { + return "mtls" + } + if info.UsedBearer { + return "bearer" + } + if info.TokenIssuedAt != nil { + return "token" + } + return "unknown" +} + +// bootstrapAllowed checks if the auth method satisfies the role's bootstrap trust. +// "local" is additive -- a bearer+local request matches both "bearer" and "local". +func (s *Server) bootstrapAllowed(trustMethods []string, method string, isLocal bool) bool { + for _, allowed := range trustMethods { + if allowed == method { + return true + } + if allowed == "local" && isLocal { + return true + } + } + return false +} + +// sessionTTL computes the effective TTL for a session, respecting the role's max_ttl. +func (s *Server) sessionTTL(maxTTL string) time.Duration { + if maxTTL == "" { + return 0 // use store default + } + d, err := time.ParseDuration(maxTTL) + if err != nil { + return 0 // validated at config load, shouldn't happen + } + return d +} + +// parseStepUpTTL parses the step-up approval window duration from a role config string. +func (s *Server) parseStepUpTTL(ttlStr string) time.Duration { + if ttlStr == "" { + return approval.DefaultTimeout + } + d, err := time.ParseDuration(ttlStr) + if err != nil { + return approval.DefaultTimeout + } + return d +} + +// handleApprovalRouter dispatches /v1/approval/{id}[/approve|/deny] requests. +func (s *Server) handleApprovalRouter(w http.ResponseWriter, r *http.Request) { + if s.approvals == nil { + jsonError(w, "step-up approval not configured", http.StatusNotImplemented) + return + } + + path := strings.TrimPrefix(r.URL.Path, "/v1/approval/") + parts := strings.SplitN(path, "/", 2) + id := parts[0] + if id == "" { + jsonError(w, "approval ID required", http.StatusBadRequest) + return + } + + if len(parts) == 1 { + if r.Method != "GET" { + jsonError(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + s.handleApprovalStatus(w, r, id) + } else if parts[1] == "approve" && r.Method == "POST" { + s.handleApprovalApprove(w, r, id) + } else if parts[1] == "deny" && r.Method == "POST" { + s.handleApprovalDeny(w, r, id) + } else { + jsonError(w, "not found", http.StatusNotFound) + } +} + +// handleApprovalStatus returns the current status of an approval request. +// The requesting agent (the one who triggered step-up) can poll for their own approval. +// Admins can poll for any approval. Session token includes the token only for the +// original requester or admins. +func (s *Server) handleApprovalStatus(w http.ResponseWriter, r *http.Request, id string) { + info, err := s.authenticateInfo(r) + if err != nil { + handleAuthError(w, err) + return + } + + apr := s.approvals.Get(id) + if apr == nil { + jsonError(w, "approval not found", http.StatusNotFound) + return + } + + // Authorization: requester can poll their own, admins can poll any + isRequester := info.Agent == apr.Agent + isAdmin := s.acl.Authorize(info.Agent, "approvals", acl.ActionAdmin) == nil + if !isRequester && !isAdmin { + jsonError(w, "access denied: not the requester or admin", http.StatusForbidden) + return + } + + resp := map[string]interface{}{ + "id": apr.ID, + "role": apr.Role, + "agent": apr.Agent, + "status": string(apr.Status), + "created_at": apr.CreatedAt.Format(time.RFC3339), + "expires_at": apr.ExpiresAt.Format(time.RFC3339), + } + // Only include session token for the original requester or admins + if apr.Status == approval.StatusApproved && (isRequester || isAdmin) { + resp["session_token"] = apr.SessionToken + resp["session_id"] = apr.SessionID + resp["session_expiry"] = apr.SessionExpiry.Format(time.RFC3339) + } + jsonOK(w, resp) +} + +// handleApprovalApprove approves a pending step-up request and mints a session. +func (s *Server) handleApprovalApprove(w http.ResponseWriter, r *http.Request, id string) { + info, err := s.authenticateInfo(r) + if err != nil { + handleAuthError(w, err) + return + } + + // Approvers must NOT use session auth — require admin bearer or mTLS + if info.UsedSession { + jsonDenied(w, "access_denied", "ADMIN_AUTH_REQUIRED", + "session tokens cannot approve step-up requests", + "use a bearer token or mTLS certificate to approve", + http.StatusForbidden) + return + } + + // Require ACL admin permission + if err := s.acl.Authorize(info.Agent, "approvals", acl.ActionAdmin); err != nil { + jsonError(w, "access denied: admin required", http.StatusForbidden) + return + } + + ip := clientIP(r) + + apr := s.approvals.Get(id) + if apr == nil { + jsonError(w, "approval not found", http.StatusNotFound) + return + } + if apr.Status != approval.StatusPending { + jsonOK(w, map[string]interface{}{ + "id": apr.ID, + "status": string(apr.Status), + "detail": fmt.Sprintf("approval is already %s", apr.Status), + }) + return + } + + // Shared safety checks: role exists, step-up enabled, bootstrap, attestation, seal key + role, valErr := approval.ValidateForMint(apr, s.sessionRoles) + if valErr != nil { + code := "VALIDATION_FAILED" + status := http.StatusForbidden + if ve, ok := valErr.(*approval.ValidationError); ok { + code = ve.Code + if code == "ROLE_CHANGED" { + status = http.StatusConflict + } + } + jsonDenied(w, "access_denied", code, + valErr.Error(), + "role config changed while approval was pending", + status) + s.logAudit(s.audit.LogDenied(info.Agent, "approval.approve", apr.Role, ip, valErr.Error())) + return + } + + // Parse optional approver_tty from request body + var body struct { + ApproverTTY string `json:"approver_tty"` + } + r.Body = http.MaxBytesReader(w, r.Body, MaxRequestBodyBytes) + json.NewDecoder(r.Body).Decode(&body) // best-effort + + // Mint session using CURRENT role config, not stale stored data + ttl := s.sessionTTL(role.MaxTTL) + tokenStr, sess, mintErr := s.sessions.Create( + apr.Role, apr.Agent, apr.SealPubKey, + role.Namespaces, role.Actions, + apr.BootstrapMethod, apr.CertFingerprint, apr.SourceIP, ttl, + ) + if mintErr != nil { + log.Printf("session mint on approval error: %v", mintErr) + jsonError(w, "internal error minting session", http.StatusInternalServerError) + return + } + + // Record approval + if aprErr := s.approvals.Approve(id, info.Agent, ip, body.ApproverTTY, tokenStr, sess.ID, sess.ExpiresAt); aprErr != nil { + jsonError(w, "approval failed: "+aprErr.Error(), http.StatusConflict) + return + } + + s.logAudit(s.audit.LogAllowed(info.Agent, "approval.approved", apr.Role, ip)) + + // TTY warning: compare requester and approver TTYs + sameTTYWarning := false + if apr.RequesterTTY != "" && body.ApproverTTY != "" && apr.RequesterTTY == body.ApproverTTY { + sameTTYWarning = true + } + + jsonOK(w, map[string]interface{}{ + "id": apr.ID, + "status": "approved", + "session_id": sess.ID, + "session_expiry": sess.ExpiresAt.Format(time.RFC3339), + "same_tty_warning": sameTTYWarning, + }) +} + +// handleApprovalDeny denies a pending step-up request. +func (s *Server) handleApprovalDeny(w http.ResponseWriter, r *http.Request, id string) { + info, err := s.authenticateInfo(r) + if err != nil { + handleAuthError(w, err) + return + } + + if info.UsedSession { + jsonDenied(w, "access_denied", "ADMIN_AUTH_REQUIRED", + "session tokens cannot deny step-up requests", + "use a bearer token or mTLS certificate to deny", + http.StatusForbidden) + return + } + + // Require ACL admin permission + if err := s.acl.Authorize(info.Agent, "approvals", acl.ActionAdmin); err != nil { + jsonError(w, "access denied: admin required", http.StatusForbidden) + return + } + + ip := clientIP(r) + + if denyErr := s.approvals.Deny(id, info.Agent, ip); denyErr != nil { + if denyErr == approval.ErrNotFound { + jsonError(w, "approval not found", http.StatusNotFound) + } else { + jsonError(w, "deny failed: "+denyErr.Error(), http.StatusConflict) + } + return + } + + s.logAudit(s.audit.LogAllowed(info.Agent, "approval.denied", id, ip)) + + jsonOK(w, map[string]interface{}{ + "id": id, + "status": "denied", + }) +} + +// handleApprovalList returns all pending approval requests. +func (s *Server) handleApprovalList(w http.ResponseWriter, r *http.Request) { + if s.approvals == nil { + jsonError(w, "step-up approval not configured", http.StatusNotImplemented) + return + } + + info, err := s.authenticateInfo(r) + if err != nil { + handleAuthError(w, err) + return + } + + if info.UsedSession { + jsonDenied(w, "access_denied", "ADMIN_AUTH_REQUIRED", + "session tokens cannot list approvals", + "use a bearer token or mTLS certificate", + http.StatusForbidden) + return + } + + // Require ACL admin permission + if err := s.acl.Authorize(info.Agent, "approvals", acl.ActionAdmin); err != nil { + jsonError(w, "access denied: admin required", http.StatusForbidden) + return + } + + pending := s.approvals.ListPending() + items := make([]map[string]interface{}, 0, len(pending)) + for _, apr := range pending { + items = append(items, map[string]interface{}{ + "id": apr.ID, + "role": apr.Role, + "agent": apr.Agent, + "source_ip": apr.SourceIP, + "bootstrap_method": apr.BootstrapMethod, + "created_at": apr.CreatedAt.Format(time.RFC3339), + "expires_at": apr.ExpiresAt.Format(time.RFC3339), + }) + } + + jsonOK(w, map[string]interface{}{ + "approvals": items, + }) +} + +// handleSessionList returns sessions visible to the caller. +// Session-token callers see only their own session. Admins see all. +// Non-admin bearer/mTLS callers see sessions belonging to their agent. +func (s *Server) handleSessionList(w http.ResponseWriter, r *http.Request) { + if s.sessions == nil { + jsonError(w, "session identity not enabled", http.StatusNotImplemented) + return + } + + info, err := s.authenticateInfo(r) + if err != nil { + handleAuthError(w, err) + return + } + + ip := clientIP(r) + + // Session-token callers: can only see their own session + if info.UsedSession { + sess := s.sessions.Get(info.SessionID) + if sess == nil { + jsonOK(w, map[string]interface{}{"sessions": []interface{}{}}) + return + } + jsonOK(w, map[string]interface{}{"sessions": []interface{}{sessionToMap(sess)}}) + s.auditAllowed(info, "session.list", "self", ip) + return + } + + isAdmin := s.acl.Authorize(info.Agent, "sessions", acl.ActionAdmin) == nil + + // Query filters + roleFilter := r.URL.Query().Get("role") + agentFilter := r.URL.Query().Get("agent") + + // Non-admin cannot filter by another agent + if agentFilter != "" && agentFilter != info.Agent && !isAdmin { + jsonError(w, "access denied: admin required to filter by agent", http.StatusForbidden) + return + } + + all := s.sessions.List() + + var result []*session.Session + for _, sess := range all { + // Non-admin: only own sessions + if !isAdmin && sess.Agent != info.Agent { + continue + } + if roleFilter != "" && sess.Role != roleFilter { + continue + } + if agentFilter != "" && sess.Agent != agentFilter { + continue + } + result = append(result, sess) + } + + items := make([]map[string]interface{}, 0, len(result)) + for _, sess := range result { + items = append(items, sessionToMap(sess)) + } + + jsonOK(w, map[string]interface{}{"sessions": items}) + s.auditAllowed(info, "session.list", "", ip) +} + +// handleSessionRouter dispatches /v1/sessions/{id} and /v1/sessions/{id}/revoke. +func (s *Server) handleSessionRouter(w http.ResponseWriter, r *http.Request) { + if s.sessions == nil { + jsonError(w, "session identity not enabled", http.StatusNotImplemented) + return + } + + path := strings.TrimPrefix(r.URL.Path, "/v1/sessions/") + parts := strings.SplitN(path, "/", 2) + id := parts[0] + if id == "" { + jsonError(w, "session ID required", http.StatusBadRequest) + return + } + + if len(parts) == 1 { + if r.Method != "GET" { + jsonError(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + s.handleSessionInfo(w, r, id) + } else if parts[1] == "revoke" && r.Method == "POST" { + s.handleSessionRevoke(w, r, id) + } else { + jsonError(w, "not found", http.StatusNotFound) + } +} + +// handleSessionInfo returns details of a single session. +func (s *Server) handleSessionInfo(w http.ResponseWriter, r *http.Request, id string) { + info, err := s.authenticateInfo(r) + if err != nil { + handleAuthError(w, err) + return + } + + ip := clientIP(r) + + sess := s.sessions.Get(id) + if sess == nil { + jsonError(w, "session not found", http.StatusNotFound) + return + } + + // Authorization: session-token callers can only see their exact session + // (scoped credentials never escalate to admin). Bearer/mTLS callers can + // see sessions belonging to their agent, or all sessions if admin. + if info.UsedSession { + if info.SessionID != id { + jsonError(w, "access denied", http.StatusForbidden) + return + } + } else { + isAdmin := s.acl.Authorize(info.Agent, "sessions", acl.ActionAdmin) == nil + if info.Agent != sess.Agent && !isAdmin { + jsonError(w, "access denied", http.StatusForbidden) + return + } + } + + jsonOK(w, sessionToMap(sess)) + s.auditAllowed(info, "session.info", id, ip) +} + +// handleSessionRevoke revokes a session by ID. +func (s *Server) handleSessionRevoke(w http.ResponseWriter, r *http.Request, id string) { + info, err := s.authenticateInfo(r) + if err != nil { + handleAuthError(w, err) + return + } + + ip := clientIP(r) + + sess := s.sessions.Get(id) + if sess == nil { + jsonError(w, "session not found", http.StatusNotFound) + return + } + + // Authorization: session-token callers can only revoke their exact session + // (scoped credentials never escalate to admin). Bearer/mTLS callers can + // revoke sessions belonging to their agent, or all sessions if admin. + if info.UsedSession { + if info.SessionID != id { + jsonError(w, "access denied", http.StatusForbidden) + return + } + } else { + isAdmin := s.acl.Authorize(info.Agent, "sessions", acl.ActionAdmin) == nil + if info.Agent != sess.Agent && !isAdmin { + jsonError(w, "access denied", http.StatusForbidden) + return + } + } + + if err := s.sessions.Revoke(id); err != nil { + jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + + s.auditAllowed(info, "session.revoke", id, ip) + jsonOK(w, map[string]string{ + "status": "revoked", + "session_id": id, + }) +} + +// sessionToMap converts a session to a JSON-friendly map (no token). +func sessionToMap(sess *session.Session) map[string]interface{} { + return map[string]interface{}{ + "session_id": sess.ID, + "role": sess.Role, + "agent": sess.Agent, + "namespaces": sess.Namespaces, + "actions": sess.Actions, + "bootstrap_method": sess.BootstrapMethod, + "source_ip": sess.SourceIP, + "created_at": sess.CreatedAt.Format(time.RFC3339), + "expires_at": sess.ExpiresAt.Format(time.RFC3339), + "revoked": sess.Revoked, + } +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 87d70d3..6a8741c 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -20,11 +20,14 @@ import ( "time" "github.com/phoenixsec/phoenix/internal/acl" + "github.com/phoenixsec/phoenix/internal/approval" "github.com/phoenixsec/phoenix/internal/audit" "github.com/phoenixsec/phoenix/internal/ca" + "github.com/phoenixsec/phoenix/internal/config" "github.com/phoenixsec/phoenix/internal/crypto" "github.com/phoenixsec/phoenix/internal/nonce" "github.com/phoenixsec/phoenix/internal/policy" + "github.com/phoenixsec/phoenix/internal/session" "github.com/phoenixsec/phoenix/internal/store" "github.com/phoenixsec/phoenix/internal/token" ) @@ -3437,3 +3440,1614 @@ func TestPolicyCheckUnsupportedCheck(t *testing.T) { t.Fatalf("status = %d, want 400", w.Code) } } + +// setupTestServerWithSessions creates a test server with session identity enabled. +func setupTestServerWithSessions(t *testing.T) (*Server, string) { + t.Helper() + srv, adminToken := setupTestServer(t) + + ss, err := session.NewStore(time.Hour) + if err != nil { + t.Fatalf("session store: %v", err) + } + t.Cleanup(ss.Stop) + srv.SetSessionStore(ss) + srv.SetSessionRoles(map[string]config.RoleConfig{ + "dev": { + Namespaces: []string{"test/*"}, + Actions: []string{"list", "read_value"}, + BootstrapTrust: []string{"bearer"}, + }, + "writer": { + Namespaces: []string{"test/*"}, + Actions: []string{"list", "read_value", "write"}, + BootstrapTrust: []string{"bearer"}, + }, + }) + return srv, adminToken +} + +func mintTestSession(t *testing.T, srv *Server, adminToken, role string) string { + t.Helper() + body := `{"role":"` + role + `"}` + req := httptest.NewRequest("POST", "/v1/session/mint", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("session mint: status %d, body: %s", w.Code, w.Body.String()) + } + var result struct { + SessionToken string `json:"session_token"` + } + json.Unmarshal(w.Body.Bytes(), &result) + return result.SessionToken +} + +func TestHandleSessionRenew(t *testing.T) { + srv, adminToken := setupTestServerWithSessions(t) + sessionToken := mintTestSession(t, srv, adminToken, "dev") + + req := httptest.NewRequest("POST", "/v1/session/renew", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer "+sessionToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("renew: status %d, body: %s", w.Code, w.Body.String()) + } + + var result struct { + SessionToken string `json:"session_token"` + Renewed bool `json:"renewed"` + Role string `json:"role"` + } + json.Unmarshal(w.Body.Bytes(), &result) + + if !result.Renewed { + t.Error("expected renewed=true") + } + if result.Role != "dev" { + t.Errorf("role = %q, want %q", result.Role, "dev") + } + if result.SessionToken == "" { + t.Error("expected non-empty session_token") + } + if result.SessionToken == sessionToken { + t.Error("expected new token to differ from original") + } + + // Verify Cache-Control header + if cc := w.Header().Get("Cache-Control"); cc != "no-store" { + t.Errorf("Cache-Control = %q, want %q", cc, "no-store") + } +} + +func TestHandleSessionRenewNonSession(t *testing.T) { + srv, adminToken := setupTestServerWithSessions(t) + + // Try to renew using a bearer token (not a session token) + req := httptest.NewRequest("POST", "/v1/session/renew", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 400 { + t.Fatalf("status = %d, want 400", w.Code) + } + + var result struct { + Code string `json:"code"` + } + json.Unmarshal(w.Body.Bytes(), &result) + if result.Code != "SESSION_REQUIRED" { + t.Errorf("code = %q, want SESSION_REQUIRED", result.Code) + } +} + +func TestSessionActionDeniedCode(t *testing.T) { + srv, adminToken := setupTestServerWithSessions(t) + + // Store a secret + body := `{"value":"secret123"}` + req := httptest.NewRequest("PUT", "/v1/secrets/test/mysecret", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("set secret: status %d", w.Code) + } + + // Mint a read-only session + sessionToken := mintTestSession(t, srv, adminToken, "dev") + + // Try to write using session (should fail with ACTION_DENIED) + req = httptest.NewRequest("PUT", "/v1/secrets/test/newsecret", strings.NewReader(`{"value":"nope"}`)) + req.Header.Set("Authorization", "Bearer "+sessionToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 403 { + t.Fatalf("status = %d, want 403", w.Code) + } + + var result struct { + Code string `json:"code"` + } + json.Unmarshal(w.Body.Bytes(), &result) + if result.Code != "ACTION_DENIED" { + t.Errorf("code = %q, want ACTION_DENIED", result.Code) + } +} + +func TestSessionMintCacheControl(t *testing.T) { + srv, adminToken := setupTestServerWithSessions(t) + + body := `{"role":"dev"}` + req := httptest.NewRequest("POST", "/v1/session/mint", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("status = %d, want 200", w.Code) + } + if cc := w.Header().Get("Cache-Control"); cc != "no-store" { + t.Errorf("Cache-Control = %q, want %q", cc, "no-store") + } +} + +func TestSessionMintAttestationEnforced(t *testing.T) { + srv, adminToken := setupTestServer(t) + + ss, err := session.NewStore(time.Hour) + if err != nil { + t.Fatalf("session store: %v", err) + } + t.Cleanup(ss.Stop) + srv.SetSessionStore(ss) + srv.SetSessionRoles(map[string]config.RoleConfig{ + "secure": { + Namespaces: []string{"prod/*"}, + BootstrapTrust: []string{"bearer"}, + Attestation: []string{"require_mtls"}, + }, + }) + + // Try to mint with bearer auth (should fail because role requires mTLS) + body := `{"role":"secure"}` + req := httptest.NewRequest("POST", "/v1/session/mint", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 403 { + t.Fatalf("status = %d, want 403, body: %s", w.Code, w.Body.String()) + } + + var result struct { + Code string `json:"code"` + } + json.Unmarshal(w.Body.Bytes(), &result) + if result.Code != "ATTESTATION_FAILED" { + t.Errorf("code = %q, want ATTESTATION_FAILED", result.Code) + } +} + +func TestSessionRenewLocalityRecheck(t *testing.T) { + // This test verifies that renewal checks the CURRENT request's locality, + // not the original mint-time source IP. + srv, adminToken := setupTestServer(t) + + ss, err := session.NewStore(time.Hour) + if err != nil { + t.Fatalf("session store: %v", err) + } + t.Cleanup(ss.Stop) + srv.SetSessionStore(ss) + srv.SetSessionRoles(map[string]config.RoleConfig{ + "local-only": { + Namespaces: []string{"dev/*"}, + BootstrapTrust: []string{"local"}, + }, + }) + + // Mint a session from loopback (simulated via httptest which uses 192.0.2.1) + // This will fail because httptest uses non-loopback, showing the local check works + body := `{"role":"local-only"}` + req := httptest.NewRequest("POST", "/v1/session/mint", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + // httptest uses 192.0.2.1 (non-loopback), so "local" bootstrap should fail + if w.Code != 403 { + t.Fatalf("expected 403 for non-local mint with local-only trust, got %d: %s", w.Code, w.Body.String()) + } + + var result struct { + Code string `json:"code"` + } + json.Unmarshal(w.Body.Bytes(), &result) + if result.Code != "BOOTSTRAP_FAILED" { + t.Errorf("code = %q, want BOOTSTRAP_FAILED", result.Code) + } +} + +func TestSessionRenewCertFingerprintContinuity(t *testing.T) { + srv, authority, _ := setupTestServerWithCA(t) + + ss, err := session.NewStore(time.Hour) + if err != nil { + t.Fatalf("session store: %v", err) + } + t.Cleanup(ss.Stop) + srv.SetSessionStore(ss) + srv.SetSessionRoles(map[string]config.RoleConfig{ + "secure": { + Namespaces: []string{"test/*"}, + BootstrapTrust: []string{"mtls"}, + Attestation: []string{"cert_fingerprint"}, + }, + }) + + bundle1, err := authority.IssueAgentCert("admin") + if err != nil { + t.Fatalf("issuing cert1: %v", err) + } + bundle2, err := authority.IssueAgentCert("reader") + if err != nil { + t.Fatalf("issuing cert2: %v", err) + } + + // Mint with cert1. + req := makeMTLSRequest("POST", "/v1/session/mint", []byte(`{"role":"secure"}`), bundle1.CertPEM) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("mint: status %d, body: %s", w.Code, w.Body.String()) + } + var mint struct { + SessionToken string `json:"session_token"` + } + if err := json.Unmarshal(w.Body.Bytes(), &mint); err != nil { + t.Fatalf("unmarshal mint: %v", err) + } + + // Renew with a different cert: should fail continuity check. + req = makeMTLSRequest("POST", "/v1/session/renew", []byte(`{}`), bundle2.CertPEM) + req.Header.Set("Authorization", "Bearer "+mint.SessionToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != 403 { + t.Fatalf("renew with wrong cert: status %d, body: %s", w.Code, w.Body.String()) + } + var denied struct { + Code string `json:"code"` + } + json.Unmarshal(w.Body.Bytes(), &denied) + if denied.Code != "ATTESTATION_FAILED" { + t.Fatalf("code = %q, want ATTESTATION_FAILED", denied.Code) + } + + // Renew with the original cert: should succeed. + req = makeMTLSRequest("POST", "/v1/session/renew", []byte(`{}`), bundle1.CertPEM) + req.Header.Set("Authorization", "Bearer "+mint.SessionToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("renew with original cert: status %d, body: %s", w.Code, w.Body.String()) + } +} + +func TestHandleSessionRenewExpired(t *testing.T) { + srv, adminToken := setupTestServer(t) + + ss, err := session.NewStore(5 * time.Millisecond) + if err != nil { + t.Fatalf("session store: %v", err) + } + t.Cleanup(ss.Stop) + srv.SetSessionStore(ss) + srv.SetSessionRoles(map[string]config.RoleConfig{ + "dev": { + Namespaces: []string{"test/*"}, + Actions: []string{"list", "read_value"}, + BootstrapTrust: []string{"bearer"}, + }, + }) + + // Mint with short TTL + sessionToken := mintTestSession(t, srv, adminToken, "dev") + + // Wait for session to expire + time.Sleep(10 * time.Millisecond) + + // Try to renew expired session + req := httptest.NewRequest("POST", "/v1/session/renew", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer "+sessionToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + // Should fail with 401 (token expired during auth) + if w.Code != 401 { + t.Fatalf("status = %d, want 401, body: %s", w.Code, w.Body.String()) + } +} + +// --- Step-up approval tests --- + +func setupTestServerWithStepUp(t *testing.T) (*Server, string) { + t.Helper() + srv, adminToken := setupTestServer(t) + + ss, err := session.NewStore(time.Hour) + if err != nil { + t.Fatalf("session store: %v", err) + } + t.Cleanup(ss.Stop) + srv.SetSessionStore(ss) + + as := approval.NewStore(5 * time.Minute) + t.Cleanup(as.Stop) + srv.SetApprovalStore(as) + + srv.SetSessionRoles(map[string]config.RoleConfig{ + "dev": { + Namespaces: []string{"test/*"}, + Actions: []string{"list", "read_value"}, + BootstrapTrust: []string{"bearer"}, + }, + "deploy": { + Namespaces: []string{"prod/*"}, + Actions: []string{"list", "read_value"}, + BootstrapTrust: []string{"bearer"}, + StepUp: true, + StepUpTTL: "2m", + }, + }) + return srv, adminToken +} + +func TestSessionMintStepUp(t *testing.T) { + srv, adminToken := setupTestServerWithStepUp(t) + + body := `{"role":"deploy"}` + req := httptest.NewRequest("POST", "/v1/session/mint", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 202 { + t.Fatalf("status = %d, want 202, body: %s", w.Code, w.Body.String()) + } + + var result struct { + Status string `json:"status"` + ApprovalID string `json:"approval_id"` + Code string `json:"code"` + ApproveCmd string `json:"approve_command"` + ExpiresAt string `json:"expires_at"` + } + json.Unmarshal(w.Body.Bytes(), &result) + + if result.Status != "approval_required" { + t.Errorf("status = %q, want approval_required", result.Status) + } + if result.Code != "APPROVAL_REQUIRED" { + t.Errorf("code = %q, want APPROVAL_REQUIRED", result.Code) + } + if result.ApprovalID == "" || result.ApprovalID[:4] != "apr_" { + t.Errorf("approval_id = %q, want apr_ prefix", result.ApprovalID) + } + if result.ApproveCmd == "" { + t.Error("approve_command should not be empty") + } + if result.ExpiresAt == "" { + t.Error("expires_at should not be empty") + } +} + +func TestSessionMintNoStepUp(t *testing.T) { + srv, adminToken := setupTestServerWithStepUp(t) + + // Non-step-up role should still mint directly + body := `{"role":"dev"}` + req := httptest.NewRequest("POST", "/v1/session/mint", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("status = %d, want 200, body: %s", w.Code, w.Body.String()) + } + + var result struct { + SessionToken string `json:"session_token"` + } + json.Unmarshal(w.Body.Bytes(), &result) + if result.SessionToken == "" { + t.Error("expected session_token for non-step-up role") + } +} + +func TestApprovalApproveFullFlow(t *testing.T) { + srv, adminToken := setupTestServerWithStepUp(t) + + // 1. Mint → 202 approval_required + body := `{"role":"deploy"}` + req := httptest.NewRequest("POST", "/v1/session/mint", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 202 { + t.Fatalf("mint: status %d, want 202", w.Code) + } + var mintResult struct { + ApprovalID string `json:"approval_id"` + } + json.Unmarshal(w.Body.Bytes(), &mintResult) + + // 2. Check status → pending + req = httptest.NewRequest("GET", "/v1/approval/"+mintResult.ApprovalID, nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("status check: %d, body: %s", w.Code, w.Body.String()) + } + var statusResult struct { + Status string `json:"status"` + } + json.Unmarshal(w.Body.Bytes(), &statusResult) + if statusResult.Status != "pending" { + t.Errorf("status = %q, want pending", statusResult.Status) + } + + // 3. Approve + req = httptest.NewRequest("POST", "/v1/approval/"+mintResult.ApprovalID+"/approve", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer "+adminToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("approve: status %d, body: %s", w.Code, w.Body.String()) + } + var approveResult struct { + Status string `json:"status"` + SessionID string `json:"session_id"` + } + json.Unmarshal(w.Body.Bytes(), &approveResult) + if approveResult.Status != "approved" { + t.Errorf("approve status = %q, want approved", approveResult.Status) + } + if approveResult.SessionID == "" { + t.Error("expected session_id after approval") + } + + // 4. Poll → approved with token + req = httptest.NewRequest("GET", "/v1/approval/"+mintResult.ApprovalID, nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("poll: status %d", w.Code) + } + var pollResult struct { + Status string `json:"status"` + SessionToken string `json:"session_token"` + SessionID string `json:"session_id"` + } + json.Unmarshal(w.Body.Bytes(), &pollResult) + if pollResult.Status != "approved" { + t.Errorf("poll status = %q, want approved", pollResult.Status) + } + if pollResult.SessionToken == "" { + t.Error("expected session_token in poll after approval") + } + if !strings.HasPrefix(pollResult.SessionToken, "phxs_") { + t.Errorf("session_token = %q, want phxs_ prefix", pollResult.SessionToken) + } +} + +func TestApprovalDeny(t *testing.T) { + srv, adminToken := setupTestServerWithStepUp(t) + + // Mint → 202 + body := `{"role":"deploy"}` + req := httptest.NewRequest("POST", "/v1/session/mint", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + var mintResult struct { + ApprovalID string `json:"approval_id"` + } + json.Unmarshal(w.Body.Bytes(), &mintResult) + + // Deny + req = httptest.NewRequest("POST", "/v1/approval/"+mintResult.ApprovalID+"/deny", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer "+adminToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("deny: status %d, body: %s", w.Code, w.Body.String()) + } + + // Poll → denied + req = httptest.NewRequest("GET", "/v1/approval/"+mintResult.ApprovalID, nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + var pollResult struct { + Status string `json:"status"` + } + json.Unmarshal(w.Body.Bytes(), &pollResult) + if pollResult.Status != "denied" { + t.Errorf("status = %q, want denied", pollResult.Status) + } +} + +func TestApprovalExpired(t *testing.T) { + srv, adminToken := setupTestServer(t) + + ss, err := session.NewStore(time.Hour) + if err != nil { + t.Fatalf("session store: %v", err) + } + t.Cleanup(ss.Stop) + srv.SetSessionStore(ss) + + // Use a very short step-up TTL + as := approval.NewStore(50 * time.Millisecond) + t.Cleanup(as.Stop) + srv.SetApprovalStore(as) + srv.SetSessionRoles(map[string]config.RoleConfig{ + "deploy": { + Namespaces: []string{"prod/*"}, + BootstrapTrust: []string{"bearer"}, + StepUp: true, + StepUpTTL: "50ms", + }, + }) + + // Mint → 202 + body := `{"role":"deploy"}` + req := httptest.NewRequest("POST", "/v1/session/mint", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 202 { + t.Fatalf("status = %d, want 202", w.Code) + } + var mintResult struct { + ApprovalID string `json:"approval_id"` + } + json.Unmarshal(w.Body.Bytes(), &mintResult) + + // Wait for expiry + time.Sleep(60 * time.Millisecond) + + // Poll → expired + req = httptest.NewRequest("GET", "/v1/approval/"+mintResult.ApprovalID, nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + var pollResult struct { + Status string `json:"status"` + } + json.Unmarshal(w.Body.Bytes(), &pollResult) + if pollResult.Status != "expired" { + t.Errorf("status = %q, want expired", pollResult.Status) + } +} + +func TestApprovalApproveRequiresAdmin(t *testing.T) { + srv, adminToken := setupTestServerWithStepUp(t) + + // Mint step-up → 202 + body := `{"role":"deploy"}` + req := httptest.NewRequest("POST", "/v1/session/mint", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + var mintResult struct { + ApprovalID string `json:"approval_id"` + } + json.Unmarshal(w.Body.Bytes(), &mintResult) + + // Mint a session token (non-step-up role) + sessionToken := mintTestSession(t, srv, adminToken, "dev") + + // Try to approve with session token — should be rejected + req = httptest.NewRequest("POST", "/v1/approval/"+mintResult.ApprovalID+"/approve", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer "+sessionToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 403 { + t.Fatalf("status = %d, want 403, body: %s", w.Code, w.Body.String()) + } + var result struct { + Code string `json:"code"` + } + json.Unmarshal(w.Body.Bytes(), &result) + if result.Code != "ADMIN_AUTH_REQUIRED" { + t.Errorf("code = %q, want ADMIN_AUTH_REQUIRED", result.Code) + } +} + +func TestApprovalSameTTYWarning(t *testing.T) { + srv, adminToken := setupTestServerWithStepUp(t) + + // Mint with requester_tty + body := `{"role":"deploy","requester_tty":"/dev/pts/0"}` + req := httptest.NewRequest("POST", "/v1/session/mint", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 202 { + t.Fatalf("status = %d, want 202", w.Code) + } + var mintResult struct { + ApprovalID string `json:"approval_id"` + } + json.Unmarshal(w.Body.Bytes(), &mintResult) + + // Approve with same TTY + approveBody := `{"approver_tty":"/dev/pts/0"}` + req = httptest.NewRequest("POST", "/v1/approval/"+mintResult.ApprovalID+"/approve", strings.NewReader(approveBody)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("approve: status %d, body: %s", w.Code, w.Body.String()) + } + var result struct { + SameTTYWarning bool `json:"same_tty_warning"` + } + json.Unmarshal(w.Body.Bytes(), &result) + if !result.SameTTYWarning { + t.Error("expected same_tty_warning=true when TTYs match") + } +} + +func TestApprovalListPending(t *testing.T) { + srv, adminToken := setupTestServerWithStepUp(t) + + // Create two pending approvals + for i := 0; i < 2; i++ { + body := `{"role":"deploy"}` + req := httptest.NewRequest("POST", "/v1/session/mint", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != 202 { + t.Fatalf("mint %d: status %d", i, w.Code) + } + } + + // List + req := httptest.NewRequest("GET", "/v1/approvals", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("list: status %d, body: %s", w.Code, w.Body.String()) + } + var result struct { + Approvals []struct { + ID string `json:"id"` + Role string `json:"role"` + } `json:"approvals"` + } + json.Unmarshal(w.Body.Bytes(), &result) + if len(result.Approvals) != 2 { + t.Errorf("pending count = %d, want 2", len(result.Approvals)) + } +} + +// --- Blocker fix tests: ACL admin required, status token gating, stale role re-check --- + +func TestApprovalApproveRejectsNonAdmin(t *testing.T) { + srv, adminToken := setupTestServerWithStepUp(t) + + // Mint step-up → 202 + body := `{"role":"deploy"}` + req := httptest.NewRequest("POST", "/v1/session/mint", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + var mintResult struct { + ApprovalID string `json:"approval_id"` + } + json.Unmarshal(w.Body.Bytes(), &mintResult) + + // Try to approve with reader token (non-admin) — should be rejected + req = httptest.NewRequest("POST", "/v1/approval/"+mintResult.ApprovalID+"/approve", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer reader-token") + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 403 { + t.Fatalf("status = %d, want 403, body: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "admin required") { + t.Errorf("expected 'admin required' in body, got: %s", w.Body.String()) + } +} + +func TestApprovalDenyRejectsNonAdmin(t *testing.T) { + srv, adminToken := setupTestServerWithStepUp(t) + + body := `{"role":"deploy"}` + req := httptest.NewRequest("POST", "/v1/session/mint", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + var mintResult struct { + ApprovalID string `json:"approval_id"` + } + json.Unmarshal(w.Body.Bytes(), &mintResult) + + // Reader tries to deny + req = httptest.NewRequest("POST", "/v1/approval/"+mintResult.ApprovalID+"/deny", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer reader-token") + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 403 { + t.Fatalf("status = %d, want 403, body: %s", w.Code, w.Body.String()) + } +} + +func TestApprovalListRejectsNonAdmin(t *testing.T) { + srv, _ := setupTestServerWithStepUp(t) + + req := httptest.NewRequest("GET", "/v1/approvals", nil) + req.Header.Set("Authorization", "Bearer reader-token") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 403 { + t.Fatalf("status = %d, want 403, body: %s", w.Code, w.Body.String()) + } +} + +func TestApprovalStatusHidesTokenFromNonRequester(t *testing.T) { + srv, adminToken := setupTestServerWithStepUp(t) + + // Mint step-up with admin (who becomes the "requester" agent "admin") + body := `{"role":"deploy"}` + req := httptest.NewRequest("POST", "/v1/session/mint", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + var mintResult struct { + ApprovalID string `json:"approval_id"` + } + json.Unmarshal(w.Body.Bytes(), &mintResult) + + // Approve it + req = httptest.NewRequest("POST", "/v1/approval/"+mintResult.ApprovalID+"/approve", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer "+adminToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("approve: %d", w.Code) + } + + // Reader (non-admin, not the requester) tries to poll — should be rejected + req = httptest.NewRequest("GET", "/v1/approval/"+mintResult.ApprovalID, nil) + req.Header.Set("Authorization", "Bearer reader-token") + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 403 { + t.Fatalf("status = %d, want 403, body: %s", w.Code, w.Body.String()) + } +} + +func TestApprovalRejectsStaleRole(t *testing.T) { + srv, adminToken := setupTestServerWithStepUp(t) + + // Mint step-up → 202 + body := `{"role":"deploy"}` + req := httptest.NewRequest("POST", "/v1/session/mint", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + var mintResult struct { + ApprovalID string `json:"approval_id"` + } + json.Unmarshal(w.Body.Bytes(), &mintResult) + + // Remove the deploy role from config while approval is pending + srv.SetSessionRoles(map[string]config.RoleConfig{ + "dev": { + Namespaces: []string{"test/*"}, + Actions: []string{"list", "read_value"}, + BootstrapTrust: []string{"bearer"}, + }, + // deploy role is gone + }) + + // Try to approve — should fail because role no longer exists + req = httptest.NewRequest("POST", "/v1/approval/"+mintResult.ApprovalID+"/approve", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer "+adminToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 403 { + t.Fatalf("status = %d, want 403, body: %s", w.Code, w.Body.String()) + } + var result struct { + Code string `json:"code"` + } + json.Unmarshal(w.Body.Bytes(), &result) + if result.Code != "ROLE_NOT_FOUND" { + t.Errorf("code = %q, want ROLE_NOT_FOUND", result.Code) + } +} + +func TestApprovalRejectsRoleNoLongerStepUp(t *testing.T) { + srv, adminToken := setupTestServerWithStepUp(t) + + // Mint step-up → 202 + body := `{"role":"deploy"}` + req := httptest.NewRequest("POST", "/v1/session/mint", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + var mintResult struct { + ApprovalID string `json:"approval_id"` + } + json.Unmarshal(w.Body.Bytes(), &mintResult) + + // Change deploy role to no longer require step-up + srv.SetSessionRoles(map[string]config.RoleConfig{ + "dev": { + Namespaces: []string{"test/*"}, + Actions: []string{"list", "read_value"}, + BootstrapTrust: []string{"bearer"}, + }, + "deploy": { + Namespaces: []string{"prod/*"}, + Actions: []string{"list", "read_value"}, + BootstrapTrust: []string{"bearer"}, + StepUp: false, // no longer step-up + }, + }) + + // Try to approve — should fail because role no longer requires step-up + req = httptest.NewRequest("POST", "/v1/approval/"+mintResult.ApprovalID+"/approve", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer "+adminToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 409 { + t.Fatalf("status = %d, want 409, body: %s", w.Code, w.Body.String()) + } + var result struct { + Code string `json:"code"` + } + json.Unmarshal(w.Body.Bytes(), &result) + if result.Code != "ROLE_CHANGED" { + t.Errorf("code = %q, want ROLE_CHANGED", result.Code) + } +} + +func TestApprovalRejectsBootstrapTrustTightened(t *testing.T) { + srv, adminToken := setupTestServerWithStepUp(t) + + // Mint step-up with bearer auth → 202 + body := `{"role":"deploy"}` + req := httptest.NewRequest("POST", "/v1/session/mint", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 202 { + t.Fatalf("status = %d, want 202", w.Code) + } + var mintResult struct { + ApprovalID string `json:"approval_id"` + } + json.Unmarshal(w.Body.Bytes(), &mintResult) + + // Tighten bootstrap trust to mTLS only + srv.SetSessionRoles(map[string]config.RoleConfig{ + "dev": { + Namespaces: []string{"test/*"}, + BootstrapTrust: []string{"bearer"}, + }, + "deploy": { + Namespaces: []string{"prod/*"}, + BootstrapTrust: []string{"mtls"}, // was "bearer", now "mtls" + StepUp: true, + StepUpTTL: "2m", + }, + }) + + // Approve — should fail because original bootstrap "bearer" no longer allowed + req = httptest.NewRequest("POST", "/v1/approval/"+mintResult.ApprovalID+"/approve", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer "+adminToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 403 { + t.Fatalf("status = %d, want 403, body: %s", w.Code, w.Body.String()) + } + var result struct { + Code string `json:"code"` + } + json.Unmarshal(w.Body.Bytes(), &result) + if result.Code != "BOOTSTRAP_FAILED" { + t.Errorf("code = %q, want BOOTSTRAP_FAILED", result.Code) + } +} + +func TestApprovalRejectsAttestationTightened(t *testing.T) { + srv, adminToken := setupTestServerWithStepUp(t) + + // Mint step-up → 202 (no attestation required at request time) + body := `{"role":"deploy"}` + req := httptest.NewRequest("POST", "/v1/session/mint", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + var mintResult struct { + ApprovalID string `json:"approval_id"` + } + json.Unmarshal(w.Body.Bytes(), &mintResult) + + // Add mTLS attestation requirement to deploy role + srv.SetSessionRoles(map[string]config.RoleConfig{ + "dev": { + Namespaces: []string{"test/*"}, + BootstrapTrust: []string{"bearer"}, + }, + "deploy": { + Namespaces: []string{"prod/*"}, + BootstrapTrust: []string{"bearer"}, + Attestation: []string{"require_mtls"}, // new requirement + StepUp: true, + StepUpTTL: "2m", + }, + }) + + // Approve — should fail because original request was bearer, not mTLS + req = httptest.NewRequest("POST", "/v1/approval/"+mintResult.ApprovalID+"/approve", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer "+adminToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 403 { + t.Fatalf("status = %d, want 403, body: %s", w.Code, w.Body.String()) + } + var result struct { + Code string `json:"code"` + } + json.Unmarshal(w.Body.Bytes(), &result) + if result.Code != "ATTESTATION_FAILED" { + t.Errorf("code = %q, want ATTESTATION_FAILED", result.Code) + } +} + +func TestApprovalRejectsSealKeyTightened(t *testing.T) { + srv, adminToken := setupTestServerWithStepUp(t) + + // Mint step-up without seal key → 202 + body := `{"role":"deploy"}` + req := httptest.NewRequest("POST", "/v1/session/mint", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + var mintResult struct { + ApprovalID string `json:"approval_id"` + } + json.Unmarshal(w.Body.Bytes(), &mintResult) + + // Add seal key requirement + srv.SetSessionRoles(map[string]config.RoleConfig{ + "dev": { + Namespaces: []string{"test/*"}, + BootstrapTrust: []string{"bearer"}, + }, + "deploy": { + Namespaces: []string{"prod/*"}, + BootstrapTrust: []string{"bearer"}, + RequireSealKey: true, // new requirement + StepUp: true, + StepUpTTL: "2m", + }, + }) + + // Approve — should fail because no seal key was provided at request time + req = httptest.NewRequest("POST", "/v1/approval/"+mintResult.ApprovalID+"/approve", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer "+adminToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 403 { + t.Fatalf("status = %d, want 403, body: %s", w.Code, w.Body.String()) + } + var result struct { + Code string `json:"code"` + } + json.Unmarshal(w.Body.Bytes(), &result) + if result.Code != "SEAL_KEY_REQUIRED" { + t.Errorf("code = %q, want SEAL_KEY_REQUIRED", result.Code) + } +} + +func TestSessionListAdmin(t *testing.T) { + srv, adminToken := setupTestServerWithSessions(t) + // Mint two sessions for different roles + mintTestSession(t, srv, adminToken, "dev") + mintTestSession(t, srv, adminToken, "writer") + + req := httptest.NewRequest("GET", "/v1/sessions", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("status = %d, want 200, body: %s", w.Code, w.Body.String()) + } + + var result struct { + Sessions []map[string]interface{} `json:"sessions"` + } + json.Unmarshal(w.Body.Bytes(), &result) + if len(result.Sessions) != 2 { + t.Fatalf("expected 2 sessions, got %d", len(result.Sessions)) + } +} + +func TestSessionListSelfOnly(t *testing.T) { + srv, adminToken := setupTestServerWithSessions(t) + + // Add a non-admin agent that can mint sessions but has no admin on sessions + srv.acl = acl.NewFromConfig(&acl.ACLConfig{ + Agents: map[string]acl.Agent{ + "admin": { + Name: "admin", + TokenHash: crypto.HashToken("admin-token"), + Permissions: []acl.Permission{ + {Path: "*", Actions: []acl.Action{acl.ActionAdmin}}, + }, + }, + "reader": { + Name: "reader", + TokenHash: crypto.HashToken("reader-token"), + Permissions: []acl.Permission{ + {Path: "test/*", Actions: []acl.Action{acl.ActionRead}}, + }, + }, + }, + }) + + // Mint sessions as different agents + mintTestSession(t, srv, adminToken, "dev") // admin's session + mintTestSession(t, srv, "reader-token", "dev") // reader's session + + // Reader should only see their own + req := httptest.NewRequest("GET", "/v1/sessions", nil) + req.Header.Set("Authorization", "Bearer reader-token") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("status = %d, want 200, body: %s", w.Code, w.Body.String()) + } + + var result struct { + Sessions []map[string]interface{} `json:"sessions"` + } + json.Unmarshal(w.Body.Bytes(), &result) + if len(result.Sessions) != 1 { + t.Fatalf("expected 1 session (own only), got %d", len(result.Sessions)) + } + if result.Sessions[0]["agent"] != "reader" { + t.Errorf("expected agent=reader, got %v", result.Sessions[0]["agent"]) + } +} + +func TestSessionListSessionToken(t *testing.T) { + srv, adminToken := setupTestServerWithSessions(t) + sessionToken := mintTestSession(t, srv, adminToken, "dev") + + // Mint another session to ensure it's not visible + mintTestSession(t, srv, adminToken, "writer") + + // Session token caller should only see their own session + req := httptest.NewRequest("GET", "/v1/sessions", nil) + req.Header.Set("Authorization", "Bearer "+sessionToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("status = %d, want 200, body: %s", w.Code, w.Body.String()) + } + + var result struct { + Sessions []map[string]interface{} `json:"sessions"` + } + json.Unmarshal(w.Body.Bytes(), &result) + if len(result.Sessions) != 1 { + t.Fatalf("expected 1 session (own only), got %d", len(result.Sessions)) + } +} + +func TestSessionRevokeAdmin(t *testing.T) { + srv, adminToken := setupTestServerWithSessions(t) + sessionToken := mintTestSession(t, srv, adminToken, "dev") + + // Extract session ID from the session list + req := httptest.NewRequest("GET", "/v1/sessions", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + var listResult struct { + Sessions []struct { + SessionID string `json:"session_id"` + } `json:"sessions"` + } + json.Unmarshal(w.Body.Bytes(), &listResult) + if len(listResult.Sessions) == 0 { + t.Fatal("no sessions to revoke") + } + sessionID := listResult.Sessions[0].SessionID + + // Admin revokes the session + req = httptest.NewRequest("POST", "/v1/sessions/"+sessionID+"/revoke", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer "+adminToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("revoke: status = %d, want 200, body: %s", w.Code, w.Body.String()) + } + + var revokeResult struct { + Status string `json:"status"` + SessionID string `json:"session_id"` + } + json.Unmarshal(w.Body.Bytes(), &revokeResult) + if revokeResult.Status != "revoked" { + t.Errorf("status = %q, want 'revoked'", revokeResult.Status) + } + + // Verify the revoked session token is rejected + _ = sessionToken + req = httptest.NewRequest("GET", "/v1/secrets/test/secret1", nil) + req.Header.Set("Authorization", "Bearer "+sessionToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 401 { + t.Fatalf("expected 401 for revoked session, got %d", w.Code) + } +} + +func TestSessionRevokeSelf(t *testing.T) { + srv, adminToken := setupTestServerWithSessions(t) + sessionToken := mintTestSession(t, srv, adminToken, "dev") + + // List to get session ID via the session token itself + req := httptest.NewRequest("GET", "/v1/sessions", nil) + req.Header.Set("Authorization", "Bearer "+sessionToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + var listResult struct { + Sessions []struct { + SessionID string `json:"session_id"` + } `json:"sessions"` + } + json.Unmarshal(w.Body.Bytes(), &listResult) + if len(listResult.Sessions) == 0 { + t.Fatal("no sessions found") + } + sessionID := listResult.Sessions[0].SessionID + + // Session token revokes itself + req = httptest.NewRequest("POST", "/v1/sessions/"+sessionID+"/revoke", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer "+sessionToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("self-revoke: status = %d, want 200, body: %s", w.Code, w.Body.String()) + } +} + +func TestSessionRevokeOtherDenied(t *testing.T) { + srv, adminToken := setupTestServerWithSessions(t) + + // Reconfigure ACL with a non-admin reader + srv.acl = acl.NewFromConfig(&acl.ACLConfig{ + Agents: map[string]acl.Agent{ + "admin": { + Name: "admin", + TokenHash: crypto.HashToken("admin-token"), + Permissions: []acl.Permission{ + {Path: "*", Actions: []acl.Action{acl.ActionAdmin}}, + }, + }, + "reader": { + Name: "reader", + TokenHash: crypto.HashToken("reader-token"), + Permissions: []acl.Permission{ + {Path: "test/*", Actions: []acl.Action{acl.ActionRead}}, + }, + }, + }, + }) + + // Admin mints a session + mintTestSession(t, srv, adminToken, "dev") + + // Get admin's session ID + req := httptest.NewRequest("GET", "/v1/sessions", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + var listResult struct { + Sessions []struct { + SessionID string `json:"session_id"` + } `json:"sessions"` + } + json.Unmarshal(w.Body.Bytes(), &listResult) + if len(listResult.Sessions) == 0 { + t.Fatal("no sessions found") + } + sessionID := listResult.Sessions[0].SessionID + + // Reader tries to revoke admin's session - should fail + req = httptest.NewRequest("POST", "/v1/sessions/"+sessionID+"/revoke", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer reader-token") + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 403 { + t.Fatalf("expected 403, got %d, body: %s", w.Code, w.Body.String()) + } +} + +func TestSessionInfo(t *testing.T) { + srv, adminToken := setupTestServerWithSessions(t) + mintTestSession(t, srv, adminToken, "dev") + + // Get session ID + req := httptest.NewRequest("GET", "/v1/sessions", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + var listResult struct { + Sessions []struct { + SessionID string `json:"session_id"` + } `json:"sessions"` + } + json.Unmarshal(w.Body.Bytes(), &listResult) + sessionID := listResult.Sessions[0].SessionID + + // Get session info + req = httptest.NewRequest("GET", "/v1/sessions/"+sessionID, nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("info: status = %d, want 200, body: %s", w.Code, w.Body.String()) + } + + var info struct { + SessionID string `json:"session_id"` + Role string `json:"role"` + Agent string `json:"agent"` + } + json.Unmarshal(w.Body.Bytes(), &info) + if info.SessionID != sessionID { + t.Errorf("session_id = %q, want %q", info.SessionID, sessionID) + } + if info.Role != "dev" { + t.Errorf("role = %q, want 'dev'", info.Role) + } + if info.Agent != "admin" { + t.Errorf("agent = %q, want 'admin'", info.Agent) + } +} + +func TestSessionRevokedDeniesAccess(t *testing.T) { + srv, adminToken := setupTestServerWithSessions(t) + + // Store a test secret first + body := `{"value":"secret123"}` + req := httptest.NewRequest("PUT", "/v1/secrets/test/mysecret", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != 200 && w.Code != 201 { + t.Fatalf("set secret: status %d", w.Code) + } + + sessionToken := mintTestSession(t, srv, adminToken, "dev") + + // Use session to read secret - should work + req = httptest.NewRequest("GET", "/v1/secrets/test/mysecret", nil) + req.Header.Set("Authorization", "Bearer "+sessionToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("get before revoke: status %d, body: %s", w.Code, w.Body.String()) + } + + // Get session ID and revoke + req = httptest.NewRequest("GET", "/v1/sessions", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + var listResult struct { + Sessions []struct { + SessionID string `json:"session_id"` + } `json:"sessions"` + } + json.Unmarshal(w.Body.Bytes(), &listResult) + // Find the session we minted (most recent) + sessionID := listResult.Sessions[len(listResult.Sessions)-1].SessionID + + req = httptest.NewRequest("POST", "/v1/sessions/"+sessionID+"/revoke", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer "+adminToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("revoke: status %d", w.Code) + } + + // Try to use revoked session - should fail + req = httptest.NewRequest("GET", "/v1/secrets/test/mysecret", nil) + req.Header.Set("Authorization", "Bearer "+sessionToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != 401 { + t.Fatalf("expected 401 for revoked session, got %d, body: %s", w.Code, w.Body.String()) + } +} + +func TestSessionListRoleFilter(t *testing.T) { + srv, adminToken := setupTestServerWithSessions(t) + mintTestSession(t, srv, adminToken, "dev") + mintTestSession(t, srv, adminToken, "writer") + + // Filter by role=dev + req := httptest.NewRequest("GET", "/v1/sessions?role=dev", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("status = %d, want 200", w.Code) + } + + var result struct { + Sessions []map[string]interface{} `json:"sessions"` + } + json.Unmarshal(w.Body.Bytes(), &result) + if len(result.Sessions) != 1 { + t.Fatalf("expected 1 session with role=dev, got %d", len(result.Sessions)) + } + if result.Sessions[0]["role"] != "dev" { + t.Errorf("expected role=dev, got %v", result.Sessions[0]["role"]) + } +} + +func TestSessionTokenCannotInspectSiblingSession(t *testing.T) { + srv, adminToken := setupTestServerWithSessions(t) + + // Mint two sessions for the same agent (admin) + sessionToken1 := mintTestSession(t, srv, adminToken, "dev") + mintTestSession(t, srv, adminToken, "writer") + + // Get session IDs via admin bearer + req := httptest.NewRequest("GET", "/v1/sessions", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + var listResult struct { + Sessions []struct { + SessionID string `json:"session_id"` + Role string `json:"role"` + } `json:"sessions"` + } + json.Unmarshal(w.Body.Bytes(), &listResult) + if len(listResult.Sessions) < 2 { + t.Fatalf("expected 2 sessions, got %d", len(listResult.Sessions)) + } + + // Find the "writer" session (the sibling) + var siblingID string + for _, s := range listResult.Sessions { + if s.Role == "writer" { + siblingID = s.SessionID + break + } + } + if siblingID == "" { + t.Fatal("could not find writer session") + } + + // Session token 1 (dev) tries to inspect the writer session — should be denied + req = httptest.NewRequest("GET", "/v1/sessions/"+siblingID, nil) + req.Header.Set("Authorization", "Bearer "+sessionToken1) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 403 { + t.Fatalf("expected 403 for sibling session info, got %d, body: %s", w.Code, w.Body.String()) + } + + // Session token 1 (dev) tries to revoke the writer session — should be denied + req = httptest.NewRequest("POST", "/v1/sessions/"+siblingID+"/revoke", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer "+sessionToken1) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 403 { + t.Fatalf("expected 403 for sibling session revoke, got %d, body: %s", w.Code, w.Body.String()) + } +} + +func TestSessionExpiredStructuredDenial(t *testing.T) { + srv, _ := setupTestServer(t) + + // Create a session store with a very short TTL + ss, err := session.NewStore(1 * time.Second) + if err != nil { + t.Fatalf("session store: %v", err) + } + t.Cleanup(ss.Stop) + srv.SetSessionStore(ss) + srv.SetSessionRoles(map[string]config.RoleConfig{ + "dev": { + Namespaces: []string{"test/*"}, + Actions: []string{"list", "read_value"}, + BootstrapTrust: []string{"bearer"}, + }, + }) + + // Mint a session with the short TTL + sessionToken := mintTestSession(t, srv, "admin-token", "dev") + + // Wait for it to expire + time.Sleep(2 * time.Second) + + // Try to use the expired session + req := httptest.NewRequest("GET", "/v1/secrets/test/secret1", nil) + req.Header.Set("Authorization", "Bearer "+sessionToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 401 { + t.Fatalf("expected 401, got %d, body: %s", w.Code, w.Body.String()) + } + + var result struct { + Code string `json:"code"` + } + json.Unmarshal(w.Body.Bytes(), &result) + if result.Code != "SESSION_EXPIRED" { + t.Errorf("code = %q, want SESSION_EXPIRED", result.Code) + } +} + +func TestSessionRevokedStructuredDenial(t *testing.T) { + srv, adminToken := setupTestServerWithSessions(t) + sessionToken := mintTestSession(t, srv, adminToken, "dev") + + // Get session ID and revoke it + req := httptest.NewRequest("GET", "/v1/sessions", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + var listResult struct { + Sessions []struct { + SessionID string `json:"session_id"` + } `json:"sessions"` + } + json.Unmarshal(w.Body.Bytes(), &listResult) + sessionID := listResult.Sessions[len(listResult.Sessions)-1].SessionID + + // Revoke + req = httptest.NewRequest("POST", "/v1/sessions/"+sessionID+"/revoke", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer "+adminToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + // Try to use the revoked session + req = httptest.NewRequest("GET", "/v1/secrets/test/secret1", nil) + req.Header.Set("Authorization", "Bearer "+sessionToken) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 401 { + t.Fatalf("expected 401, got %d, body: %s", w.Code, w.Body.String()) + } + + var result struct { + Code string `json:"code"` + } + json.Unmarshal(w.Body.Bytes(), &result) + if result.Code != "SESSION_REVOKED" { + t.Errorf("code = %q, want SESSION_REVOKED", result.Code) + } +} + +func TestSessionTokenBlockedOnAdminEndpoints(t *testing.T) { + srv, adminToken := setupTestServerWithSessions(t) + sessionToken := mintTestSession(t, srv, adminToken, "dev") + + // Admin endpoints that use authenticate() should all reject session tokens + adminEndpoints := []struct { + method string + path string + body string + }{ + {"GET", "/v1/audit", ""}, + {"GET", "/v1/agents", ""}, + {"POST", "/v1/agents", `{"name":"x","token":"y","permissions":[]}`}, + {"GET", "/v1/status", ""}, + {"POST", "/v1/rotate-master", ""}, + {"GET", "/v1/policy/check?path=test/x&check=allow_unseal", ""}, + } + + for _, ep := range adminEndpoints { + var req *http.Request + if ep.body != "" { + req = httptest.NewRequest(ep.method, ep.path, strings.NewReader(ep.body)) + } else { + req = httptest.NewRequest(ep.method, ep.path, nil) + } + req.Header.Set("Authorization", "Bearer "+sessionToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != 401 { + t.Errorf("%s %s: expected 401, got %d, body: %s", ep.method, ep.path, w.Code, w.Body.String()) + continue + } + + var result struct { + Code string `json:"code"` + } + json.Unmarshal(w.Body.Bytes(), &result) + if result.Code != "ADMIN_AUTH_REQUIRED" { + t.Errorf("%s %s: code = %q, want ADMIN_AUTH_REQUIRED", ep.method, ep.path, result.Code) + } + } +} diff --git a/internal/approval/approval.go b/internal/approval/approval.go new file mode 100644 index 0000000..b683e9c --- /dev/null +++ b/internal/approval/approval.go @@ -0,0 +1,63 @@ +// Package approval implements step-up approval for Phoenix session identity. +// +// When a role has StepUp enabled, session minting returns a pending approval +// instead of a token. A human approves from the CLI, and the agent polls +// for approval status to receive the session token once approved. +package approval + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "time" +) + +const ( + // ApprovalIDBytes is the number of random bytes in an approval ID. + ApprovalIDBytes = 16 + // DefaultTimeout is the default approval window. + DefaultTimeout = 5 * time.Minute +) + +// Status represents the lifecycle state of an approval request. +type Status string + +const ( + StatusPending Status = "pending" + StatusApproved Status = "approved" + StatusDenied Status = "denied" + StatusExpired Status = "expired" +) + +// Approval represents a pending or resolved step-up approval request. +type Approval struct { + ID string `json:"id"` + Role string `json:"role"` + Agent string `json:"agent"` + SealPubKey []byte `json:"-"` + Namespaces []string `json:"namespaces"` + Actions []string `json:"actions"` + BootstrapMethod string `json:"bootstrap_method"` + CertFingerprint string `json:"cert_fingerprint,omitempty"` + SourceIP string `json:"source_ip"` + RequesterTTY string `json:"requester_tty,omitempty"` + SessionTTL time.Duration `json:"-"` + Status Status `json:"status"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + ApprovedBy string `json:"approved_by,omitempty"` + ApproverIP string `json:"approver_ip,omitempty"` + ApproverTTY string `json:"approver_tty,omitempty"` + SessionToken string `json:"session_token,omitempty"` + SessionID string `json:"session_id,omitempty"` + SessionExpiry time.Time `json:"session_expiry,omitempty"` +} + +// generateApprovalID creates a random hex-encoded approval ID with "apr_" prefix. +func generateApprovalID() (string, error) { + b := make([]byte, ApprovalIDBytes) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generating approval ID: %w", err) + } + return "apr_" + hex.EncodeToString(b), nil +} diff --git a/internal/approval/store.go b/internal/approval/store.go new file mode 100644 index 0000000..7a314dc --- /dev/null +++ b/internal/approval/store.go @@ -0,0 +1,233 @@ +package approval + +import ( + "errors" + "sync" + "time" +) + +var ( + ErrNotFound = errors.New("approval not found") + ErrNotPending = errors.New("approval is not pending") +) + +// CleanupInterval is how often the background goroutine runs. +const CleanupInterval = 30 * time.Second + +// Store manages approval lifecycle: creation, approval, denial, expiry, and cleanup. +type Store struct { + mu sync.RWMutex + approvals map[string]*Approval + defaultTimeout time.Duration + stopCh chan struct{} + stopped bool +} + +// NewStore creates a new approval store with the given default timeout. +// If defaultTimeout is zero, DefaultTimeout (5m) is used. +func NewStore(defaultTimeout time.Duration) *Store { + if defaultTimeout <= 0 { + defaultTimeout = DefaultTimeout + } + s := &Store{ + approvals: make(map[string]*Approval), + defaultTimeout: defaultTimeout, + stopCh: make(chan struct{}), + } + go s.cleanupLoop() + return s +} + +// Create adds a new pending approval and returns it. +func (s *Store) Create(role, agent string, sealPubKey []byte, namespaces, actions []string, + bootstrapMethod, certFingerprint, sourceIP, requesterTTY string, sessionTTL, approvalTimeout time.Duration) (*Approval, error) { + + id, err := generateApprovalID() + if err != nil { + return nil, err + } + + if approvalTimeout <= 0 { + approvalTimeout = s.defaultTimeout + } + + now := time.Now().UTC() + apr := &Approval{ + ID: id, + Role: role, + Agent: agent, + SealPubKey: sealPubKey, + Namespaces: namespaces, + Actions: actions, + BootstrapMethod: bootstrapMethod, + CertFingerprint: certFingerprint, + SourceIP: sourceIP, + RequesterTTY: requesterTTY, + SessionTTL: sessionTTL, + Status: StatusPending, + CreatedAt: now, + ExpiresAt: now.Add(approvalTimeout), + } + + s.mu.Lock() + s.approvals[id] = apr + s.mu.Unlock() + + return apr, nil +} + +// Get returns an approval by ID, or nil if not found. +// Pending approvals past their expiry are marked expired before return. +func (s *Store) Get(id string) *Approval { + s.mu.Lock() + defer s.mu.Unlock() + + apr, ok := s.approvals[id] + if !ok { + return nil + } + // Lazily expire + if apr.Status == StatusPending && time.Now().After(apr.ExpiresAt) { + apr.Status = StatusExpired + } + return apr +} + +// Approve marks a pending approval as approved and stores the session details. +// Approving an already-approved approval is idempotent (returns nil). +// Approving a denied or expired approval returns ErrNotPending. +func (s *Store) Approve(id, approvedBy, approverIP, approverTTY, sessionToken, sessionID string, sessionExpiry time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + + apr, ok := s.approvals[id] + if !ok { + return ErrNotFound + } + + // Lazily expire + if apr.Status == StatusPending && time.Now().After(apr.ExpiresAt) { + apr.Status = StatusExpired + } + + switch apr.Status { + case StatusApproved: + return nil // idempotent + case StatusPending: + apr.Status = StatusApproved + apr.ApprovedBy = approvedBy + apr.ApproverIP = approverIP + apr.ApproverTTY = approverTTY + apr.SessionToken = sessionToken + apr.SessionID = sessionID + apr.SessionExpiry = sessionExpiry + return nil + default: + return ErrNotPending + } +} + +// Deny marks a pending approval as denied. +// Denying an already-denied approval is idempotent. +// Denying an approved or expired approval returns ErrNotPending. +func (s *Store) Deny(id, deniedBy, denierIP string) error { + s.mu.Lock() + defer s.mu.Unlock() + + apr, ok := s.approvals[id] + if !ok { + return ErrNotFound + } + + // Lazily expire + if apr.Status == StatusPending && time.Now().After(apr.ExpiresAt) { + apr.Status = StatusExpired + } + + switch apr.Status { + case StatusDenied: + return nil // idempotent + case StatusPending: + apr.Status = StatusDenied + apr.ApprovedBy = deniedBy // reuse field for denier + apr.ApproverIP = denierIP + return nil + default: + return ErrNotPending + } +} + +// ListPending returns all pending (non-expired) approvals. +func (s *Store) ListPending() []*Approval { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + var result []*Approval + for _, apr := range s.approvals { + if apr.Status == StatusPending { + if now.After(apr.ExpiresAt) { + apr.Status = StatusExpired + continue + } + result = append(result, apr) + } + } + return result +} + +// ListAll returns all approvals (pending, approved, denied, expired). +func (s *Store) ListAll() []*Approval { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + result := make([]*Approval, 0, len(s.approvals)) + for _, apr := range s.approvals { + if apr.Status == StatusPending && now.After(apr.ExpiresAt) { + apr.Status = StatusExpired + } + result = append(result, apr) + } + return result +} + +// Stop halts the background cleanup goroutine. +func (s *Store) Stop() { + s.mu.Lock() + defer s.mu.Unlock() + if !s.stopped { + close(s.stopCh) + s.stopped = true + } +} + +func (s *Store) cleanupLoop() { + ticker := time.NewTicker(CleanupInterval) + defer ticker.Stop() + for { + select { + case <-s.stopCh: + return + case <-ticker.C: + s.cleanup() + } + } +} + +func (s *Store) cleanup() { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + for id, apr := range s.approvals { + // Expire pending approvals past their deadline + if apr.Status == StatusPending && now.After(apr.ExpiresAt) { + apr.Status = StatusExpired + } + // Purge completed approvals older than 10 minutes + if apr.Status != StatusPending && now.Sub(apr.ExpiresAt) > 10*time.Minute { + delete(s.approvals, id) + } + } +} diff --git a/internal/approval/store_test.go b/internal/approval/store_test.go new file mode 100644 index 0000000..0f236e7 --- /dev/null +++ b/internal/approval/store_test.go @@ -0,0 +1,207 @@ +package approval + +import ( + "testing" + "time" +) + +func TestStoreCreateGet(t *testing.T) { + s := NewStore(5 * time.Minute) + defer s.Stop() + + apr, err := s.Create("deploy", "agent-1", []byte("pubkey"), []string{"prod/*"}, []string{"read_value"}, "bearer", "", "127.0.0.1", "/dev/pts/0", 15*time.Minute, 0) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if apr.ID == "" || apr.ID[:4] != "apr_" { + t.Errorf("ID = %q, want apr_ prefix", apr.ID) + } + if apr.Status != StatusPending { + t.Errorf("Status = %q, want pending", apr.Status) + } + if apr.Role != "deploy" { + t.Errorf("Role = %q, want deploy", apr.Role) + } + + got := s.Get(apr.ID) + if got == nil { + t.Fatal("Get returned nil") + } + if got.ID != apr.ID { + t.Errorf("Get.ID = %q, want %q", got.ID, apr.ID) + } +} + +func TestStoreGetNotFound(t *testing.T) { + s := NewStore(5 * time.Minute) + defer s.Stop() + + if got := s.Get("apr_nonexistent"); got != nil { + t.Errorf("expected nil for nonexistent ID, got %+v", got) + } +} + +func TestStoreApprove(t *testing.T) { + s := NewStore(5 * time.Minute) + defer s.Stop() + + apr, _ := s.Create("deploy", "agent-1", nil, []string{"prod/*"}, nil, "bearer", "", "127.0.0.1", "", 15*time.Minute, 0) + + expiry := time.Now().Add(15 * time.Minute) + err := s.Approve(apr.ID, "admin", "127.0.0.1", "/dev/pts/1", "phxs_token123", "ses_abc", expiry) + if err != nil { + t.Fatalf("Approve: %v", err) + } + + got := s.Get(apr.ID) + if got.Status != StatusApproved { + t.Errorf("Status = %q, want approved", got.Status) + } + if got.SessionToken != "phxs_token123" { + t.Errorf("SessionToken = %q, want phxs_token123", got.SessionToken) + } + if got.ApprovedBy != "admin" { + t.Errorf("ApprovedBy = %q, want admin", got.ApprovedBy) + } +} + +func TestStoreApproveIdempotent(t *testing.T) { + s := NewStore(5 * time.Minute) + defer s.Stop() + + apr, _ := s.Create("deploy", "agent-1", nil, []string{"prod/*"}, nil, "bearer", "", "127.0.0.1", "", 15*time.Minute, 0) + + expiry := time.Now().Add(15 * time.Minute) + _ = s.Approve(apr.ID, "admin", "127.0.0.1", "", "phxs_tok", "ses_1", expiry) + + // Second approve should succeed (idempotent) + err := s.Approve(apr.ID, "admin2", "127.0.0.2", "", "phxs_tok2", "ses_2", expiry) + if err != nil { + t.Fatalf("second Approve should be idempotent, got: %v", err) + } + + // Original values should be preserved + got := s.Get(apr.ID) + if got.SessionToken != "phxs_tok" { + t.Errorf("SessionToken = %q, want phxs_tok (original)", got.SessionToken) + } +} + +func TestStoreDeny(t *testing.T) { + s := NewStore(5 * time.Minute) + defer s.Stop() + + apr, _ := s.Create("deploy", "agent-1", nil, []string{"prod/*"}, nil, "bearer", "", "127.0.0.1", "", 15*time.Minute, 0) + + err := s.Deny(apr.ID, "admin", "127.0.0.1") + if err != nil { + t.Fatalf("Deny: %v", err) + } + + got := s.Get(apr.ID) + if got.Status != StatusDenied { + t.Errorf("Status = %q, want denied", got.Status) + } +} + +func TestStoreDenyAfterApprove(t *testing.T) { + s := NewStore(5 * time.Minute) + defer s.Stop() + + apr, _ := s.Create("deploy", "agent-1", nil, []string{"prod/*"}, nil, "bearer", "", "127.0.0.1", "", 15*time.Minute, 0) + + expiry := time.Now().Add(15 * time.Minute) + _ = s.Approve(apr.ID, "admin", "127.0.0.1", "", "phxs_tok", "ses_1", expiry) + + err := s.Deny(apr.ID, "admin", "127.0.0.1") + if err != ErrNotPending { + t.Fatalf("Deny after Approve should return ErrNotPending, got: %v", err) + } +} + +func TestStoreApproveAfterDeny(t *testing.T) { + s := NewStore(5 * time.Minute) + defer s.Stop() + + apr, _ := s.Create("deploy", "agent-1", nil, []string{"prod/*"}, nil, "bearer", "", "127.0.0.1", "", 15*time.Minute, 0) + + _ = s.Deny(apr.ID, "admin", "127.0.0.1") + + expiry := time.Now().Add(15 * time.Minute) + err := s.Approve(apr.ID, "admin", "127.0.0.1", "", "phxs_tok", "ses_1", expiry) + if err != ErrNotPending { + t.Fatalf("Approve after Deny should return ErrNotPending, got: %v", err) + } +} + +func TestStoreExpiry(t *testing.T) { + s := NewStore(50 * time.Millisecond) + defer s.Stop() + + apr, _ := s.Create("deploy", "agent-1", nil, []string{"prod/*"}, nil, "bearer", "", "127.0.0.1", "", 15*time.Minute, 50*time.Millisecond) + + // Should be pending initially + got := s.Get(apr.ID) + if got.Status != StatusPending { + t.Fatalf("initial Status = %q, want pending", got.Status) + } + + time.Sleep(60 * time.Millisecond) + + got = s.Get(apr.ID) + if got.Status != StatusExpired { + t.Errorf("Status = %q, want expired", got.Status) + } + + // Approve should fail on expired + expiry := time.Now().Add(15 * time.Minute) + err := s.Approve(apr.ID, "admin", "127.0.0.1", "", "phxs_tok", "ses_1", expiry) + if err != ErrNotPending { + t.Errorf("Approve on expired should return ErrNotPending, got: %v", err) + } +} + +func TestStoreApproveNotFound(t *testing.T) { + s := NewStore(5 * time.Minute) + defer s.Stop() + + expiry := time.Now().Add(15 * time.Minute) + err := s.Approve("apr_nonexistent", "admin", "127.0.0.1", "", "phxs_tok", "ses_1", expiry) + if err != ErrNotFound { + t.Errorf("Approve nonexistent should return ErrNotFound, got: %v", err) + } +} + +func TestStoreDenyNotFound(t *testing.T) { + s := NewStore(5 * time.Minute) + defer s.Stop() + + err := s.Deny("apr_nonexistent", "admin", "127.0.0.1") + if err != ErrNotFound { + t.Errorf("Deny nonexistent should return ErrNotFound, got: %v", err) + } +} + +func TestStoreListPending(t *testing.T) { + s := NewStore(5 * time.Minute) + defer s.Stop() + + // Create 3 approvals + apr1, _ := s.Create("deploy", "agent-1", nil, []string{"prod/*"}, nil, "bearer", "", "127.0.0.1", "", 15*time.Minute, 0) + _, _ = s.Create("viewer", "agent-2", nil, []string{"dev/*"}, nil, "bearer", "", "127.0.0.1", "", 15*time.Minute, 0) + apr3, _ := s.Create("admin", "agent-3", nil, []string{"*"}, nil, "mtls", "", "10.0.0.1", "", 15*time.Minute, 0) + + // Approve one, deny another + expiry := time.Now().Add(15 * time.Minute) + _ = s.Approve(apr1.ID, "admin", "127.0.0.1", "", "phxs_tok", "ses_1", expiry) + _ = s.Deny(apr3.ID, "admin", "127.0.0.1") + + pending := s.ListPending() + if len(pending) != 1 { + t.Fatalf("ListPending = %d, want 1", len(pending)) + } + if pending[0].Role != "viewer" { + t.Errorf("pending[0].Role = %q, want viewer", pending[0].Role) + } +} diff --git a/internal/approval/validate.go b/internal/approval/validate.go new file mode 100644 index 0000000..2851495 --- /dev/null +++ b/internal/approval/validate.go @@ -0,0 +1,102 @@ +package approval + +import ( + "fmt" + + "github.com/phoenixsec/phoenix/internal/config" + "github.com/phoenixsec/phoenix/internal/session" +) + +// ValidationError is returned by ValidateForMint with a machine-readable code. +type ValidationError struct { + Code string // e.g. "ROLE_NOT_FOUND", "BOOTSTRAP_FAILED" + Message string +} + +func (e *ValidationError) Error() string { return e.Message } + +// ValidateForMint re-checks a pending approval against the current role config. +// Returns the current RoleConfig if valid, or a *ValidationError with a specific code. +// This is called both by the API approval handler and the dashboard. +func ValidateForMint(apr *Approval, roles map[string]config.RoleConfig) (config.RoleConfig, error) { + role, ok := roles[apr.Role] + if !ok { + return config.RoleConfig{}, &ValidationError{ + Code: "ROLE_NOT_FOUND", + Message: fmt.Sprintf("role %q no longer exists in server config", apr.Role), + } + } + + if !role.StepUp { + return config.RoleConfig{}, &ValidationError{ + Code: "ROLE_CHANGED", + Message: fmt.Sprintf("role %q no longer requires step-up approval", apr.Role), + } + } + + // Re-check bootstrap trust + if !BootstrapAllowed(role.BootstrapTrust, apr.BootstrapMethod, session.IsLoopback(apr.SourceIP)) { + return config.RoleConfig{}, &ValidationError{ + Code: "BOOTSTRAP_FAILED", + Message: fmt.Sprintf("original auth method %q is no longer in role's bootstrap_trust list (accepts: %v)", apr.BootstrapMethod, role.BootstrapTrust), + } + } + + // Re-check attestation requirements + if len(role.Attestation) > 0 { + if reason := CheckAttestation(role.Attestation, apr.BootstrapMethod, apr.CertFingerprint, apr.SourceIP); reason != "" { + return config.RoleConfig{}, &ValidationError{ + Code: "ATTESTATION_FAILED", + Message: fmt.Sprintf("attestation requirements not met: %s", reason), + } + } + } + + // Re-check seal key requirement + if role.RequireSealKey && len(apr.SealPubKey) == 0 { + return config.RoleConfig{}, &ValidationError{ + Code: "SEAL_KEY_REQUIRED", + Message: fmt.Sprintf("role %q now requires a seal key, but none was provided at request time", apr.Role), + } + } + + return role, nil +} + +// BootstrapAllowed checks if the auth method satisfies the role's bootstrap trust. +// "local" is additive — a bearer+local request matches both "bearer" and "local". +func BootstrapAllowed(trustMethods []string, method string, isLocal bool) bool { + for _, allowed := range trustMethods { + if allowed == method { + return true + } + if allowed == "local" && isLocal { + return true + } + } + return false +} + +// CheckAttestation validates attestation requirements against stored approval data. +// Returns a failure reason or empty string on success. +func CheckAttestation(attestation []string, bootstrapMethod, certFingerprint, sourceIP string) string { + for _, req := range attestation { + switch req { + case "require_mtls": + if bootstrapMethod != "mtls" { + return "role requires mTLS authentication" + } + case "source_ip": + if !session.IsLoopback(sourceIP) { + return "role requires local (loopback) access" + } + case "cert_fingerprint": + if certFingerprint == "" { + return "role requires client certificate fingerprint" + } + case "require_sealed": + // Seal key is checked separately + } + } + return "" +} diff --git a/internal/approval/validate_test.go b/internal/approval/validate_test.go new file mode 100644 index 0000000..237fd02 --- /dev/null +++ b/internal/approval/validate_test.go @@ -0,0 +1,158 @@ +package approval + +import ( + "testing" + + "github.com/phoenixsec/phoenix/internal/config" +) + +func TestValidateForMint(t *testing.T) { + roles := map[string]config.RoleConfig{ + "deploy": { + Namespaces: []string{"prod/*"}, + Actions: []string{"list", "read_value"}, + BootstrapTrust: []string{"bearer"}, + StepUp: true, + }, + } + + apr := &Approval{ + Role: "deploy", + Agent: "agent1", + BootstrapMethod: "bearer", + SourceIP: "10.0.0.1", + Status: StatusPending, + } + + role, err := ValidateForMint(apr, roles) + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + if role.Namespaces[0] != "prod/*" { + t.Fatalf("expected role namespaces, got %v", role.Namespaces) + } +} + +func TestValidateRoleRemoved(t *testing.T) { + roles := map[string]config.RoleConfig{} // empty + + apr := &Approval{ + Role: "deploy", + BootstrapMethod: "bearer", + SourceIP: "10.0.0.1", + } + + _, err := ValidateForMint(apr, roles) + if err == nil { + t.Fatal("expected error for removed role") + } +} + +func TestValidateStepUpDisabled(t *testing.T) { + roles := map[string]config.RoleConfig{ + "deploy": { + Namespaces: []string{"prod/*"}, + BootstrapTrust: []string{"bearer"}, + StepUp: false, // no longer step-up + }, + } + + apr := &Approval{ + Role: "deploy", + BootstrapMethod: "bearer", + SourceIP: "10.0.0.1", + } + + _, err := ValidateForMint(apr, roles) + if err == nil { + t.Fatal("expected error when step-up disabled") + } +} + +func TestValidateBootstrapChanged(t *testing.T) { + roles := map[string]config.RoleConfig{ + "deploy": { + Namespaces: []string{"prod/*"}, + BootstrapTrust: []string{"mtls"}, // was bearer, now mtls only + StepUp: true, + }, + } + + apr := &Approval{ + Role: "deploy", + BootstrapMethod: "bearer", + SourceIP: "10.0.0.1", + } + + _, err := ValidateForMint(apr, roles) + if err == nil { + t.Fatal("expected error for changed bootstrap trust") + } +} + +func TestValidateSealKeyRequired(t *testing.T) { + roles := map[string]config.RoleConfig{ + "deploy": { + Namespaces: []string{"prod/*"}, + BootstrapTrust: []string{"bearer"}, + StepUp: true, + RequireSealKey: true, + }, + } + + apr := &Approval{ + Role: "deploy", + BootstrapMethod: "bearer", + SourceIP: "10.0.0.1", + SealPubKey: nil, // no seal key + } + + _, err := ValidateForMint(apr, roles) + if err == nil { + t.Fatal("expected error for missing seal key") + } +} + +func TestBootstrapAllowed(t *testing.T) { + tests := []struct { + name string + trust []string + method string + isLocal bool + want bool + }{ + {"direct match", []string{"bearer"}, "bearer", false, true}, + {"no match", []string{"mtls"}, "bearer", false, false}, + {"local additive", []string{"local"}, "bearer", true, true}, + {"local not loopback", []string{"local"}, "bearer", false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BootstrapAllowed(tt.trust, tt.method, tt.isLocal) + if got != tt.want { + t.Errorf("BootstrapAllowed(%v, %q, %v) = %v, want %v", tt.trust, tt.method, tt.isLocal, got, tt.want) + } + }) + } +} + +func TestCheckAttestation(t *testing.T) { + if reason := CheckAttestation([]string{"require_mtls"}, "bearer", "", "10.0.0.1"); reason == "" { + t.Error("expected failure for require_mtls with bearer") + } + if reason := CheckAttestation([]string{"require_mtls"}, "mtls", "", "10.0.0.1"); reason != "" { + t.Errorf("expected success for require_mtls with mtls, got: %s", reason) + } + if reason := CheckAttestation([]string{"source_ip"}, "bearer", "", "10.0.0.1"); reason == "" { + t.Error("expected failure for source_ip with non-local") + } + if reason := CheckAttestation([]string{"source_ip"}, "bearer", "", "127.0.0.1"); reason != "" { + t.Errorf("expected success for source_ip with loopback, got: %s", reason) + } + if reason := CheckAttestation([]string{"cert_fingerprint"}, "mtls", "", "10.0.0.1"); reason == "" { + t.Error("expected failure for cert_fingerprint with empty") + } + if reason := CheckAttestation([]string{"cert_fingerprint"}, "mtls", "sha256:abc", "10.0.0.1"); reason != "" { + t.Errorf("expected success for cert_fingerprint, got: %s", reason) + } +} diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 4304910..07ab061 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -22,6 +22,7 @@ type Entry struct { Status string `json:"status"` // "allowed" or "denied" IP string `json:"ip,omitempty"` Reason string `json:"reason,omitempty"` + SessionID string `json:"session_id,omitempty"` } // Logger writes audit entries to a file. @@ -86,6 +87,51 @@ func (l *Logger) LogDenied(agent, action, path, ip, reason string) error { return l.Log(agent, action, path, "denied", ip, reason) } +// LogSessionAllowed logs a permitted action with session context. +func (l *Logger) LogSessionAllowed(agent, action, path, ip, sessionID string) error { + entry := Entry{ + Timestamp: time.Now().UTC(), + Agent: agent, + Action: action, + Path: path, + Status: "allowed", + IP: ip, + SessionID: sessionID, + } + l.mu.Lock() + defer l.mu.Unlock() + if err := l.enc.Encode(entry); err != nil { + return err + } + if l.file != nil { + return l.file.Sync() + } + return nil +} + +// LogSessionDenied logs a denied action with session context. +func (l *Logger) LogSessionDenied(agent, action, path, ip, reason, sessionID string) error { + entry := Entry{ + Timestamp: time.Now().UTC(), + Agent: agent, + Action: action, + Path: path, + Status: "denied", + IP: ip, + Reason: reason, + SessionID: sessionID, + } + l.mu.Lock() + defer l.mu.Unlock() + if err := l.enc.Encode(entry); err != nil { + return err + } + if l.file != nil { + return l.file.Sync() + } + return nil +} + // Close flushes and closes the audit log file. func (l *Logger) Close() error { l.mu.Lock() diff --git a/internal/config/config.go b/internal/config/config.go index 34f3b26..166075a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "os" + "strings" "time" ) @@ -20,6 +21,8 @@ type Config struct { Policy PolicyConfig `json:"policy,omitempty"` Attestation AttestationConfig `json:"attestation,omitempty"` OnePassword OPConfig `json:"onepassword,omitempty"` + Session SessionConfig `json:"session,omitempty"` + Dashboard DashboardConfig `json:"dashboard,omitempty"` } // AuthConfig controls authentication methods. @@ -111,6 +114,51 @@ type OPConfig struct { CacheTTL string `json:"cache_ttl,omitempty"` // duration string, e.g. "60s" (default) } +// SessionConfig controls the v1 session identity system. +type SessionConfig struct { + Enabled bool `json:"enabled"` + TTL string `json:"ttl,omitempty"` // default "1h" + Roles map[string]RoleConfig `json:"roles,omitempty"` +} + +// RoleConfig defines a named role that agents assume via session tokens. +type RoleConfig struct { + Namespaces []string `json:"namespaces"` + Actions []string `json:"actions,omitempty"` // default: ["list", "read_value"] + BootstrapTrust []string `json:"bootstrap_trust"` // allowed auth methods: "mtls", "bearer", "local" + RequireSealKey bool `json:"require_seal_key"` // must present seal pubkey at mint + MaxTTL string `json:"max_ttl,omitempty"` // per-role session TTL override + Attestation []string `json:"attestation,omitempty"` // declared, enforcement deferred to Phase 2 + StepUp bool `json:"step_up,omitempty"` // declared, enforcement deferred to Phase 3 + StepUpTTL string `json:"step_up_ttl,omitempty"` // declared, enforcement deferred to Phase 3 +} + +// DashboardConfig controls the optional operator dashboard web UI. +type DashboardConfig struct { + Enabled bool `json:"enabled"` + PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash (generate with: phoenix-server --hash-password) + PIN string `json:"pin,omitempty"` + SessionTTL string `json:"session_ttl,omitempty"` // default "4h" + AllowMultiLogin bool `json:"allow_multi_login,omitempty"` // default false: only one active dashboard session +} + +// validBootstrapMethods lists the allowed values for RoleConfig.BootstrapTrust. +var validBootstrapMethods = map[string]bool{ + "mtls": true, + "bearer": true, + "local": true, + "token": true, +} + +// validSessionActions lists the allowed values for RoleConfig.Actions. +var validSessionActions = map[string]bool{ + "list": true, + "read_value": true, + "write": true, + "delete": true, + "admin": true, +} + // DefaultConfig returns a config with sensible defaults for /data volume mount. func DefaultConfig() *Config { return &Config{ @@ -141,6 +189,48 @@ func DefaultConfig() *Config { } } +// ExampleConfig returns a fuller reference config for config.example.json and +// other human-facing examples. Unlike DefaultConfig, it includes optional +// sections in disabled/sample form so operators can see the current surface +// area without having to cross-reference multiple docs. +func ExampleConfig() *Config { + cfg := DefaultConfig() + cfg.Server.Listen = "0.0.0.0:9090" + cfg.Store.Backend = "file" + cfg.Attestation.Nonce.Enabled = false + cfg.Attestation.Nonce.MaxAge = "30s" + cfg.Attestation.Token.Enabled = false + cfg.Attestation.Token.TTL = "15m" + cfg.Attestation.LocalAgent.Enabled = false + cfg.Attestation.LocalAgent.SocketPath = "/tmp/phoenix-agent.sock" + cfg.OnePassword.Vault = "Engineering" + cfg.OnePassword.CacheTTL = "60s" + cfg.Session = SessionConfig{ + Enabled: false, + TTL: "1h", + Roles: map[string]RoleConfig{ + "dev": { + Namespaces: []string{"dev/*", "staging/*"}, + Actions: []string{"list", "read_value"}, + BootstrapTrust: []string{"bearer"}, + }, + "deploy": { + Namespaces: []string{"prod/*"}, + Actions: []string{"list", "read_value"}, + BootstrapTrust: []string{"mtls"}, + RequireSealKey: true, + StepUp: true, + StepUpTTL: "15m", + }, + }, + } + cfg.Dashboard = DashboardConfig{ + Enabled: false, + SessionTTL: "4h", + } + return cfg +} + // Load reads a config file from disk. Missing file returns defaults. func Load(path string) (*Config, error) { cfg := DefaultConfig() @@ -230,12 +320,73 @@ func (c *Config) Validate() error { return errors.New("attestation.local_agent.socket_path is required when local_agent is enabled") } + // Validate dashboard config + if c.Dashboard.Enabled { + if c.Dashboard.PasswordHash == "" && c.Dashboard.PIN == "" { + return errors.New("dashboard: password_hash or pin is required when dashboard is enabled") + } + if c.Dashboard.PasswordHash != "" { + if !strings.HasPrefix(c.Dashboard.PasswordHash, "$2") { + return errors.New("dashboard.password_hash: must be a bcrypt hash (generate with: phoenix-server --hash-password)") + } + // Verify it's a parseable bcrypt hash, not just a $2 prefix + if len(c.Dashboard.PasswordHash) < 59 { + return errors.New("dashboard.password_hash: truncated bcrypt hash (expected ~60 characters)") + } + } + if c.Dashboard.SessionTTL != "" { + if _, err := time.ParseDuration(c.Dashboard.SessionTTL); err != nil { + return fmt.Errorf("dashboard.session_ttl: invalid duration %q: %w", c.Dashboard.SessionTTL, err) + } + } + } + + // Validate session config + if c.Session.Enabled { + if c.Session.TTL != "" { + if _, err := time.ParseDuration(c.Session.TTL); err != nil { + return fmt.Errorf("session.ttl: invalid duration %q: %w", c.Session.TTL, err) + } + } + if len(c.Session.Roles) == 0 { + return errors.New("session.roles: at least one role must be defined when sessions are enabled") + } + for name, role := range c.Session.Roles { + if len(role.Namespaces) == 0 { + return fmt.Errorf("session.roles.%s: at least one namespace is required", name) + } + if len(role.BootstrapTrust) == 0 { + return fmt.Errorf("session.roles.%s: at least one bootstrap_trust method is required", name) + } + for _, method := range role.BootstrapTrust { + if !validBootstrapMethods[method] { + return fmt.Errorf("session.roles.%s: unknown bootstrap_trust method %q (valid: mtls, bearer, local)", name, method) + } + } + for _, action := range role.Actions { + if !validSessionActions[action] { + return fmt.Errorf("session.roles.%s: unknown action %q (valid: list, read_value, write, delete, admin)", name, action) + } + } + if role.MaxTTL != "" { + if _, err := time.ParseDuration(role.MaxTTL); err != nil { + return fmt.Errorf("session.roles.%s.max_ttl: invalid duration %q: %w", name, role.MaxTTL, err) + } + } + if role.StepUpTTL != "" { + if _, err := time.ParseDuration(role.StepUpTTL); err != nil { + return fmt.Errorf("session.roles.%s.step_up_ttl: invalid duration %q: %w", name, role.StepUpTTL, err) + } + } + } + } + return nil } // SaveExample writes a default config to disk as a reference. func SaveExample(path string) error { - cfg := DefaultConfig() + cfg := ExampleConfig() data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e17d511..b86e492 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,6 +2,7 @@ package config import ( "encoding/json" + "strings" "testing" ) @@ -275,6 +276,184 @@ func TestDefaultConfigNoLocalAgent(t *testing.T) { } } +func TestValidateSessionActionNames(t *testing.T) { + cfg := DefaultConfig() + cfg.Session.Enabled = true + cfg.Session.Roles = map[string]RoleConfig{ + "bad": { + Namespaces: []string{"dev/*"}, + Actions: []string{"list", "explode"}, + BootstrapTrust: []string{"bearer"}, + }, + } + err := cfg.Validate() + if err == nil { + t.Fatal("expected error for invalid action name") + } + if !strings.Contains(err.Error(), "explode") { + t.Fatalf("error should mention invalid action: %v", err) + } +} + +func TestValidateSessionActionNamesValid(t *testing.T) { + cfg := DefaultConfig() + cfg.Session.Enabled = true + cfg.Session.Roles = map[string]RoleConfig{ + "good": { + Namespaces: []string{"dev/*"}, + Actions: []string{"list", "read_value", "write"}, + BootstrapTrust: []string{"bearer"}, + }, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("valid actions should pass: %v", err) + } +} + +func TestValidateBootstrapTrustToken(t *testing.T) { + cfg := DefaultConfig() + cfg.Session.Enabled = true + cfg.Session.Roles = map[string]RoleConfig{ + "via-token": { + Namespaces: []string{"dev/*"}, + BootstrapTrust: []string{"token"}, + }, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("bootstrap_trust 'token' should be valid: %v", err) + } +} + +// --- Dashboard config validation --- + +func TestValidateDashboardEnabledRequiresCredential(t *testing.T) { + cfg := DefaultConfig() + cfg.Dashboard.Enabled = true + cfg.Dashboard.PasswordHash = "" + cfg.Dashboard.PIN = "" + + err := cfg.Validate() + if err == nil { + t.Fatal("dashboard enabled without password_hash or pin should fail") + } + if !strings.Contains(err.Error(), "password_hash or pin") { + t.Fatalf("error should mention credential requirement: %v", err) + } +} + +func TestValidateDashboardWithPasswordHash(t *testing.T) { + cfg := DefaultConfig() + cfg.Dashboard.Enabled = true + cfg.Dashboard.PasswordHash = "$2a$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234" + + if err := cfg.Validate(); err != nil { + t.Fatalf("dashboard with bcrypt hash should pass: %v", err) + } +} + +func TestValidateDashboardRejectsPlaintextPassword(t *testing.T) { + cfg := DefaultConfig() + cfg.Dashboard.Enabled = true + cfg.Dashboard.PasswordHash = "plaintext-password" + + err := cfg.Validate() + if err == nil { + t.Fatal("plaintext password_hash should fail validation") + } + if !strings.Contains(err.Error(), "bcrypt hash") { + t.Fatalf("error should mention bcrypt: %v", err) + } +} + +func TestValidateDashboardWithPIN(t *testing.T) { + cfg := DefaultConfig() + cfg.Dashboard.Enabled = true + cfg.Dashboard.PIN = "1234" + + if err := cfg.Validate(); err != nil { + t.Fatalf("dashboard with PIN should pass: %v", err) + } +} + +func TestValidateDashboardSessionTTLInvalid(t *testing.T) { + cfg := DefaultConfig() + cfg.Dashboard.Enabled = true + cfg.Dashboard.PasswordHash = "$2a$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234" + cfg.Dashboard.SessionTTL = "not-a-duration" + + err := cfg.Validate() + if err == nil { + t.Fatal("invalid session_ttl should fail validation") + } + if !strings.Contains(err.Error(), "session_ttl") { + t.Fatalf("error should mention session_ttl: %v", err) + } +} + +func TestValidateDashboardSessionTTLValid(t *testing.T) { + cfg := DefaultConfig() + cfg.Dashboard.Enabled = true + cfg.Dashboard.PasswordHash = "$2a$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234" + cfg.Dashboard.SessionTTL = "8h" + + if err := cfg.Validate(); err != nil { + t.Fatalf("valid session_ttl should pass: %v", err) + } +} + +func TestValidateDashboardDisabledIgnoresFields(t *testing.T) { + cfg := DefaultConfig() + cfg.Dashboard.Enabled = false + cfg.Dashboard.PasswordHash = "" + cfg.Dashboard.PIN = "" + cfg.Dashboard.SessionTTL = "garbage" + + if err := cfg.Validate(); err != nil { + t.Fatalf("disabled dashboard should not validate fields: %v", err) + } +} + +func TestDashboardConfigJSON(t *testing.T) { + cfg := DefaultConfig() + cfg.Dashboard.Enabled = true + cfg.Dashboard.PasswordHash = "$2a$10$testbcrypthashvalue" + cfg.Dashboard.SessionTTL = "4h" + + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var parsed Config + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if !parsed.Dashboard.Enabled { + t.Error("dashboard.enabled should be true after round-trip") + } + if parsed.Dashboard.PasswordHash != "$2a$10$testbcrypthashvalue" { + t.Errorf("dashboard.password_hash = %q, want %q", parsed.Dashboard.PasswordHash, "$2a$10$testbcrypthashvalue") + } + if parsed.Dashboard.SessionTTL != "4h" { + t.Errorf("dashboard.session_ttl = %q, want %q", parsed.Dashboard.SessionTTL, "4h") + } +} + +func TestExampleConfigIncludesSessionAndDashboard(t *testing.T) { + cfg := ExampleConfig() + + if cfg.Session.TTL != "1h" { + t.Fatalf("session.ttl = %q, want 1h", cfg.Session.TTL) + } + if len(cfg.Session.Roles) == 0 { + t.Fatal("expected example session roles") + } + if cfg.Dashboard.SessionTTL != "4h" { + t.Fatalf("dashboard.session_ttl = %q, want 4h", cfg.Dashboard.SessionTTL) + } +} + func TestOPConfigJSON(t *testing.T) { cfg := DefaultConfig() cfg.Store.Backend = "1password" diff --git a/internal/dashboard/dashboard.go b/internal/dashboard/dashboard.go new file mode 100644 index 0000000..94fdd3f --- /dev/null +++ b/internal/dashboard/dashboard.go @@ -0,0 +1,876 @@ +// Package dashboard implements a lightweight operator web UI for Phoenix. +// All templates, CSS, JS, and SVG are embedded via go:embed — no external deps. +package dashboard + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "embed" + "encoding/hex" + "encoding/json" + "fmt" + "html/template" + "io/fs" + "log" + "net/http" + "sort" + "strings" + "sync" + "time" + + "github.com/phoenixsec/phoenix/internal/acl" + "github.com/phoenixsec/phoenix/internal/approval" + "github.com/phoenixsec/phoenix/internal/audit" + "github.com/phoenixsec/phoenix/internal/config" + "github.com/phoenixsec/phoenix/internal/policy" + "github.com/phoenixsec/phoenix/internal/session" + "github.com/phoenixsec/phoenix/internal/store" + + "golang.org/x/crypto/bcrypt" +) + +// Login rate limiting — exponential backoff per IP. +const ( + loginMaxFailures = 5 + loginBaseDelay = 1 * time.Second + loginMaxDelay = 60 * time.Second + loginCleanupAge = 10 * time.Minute +) + +type loginRateEntry struct { + failures int + blockedAt time.Time +} + +type loginRateLimiter struct { + mu sync.Mutex + entries map[string]*loginRateEntry +} + +func newLoginRateLimiter() *loginRateLimiter { + rl := &loginRateLimiter{entries: make(map[string]*loginRateEntry)} + go rl.cleanupLoop() + return rl +} + +func (rl *loginRateLimiter) check(ip string) error { + rl.mu.Lock() + defer rl.mu.Unlock() + e, ok := rl.entries[ip] + if !ok || e.failures < loginMaxFailures { + return nil + } + exp := e.failures - loginMaxFailures + if exp > 6 { + exp = 6 + } + delay := loginBaseDelay * (1 << exp) + if delay > loginMaxDelay { + delay = loginMaxDelay + } + if time.Since(e.blockedAt) < delay { + return fmt.Errorf("too many login attempts, retry in %s", (delay - time.Since(e.blockedAt)).Truncate(time.Second)) + } + return nil +} + +func (rl *loginRateLimiter) recordFailure(ip string) { + rl.mu.Lock() + defer rl.mu.Unlock() + e, ok := rl.entries[ip] + if !ok { + e = &loginRateEntry{} + rl.entries[ip] = e + } + e.failures++ + e.blockedAt = time.Now() +} + +func (rl *loginRateLimiter) recordSuccess(ip string) { + rl.mu.Lock() + defer rl.mu.Unlock() + delete(rl.entries, ip) +} + +func (rl *loginRateLimiter) cleanupLoop() { + ticker := time.NewTicker(loginCleanupAge) + defer ticker.Stop() + for range ticker.C { + rl.mu.Lock() + cutoff := time.Now().Add(-loginCleanupAge) + for ip, e := range rl.entries { + if e.blockedAt.Before(cutoff) { + delete(rl.entries, ip) + } + } + rl.mu.Unlock() + } +} + +//go:embed templates/*.html +var templateFS embed.FS + +//go:embed static/* +var staticFS embed.FS + +// Deps holds pointers to the stores the dashboard needs. +// The dashboard accesses these directly — it does not proxy through the HTTP API. +type Deps struct { + Sessions *session.Store + SessionRoles map[string]config.RoleConfig + Approvals *approval.Store + AuditPath string + Backend store.SecretBackend + ACL *acl.ACL + Policy *policy.Engine + StartTime time.Time + Audit *audit.Logger +} + +// Handler serves the dashboard web UI. +type Handler struct { + deps Deps + tmpl *template.Template + cookieKey []byte // 32 bytes, HMAC signing + passwordH []byte // bcrypt hash (nil if PIN mode) + pin string // raw PIN (empty if password mode) + sessionTTL time.Duration + allowMultiLogin bool + loginRL *loginRateLimiter + nonceMu sync.Mutex + activeNonce string // set on login; checked on auth when single-session mode + mux *http.ServeMux +} + +// cookiePayload is the signed cookie content. +type cookiePayload struct { + Exp int64 `json:"exp"` + CSRF string `json:"csrf"` + Nonce string `json:"nonce"` // binds cookie to a specific login; verified in single-session mode +} + +// New creates a dashboard handler from config and deps. +func New(cfg config.DashboardConfig, deps Deps) (*Handler, error) { + // Generate cookie signing key + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("generating cookie key: %w", err) + } + + h := &Handler{ + deps: deps, + cookieKey: key, + sessionTTL: 4 * time.Hour, + allowMultiLogin: cfg.AllowMultiLogin, + loginRL: newLoginRateLimiter(), + mux: http.NewServeMux(), + } + + // Parse session TTL + if cfg.SessionTTL != "" { + d, err := time.ParseDuration(cfg.SessionTTL) + if err == nil { + h.sessionTTL = d + } + } + + // Set up auth: password hash (bcrypt) or PIN + if cfg.PasswordHash != "" { + h.passwordH = []byte(cfg.PasswordHash) + } else if cfg.PIN != "" { + h.pin = cfg.PIN + } + + // Parse templates with functions + funcMap := template.FuncMap{ + "truncate": truncate, + "fmtTime": fmtTime, + "fmtAgo": fmtAgo, + "ttlRemain": ttlRemain, + "join": strings.Join, + "upper": strings.ToUpper, + "hasPrefix": strings.HasPrefix, + } + + tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html") + if err != nil { + return nil, fmt.Errorf("parsing templates: %w", err) + } + h.tmpl = tmpl + + // Static file server (strip /dashboard/static/ prefix) + staticSub, _ := fs.Sub(staticFS, "static") + staticHandler := http.FileServer(http.FS(staticSub)) + + // Routes + h.mux.HandleFunc("GET /dashboard/login", h.handleLoginPage) + h.mux.HandleFunc("POST /dashboard/login", h.handleLogin) + h.mux.HandleFunc("POST /dashboard/force-login", h.handleForceLogin) + h.mux.HandleFunc("POST /dashboard/logout", h.requireAuth(h.handleLogout)) + h.mux.HandleFunc("GET /dashboard/", h.requireAuth(h.handleOverview)) + h.mux.HandleFunc("GET /dashboard/approvals", h.requireAuth(h.handleApprovals)) + h.mux.HandleFunc("POST /dashboard/approvals/{id}/approve", h.requireAuth(h.handleApprove)) + h.mux.HandleFunc("POST /dashboard/approvals/{id}/deny", h.requireAuth(h.handleDeny)) + h.mux.HandleFunc("GET /dashboard/sessions", h.requireAuth(h.handleSessions)) + h.mux.HandleFunc("POST /dashboard/sessions/{id}/revoke", h.requireAuth(h.handleRevoke)) + h.mux.HandleFunc("GET /dashboard/audit", h.requireAuth(h.handleAudit)) + h.mux.HandleFunc("GET /dashboard/roles", h.requireAuth(h.handleRoles)) + h.mux.Handle("GET /dashboard/static/", http.StripPrefix("/dashboard/static/", staticHandler)) + + return h, nil +} + +// ServeHTTP implements http.Handler. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.mux.ServeHTTP(w, r) +} + +// --- Auth --- + +func (h *Handler) handleLoginPage(w http.ResponseWriter, r *http.Request) { + h.render(w, "login.html", map[string]interface{}{ + "Error": r.URL.Query().Get("error"), + "Blocked": r.URL.Query().Get("blocked") == "1", + }) +} + +func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { + clientIP := extractClientIP(r) + + // Rate limit check + if err := h.loginRL.check(clientIP); err != nil { + if h.deps.Audit != nil { + h.deps.Audit.LogDenied("dashboard", "dashboard.login", "rate_limited", clientIP, err.Error()) + } + http.Redirect(w, r, "/dashboard/login?error="+escapeFlash(err.Error()), http.StatusSeeOther) + return + } + + credential := r.FormValue("credential") + if !h.checkCredential(credential) { + h.loginRL.recordFailure(clientIP) + if h.deps.Audit != nil { + h.deps.Audit.LogDenied("dashboard", "dashboard.login", "login", clientIP, "invalid_credentials") + } + http.Redirect(w, r, "/dashboard/login?error=invalid+credentials", http.StatusSeeOther) + return + } + + // Single-session enforcement: reject if another session is active + if !h.allowMultiLogin { + h.nonceMu.Lock() + active := h.activeNonce != "" + h.nonceMu.Unlock() + if active { + if h.deps.Audit != nil { + h.deps.Audit.LogDenied("dashboard", "dashboard.login", "login", clientIP, "session_already_active") + } + http.Redirect(w, r, "/dashboard/login?error=another+session+is+active&blocked=1", http.StatusSeeOther) + return + } + } + + h.loginRL.recordSuccess(clientIP) + h.mintDashboardSession(w, r, clientIP, "dashboard.login") +} + +// handleForceLogin authenticates and takes over, clearing any existing session. +// This is the escape hatch when the active session is orphaned (browser closed, etc.). +func (h *Handler) handleForceLogin(w http.ResponseWriter, r *http.Request) { + clientIP := extractClientIP(r) + + if err := h.loginRL.check(clientIP); err != nil { + if h.deps.Audit != nil { + h.deps.Audit.LogDenied("dashboard", "dashboard.force_login", "rate_limited", clientIP, err.Error()) + } + http.Redirect(w, r, "/dashboard/login?error="+escapeFlash(err.Error()), http.StatusSeeOther) + return + } + + credential := r.FormValue("credential") + if !h.checkCredential(credential) { + h.loginRL.recordFailure(clientIP) + if h.deps.Audit != nil { + h.deps.Audit.LogDenied("dashboard", "dashboard.force_login", "login", clientIP, "invalid_credentials") + } + http.Redirect(w, r, "/dashboard/login?error=invalid+credentials&blocked=1", http.StatusSeeOther) + return + } + + h.loginRL.recordSuccess(clientIP) + + // Clear existing session nonce — the old cookie becomes invalid + if h.deps.Audit != nil { + h.deps.Audit.LogAllowed("dashboard@"+clientIP, "dashboard.force_login", "login", clientIP) + } + + h.mintDashboardSession(w, r, clientIP, "dashboard.force_login") +} + +// mintDashboardSession creates a new dashboard cookie and sets the active nonce. +func (h *Handler) mintDashboardSession(w http.ResponseWriter, r *http.Request, clientIP, auditAction string) { + // Generate CSRF token and session nonce + csrfBytes := make([]byte, 16) + rand.Read(csrfBytes) + csrf := hex.EncodeToString(csrfBytes) + + nonceBytes := make([]byte, 16) + rand.Read(nonceBytes) + nonce := hex.EncodeToString(nonceBytes) + + // Set active nonce (invalidates any prior session) + h.nonceMu.Lock() + h.activeNonce = nonce + h.nonceMu.Unlock() + + payload := cookiePayload{ + Exp: time.Now().Add(h.sessionTTL).Unix(), + CSRF: csrf, + Nonce: nonce, + } + cookie, err := h.signCookie(payload) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "phoenix_dash", + Value: cookie, + Path: "/dashboard/", + HttpOnly: true, + Secure: isTLS(r), + SameSite: http.SameSiteStrictMode, + MaxAge: int(h.sessionTTL.Seconds()), + }) + + if h.deps.Audit != nil { + h.deps.Audit.LogAllowed("dashboard@"+clientIP, auditAction, "login", clientIP) + } + + http.Redirect(w, r, "/dashboard/", http.StatusSeeOther) +} + +// checkCredential verifies the password hash or PIN. +func (h *Handler) checkCredential(credential string) bool { + if h.passwordH != nil { + return bcrypt.CompareHashAndPassword(h.passwordH, []byte(credential)) == nil + } + if h.pin != "" { + return len(credential) > 0 && hmac.Equal([]byte(h.pin), []byte(credential)) + } + return false +} + +func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) { + clientIP := extractClientIP(r) + + // Clear active nonce so a new login can proceed + h.nonceMu.Lock() + h.activeNonce = "" + h.nonceMu.Unlock() + + http.SetCookie(w, &http.Cookie{ + Name: "phoenix_dash", + Value: "", + Path: "/dashboard/", + HttpOnly: true, + MaxAge: -1, + }) + if h.deps.Audit != nil { + h.deps.Audit.LogAllowed("dashboard@"+clientIP, "dashboard.logout", "logout", clientIP) + } + http.Redirect(w, r, "/dashboard/login", http.StatusSeeOther) +} + +// requireAuth wraps a handler with cookie auth and CSRF validation for POST. +func (h *Handler) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + payload, err := h.verifyCookie(r) + if err != nil { + // Audit expired/invalid dashboard sessions (but not missing cookies — + // those are just unauthenticated first visits, not security events). + if h.deps.Audit != nil { + if _, cookieErr := r.Cookie("phoenix_dash"); cookieErr == nil { + // Cookie was present but invalid/expired + clientIP := extractClientIP(r) + h.deps.Audit.LogDenied("dashboard", "dashboard.auth", r.URL.Path, clientIP, err.Error()) + } + } + http.Redirect(w, r, "/dashboard/login", http.StatusSeeOther) + return + } + + // CSRF check on POST + if r.Method == "POST" { + formCSRF := r.FormValue("_csrf") + if !hmac.Equal([]byte(payload.CSRF), []byte(formCSRF)) { + if h.deps.Audit != nil { + clientIP := extractClientIP(r) + h.deps.Audit.LogDenied("dashboard", "dashboard.csrf", r.URL.Path, clientIP, "csrf_token_mismatch") + } + http.Error(w, "CSRF validation failed", http.StatusForbidden) + return + } + } + + // Inject CSRF into request context via query param for templates + r.Header.Set("X-Csrf-Token", payload.CSRF) + next(w, r) + } +} + +func (h *Handler) signCookie(p cookiePayload) (string, error) { + data, err := json.Marshal(p) + if err != nil { + return "", err + } + mac := hmac.New(sha256.New, h.cookieKey) + mac.Write(data) + sig := hex.EncodeToString(mac.Sum(nil)) + return hex.EncodeToString(data) + "." + sig, nil +} + +func (h *Handler) verifyCookie(r *http.Request) (*cookiePayload, error) { + c, err := r.Cookie("phoenix_dash") + if err != nil { + return nil, err + } + + parts := strings.SplitN(c.Value, ".", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid cookie format") + } + + data, err := hex.DecodeString(parts[0]) + if err != nil { + return nil, err + } + + sig, err := hex.DecodeString(parts[1]) + if err != nil { + return nil, err + } + + mac := hmac.New(sha256.New, h.cookieKey) + mac.Write(data) + if !hmac.Equal(mac.Sum(nil), sig) { + return nil, fmt.Errorf("invalid signature") + } + + var p cookiePayload + if err := json.Unmarshal(data, &p); err != nil { + return nil, err + } + + if time.Now().Unix() > p.Exp { + // Clear nonce on expiry so a new login can proceed + if !h.allowMultiLogin { + h.nonceMu.Lock() + if h.activeNonce == p.Nonce { + h.activeNonce = "" + } + h.nonceMu.Unlock() + } + return nil, fmt.Errorf("cookie expired") + } + + // Single-session: verify cookie's nonce matches the active session + if !h.allowMultiLogin { + h.nonceMu.Lock() + active := h.activeNonce + h.nonceMu.Unlock() + if active != p.Nonce { + return nil, fmt.Errorf("session superseded") + } + } + + return &p, nil +} + +// --- Page Handlers --- + +func (h *Handler) handleOverview(w http.ResponseWriter, r *http.Request) { + // Gather stats + secretCount := 0 + if h.deps.Backend != nil { + secretCount = h.deps.Backend.Count() + } + + agentCount := 0 + if h.deps.ACL != nil { + agentCount = len(h.deps.ACL.ListAgents()) + } + + activeSessionCount := 0 + if h.deps.Sessions != nil { + activeSessionCount = h.deps.Sessions.ActiveCount() + } + + pendingApprovals := 0 + if h.deps.Approvals != nil { + pendingApprovals = len(h.deps.Approvals.ListPending()) + } + + // Recent audit + var recentAudit []audit.Entry + if h.deps.AuditPath != "" { + recentAudit, _ = audit.Query(h.deps.AuditPath, audit.QueryOptions{Limit: 10}) + } + // Reverse for newest first + for i, j := 0, len(recentAudit)-1; i < j; i, j = i+1, j-1 { + recentAudit[i], recentAudit[j] = recentAudit[j], recentAudit[i] + } + + h.render(w, "overview.html", map[string]interface{}{ + "CSRF": r.Header.Get("X-Csrf-Token"), + "Active": "overview", + "SecretCount": secretCount, + "AgentCount": agentCount, + "SessionCount": activeSessionCount, + "PendingApprovals": pendingApprovals, + "Uptime": fmtAgo(h.deps.StartTime), + "RecentAudit": recentAudit, + "SessionsEnabled": h.deps.Sessions != nil, + }) +} + +func (h *Handler) handleApprovals(w http.ResponseWriter, r *http.Request) { + if h.deps.Approvals == nil { + h.render(w, "approvals.html", map[string]interface{}{ + "CSRF": r.Header.Get("X-Csrf-Token"), + "Active": "approvals", + "Pending": nil, + "Resolved": nil, + "Flash": r.URL.Query().Get("flash"), + }) + return + } + + all := h.deps.Approvals.ListAll() + + var pending, resolved []*approval.Approval + for _, a := range all { + if a.Status == approval.StatusPending { + pending = append(pending, a) + } else { + resolved = append(resolved, a) + } + } + + // Sort pending by created (oldest first), resolved by created (newest first) + sort.Slice(pending, func(i, j int) bool { + return pending[i].CreatedAt.Before(pending[j].CreatedAt) + }) + sort.Slice(resolved, func(i, j int) bool { + return resolved[i].CreatedAt.After(resolved[j].CreatedAt) + }) + + h.render(w, "approvals.html", map[string]interface{}{ + "CSRF": r.Header.Get("X-Csrf-Token"), + "Active": "approvals", + "Pending": pending, + "Resolved": resolved, + "Flash": r.URL.Query().Get("flash"), + }) +} + +func (h *Handler) handleApprove(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if h.deps.Approvals == nil { + http.Redirect(w, r, "/dashboard/approvals?flash=approvals+not+configured", http.StatusSeeOther) + return + } + + apr := h.deps.Approvals.Get(id) + if apr == nil { + http.Redirect(w, r, "/dashboard/approvals?flash=approval+not+found", http.StatusSeeOther) + return + } + if apr.Status != approval.StatusPending { + http.Redirect(w, r, "/dashboard/approvals?flash=approval+already+"+string(apr.Status), http.StatusSeeOther) + return + } + + // Shared safety checks + role, valErr := approval.ValidateForMint(apr, h.deps.SessionRoles) + if valErr != nil { + http.Redirect(w, r, "/dashboard/approvals?flash="+escapeFlash(valErr.Error()), http.StatusSeeOther) + return + } + + // Mint session + ttl := sessionTTL(role.MaxTTL) + tokenStr, sess, mintErr := h.deps.Sessions.Create( + apr.Role, apr.Agent, apr.SealPubKey, + role.Namespaces, role.Actions, + apr.BootstrapMethod, apr.CertFingerprint, apr.SourceIP, ttl, + ) + if mintErr != nil { + log.Printf("dashboard: session mint error: %v", mintErr) + http.Redirect(w, r, "/dashboard/approvals?flash=mint+error", http.StatusSeeOther) + return + } + + // Record approval + clientIP := extractClientIP(r) + approverTTY := "dashboard:" + clientIP + if aprErr := h.deps.Approvals.Approve(id, "dashboard", clientIP, approverTTY, tokenStr, sess.ID, sess.ExpiresAt); aprErr != nil { + http.Redirect(w, r, "/dashboard/approvals?flash="+escapeFlash(aprErr.Error()), http.StatusSeeOther) + return + } + + // Audit + if h.deps.Audit != nil { + h.deps.Audit.LogAllowed("dashboard@"+clientIP, "approval.approved", apr.Role, clientIP) + } + + http.Redirect(w, r, "/dashboard/approvals?flash=approved+"+id, http.StatusSeeOther) +} + +func (h *Handler) handleDeny(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if h.deps.Approvals == nil { + http.Redirect(w, r, "/dashboard/approvals?flash=approvals+not+configured", http.StatusSeeOther) + return + } + + clientIP := extractClientIP(r) + if err := h.deps.Approvals.Deny(id, "dashboard", clientIP); err != nil { + http.Redirect(w, r, "/dashboard/approvals?flash="+escapeFlash(err.Error()), http.StatusSeeOther) + return + } + + if h.deps.Audit != nil { + h.deps.Audit.LogAllowed("dashboard@"+clientIP, "approval.denied", id, clientIP) + } + + http.Redirect(w, r, "/dashboard/approvals?flash=denied+"+id, http.StatusSeeOther) +} + +func (h *Handler) handleSessions(w http.ResponseWriter, r *http.Request) { + var sessions []*session.Session + if h.deps.Sessions != nil { + sessions = h.deps.Sessions.List() + } + + roleFilter := r.URL.Query().Get("role") + agentFilter := r.URL.Query().Get("agent") + + if roleFilter != "" || agentFilter != "" { + var filtered []*session.Session + for _, s := range sessions { + if roleFilter != "" && s.Role != roleFilter { + continue + } + if agentFilter != "" && !strings.Contains(s.Agent, agentFilter) { + continue + } + filtered = append(filtered, s) + } + sessions = filtered + } + + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].CreatedAt.After(sessions[j].CreatedAt) + }) + + // Collect role names for filter dropdown + var roleNames []string + for name := range h.deps.SessionRoles { + roleNames = append(roleNames, name) + } + sort.Strings(roleNames) + + h.render(w, "sessions.html", map[string]interface{}{ + "CSRF": r.Header.Get("X-Csrf-Token"), + "Active": "sessions", + "Sessions": sessions, + "Roles": roleNames, + "RoleFilter": roleFilter, + "AgentFilter": agentFilter, + "Flash": r.URL.Query().Get("flash"), + }) +} + +func (h *Handler) handleRevoke(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if h.deps.Sessions == nil { + http.Redirect(w, r, "/dashboard/sessions?flash=sessions+not+configured", http.StatusSeeOther) + return + } + + if err := h.deps.Sessions.Revoke(id); err != nil { + http.Redirect(w, r, "/dashboard/sessions?flash="+escapeFlash(err.Error()), http.StatusSeeOther) + return + } + + clientIP := extractClientIP(r) + if h.deps.Audit != nil { + h.deps.Audit.LogAllowed("dashboard@"+clientIP, "session.revoke", id, clientIP) + } + + http.Redirect(w, r, "/dashboard/sessions?flash=revoked+"+id, http.StatusSeeOther) +} + +func (h *Handler) handleAudit(w http.ResponseWriter, r *http.Request) { + agentFilter := r.URL.Query().Get("agent") + statusFilter := r.URL.Query().Get("status") + limitStr := r.URL.Query().Get("limit") + + limit := 50 + switch limitStr { + case "100": + limit = 100 + case "500": + limit = 500 + } + + opts := audit.QueryOptions{Limit: limit} + if agentFilter != "" { + opts.Agent = agentFilter + } + + var entries []audit.Entry + if h.deps.AuditPath != "" { + entries, _ = audit.Query(h.deps.AuditPath, opts) + } + + // Filter by status client-side (audit.Query doesn't support it) + if statusFilter != "" { + var filtered []audit.Entry + for _, e := range entries { + if e.Status == statusFilter { + filtered = append(filtered, e) + } + } + entries = filtered + } + + // Reverse for newest first + for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 { + entries[i], entries[j] = entries[j], entries[i] + } + + h.render(w, "audit.html", map[string]interface{}{ + "CSRF": r.Header.Get("X-Csrf-Token"), + "Active": "audit", + "Entries": entries, + "AgentFilter": agentFilter, + "StatusFilter": statusFilter, + "Limit": limit, + }) +} + +func (h *Handler) handleRoles(w http.ResponseWriter, r *http.Request) { + // Build ordered role list + type roleDisplay struct { + Name string + Config config.RoleConfig + } + var roles []roleDisplay + for name, cfg := range h.deps.SessionRoles { + roles = append(roles, roleDisplay{Name: name, Config: cfg}) + } + sort.Slice(roles, func(i, j int) bool { + return roles[i].Name < roles[j].Name + }) + + h.render(w, "roles.html", map[string]interface{}{ + "CSRF": r.Header.Get("X-Csrf-Token"), + "Active": "roles", + "Roles": roles, + }) +} + +// --- Rendering --- + +func (h *Handler) render(w http.ResponseWriter, name string, data map[string]interface{}) { + // Count pending approvals for nav badge + if h.deps.Approvals != nil { + data["PendingCount"] = len(h.deps.Approvals.ListPending()) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil { + log.Printf("dashboard: template error: %v", err) + http.Error(w, "template error", http.StatusInternalServerError) + } +} + +// --- Helpers --- + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +func fmtTime(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.Format("2006-01-02 15:04:05 UTC") +} + +func fmtAgo(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60) + default: + return fmt.Sprintf("%dd %dh", int(d.Hours())/24, int(d.Hours())%24) + } +} + +func ttlRemain(t time.Time) string { + d := time.Until(t) + if d <= 0 { + return "expired" + } + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60) + } + return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60) +} + +func sessionTTL(maxTTL string) time.Duration { + if maxTTL == "" { + return 0 + } + d, err := time.ParseDuration(maxTTL) + if err != nil { + return 0 + } + return d +} + +func extractClientIP(r *http.Request) string { + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + return strings.SplitN(fwd, ",", 2)[0] + } + if idx := strings.LastIndex(r.RemoteAddr, ":"); idx != -1 { + return r.RemoteAddr[:idx] + } + return r.RemoteAddr +} + +func escapeFlash(s string) string { + return strings.ReplaceAll(strings.ReplaceAll(s, " ", "+"), "&", "%26") +} + +// isTLS returns true if the request arrived over TLS (direct or via reverse proxy). +func isTLS(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} diff --git a/internal/dashboard/dashboard_test.go b/internal/dashboard/dashboard_test.go new file mode 100644 index 0000000..5ffe123 --- /dev/null +++ b/internal/dashboard/dashboard_test.go @@ -0,0 +1,725 @@ +package dashboard + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/phoenixsec/phoenix/internal/approval" + "github.com/phoenixsec/phoenix/internal/audit" + "github.com/phoenixsec/phoenix/internal/config" + "github.com/phoenixsec/phoenix/internal/session" + + "golang.org/x/crypto/bcrypt" +) + +func testHandler(t *testing.T) (*Handler, *session.Store, *approval.Store) { + t.Helper() + h, ss, as, _ := testHandlerWithAudit(t) + return h, ss, as +} + +func testHandlerWithAudit(t *testing.T) (*Handler, *session.Store, *approval.Store, *bytes.Buffer) { + t.Helper() + ss, err := session.NewStore(time.Hour) + if err != nil { + t.Fatal(err) + } + t.Cleanup(ss.Stop) + + as := approval.NewStore(5 * time.Minute) + t.Cleanup(as.Stop) + + roles := map[string]config.RoleConfig{ + "deploy": { + Namespaces: []string{"prod/*"}, + Actions: []string{"list", "read_value"}, + BootstrapTrust: []string{"bearer"}, + StepUp: true, + }, + } + + var auditBuf bytes.Buffer + al := audit.NewWriterLogger(&auditBuf) + + hash, err := bcrypt.GenerateFromPassword([]byte("testpass"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + + cfg := config.DashboardConfig{ + Enabled: true, + PasswordHash: string(hash), + } + deps := Deps{ + Sessions: ss, + SessionRoles: roles, + Approvals: as, + StartTime: time.Now(), + Audit: al, + } + + h, err := New(cfg, deps) + if err != nil { + t.Fatal(err) + } + return h, ss, as, &auditBuf +} + +// parseAuditEntries reads JSONL audit entries from a buffer. +func parseAuditEntries(buf *bytes.Buffer) []audit.Entry { + var entries []audit.Entry + dec := json.NewDecoder(bytes.NewReader(buf.Bytes())) + for dec.More() { + var e audit.Entry + if err := dec.Decode(&e); err != nil { + break + } + entries = append(entries, e) + } + return entries +} + +// findAuditEntry returns the first entry matching action and status. +func findAuditEntry(entries []audit.Entry, action, status string) *audit.Entry { + for i := range entries { + if entries[i].Action == action && entries[i].Status == status { + return &entries[i] + } + } + return nil +} + +func login(t *testing.T, h *Handler) *http.Cookie { + t.Helper() + form := url.Values{"credential": {"testpass"}} + req := httptest.NewRequest("POST", "/dashboard/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("login: expected 303, got %d", w.Code) + } + cookies := w.Result().Cookies() + for _, c := range cookies { + if c.Name == "phoenix_dash" { + return c + } + } + t.Fatal("no phoenix_dash cookie set") + return nil +} + +func csrfFromCookie(t *testing.T, h *Handler, cookie *http.Cookie) string { + t.Helper() + p, err := h.verifyCookie(&http.Request{Header: http.Header{"Cookie": {cookie.String()}}}) + if err != nil { + t.Fatalf("csrf extraction: %v", err) + } + return p.CSRF +} + +func TestLoginSuccess(t *testing.T) { + h, _, _ := testHandler(t) + cookie := login(t, h) + if cookie.Value == "" { + t.Fatal("expected non-empty cookie") + } +} + +func TestLoginFailure(t *testing.T) { + h, _, _ := testHandler(t) + form := url.Values{"credential": {"wrongpass"}} + req := httptest.NewRequest("POST", "/dashboard/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303 redirect, got %d", w.Code) + } + loc := w.Header().Get("Location") + if !strings.Contains(loc, "error=") { + t.Fatalf("expected error in redirect, got %s", loc) + } +} + +func TestRequireAuth(t *testing.T) { + h, _, _ := testHandler(t) + req := httptest.NewRequest("GET", "/dashboard/", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected redirect to login, got %d", w.Code) + } + loc := w.Header().Get("Location") + if !strings.Contains(loc, "/dashboard/login") { + t.Fatalf("expected redirect to login, got %s", loc) + } +} + +func TestCSRFValidation(t *testing.T) { + h, _, as := testHandler(t) + cookie := login(t, h) + + // Create an approval to have a valid target + apr, _ := as.Create("deploy", "agent1", nil, []string{"prod/*"}, []string{"list"}, "bearer", "", "10.0.0.1", "", time.Hour, 0) + + // POST without CSRF should fail + form := url.Values{} + req := httptest.NewRequest("POST", "/dashboard/approvals/"+apr.ID+"/approve", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for missing CSRF, got %d", w.Code) + } +} + +func TestApproveFlow(t *testing.T) { + h, ss, as := testHandler(t) + cookie := login(t, h) + csrf := csrfFromCookie(t, h, cookie) + + // Create pending approval + apr, err := as.Create("deploy", "agent1", nil, []string{"prod/*"}, []string{"list", "read_value"}, "bearer", "", "10.0.0.1", "", time.Hour, 0) + if err != nil { + t.Fatal(err) + } + + // Approve via dashboard + form := url.Values{"_csrf": {csrf}} + req := httptest.NewRequest("POST", "/dashboard/approvals/"+apr.ID+"/approve", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d (body: %s)", w.Code, w.Body.String()) + } + + // Verify approval is approved + resolved := as.Get(apr.ID) + if resolved.Status != approval.StatusApproved { + t.Fatalf("expected approved, got %s", resolved.Status) + } + + // Verify session was minted + if resolved.SessionID == "" { + t.Fatal("expected session ID on approved approval") + } + + sess := ss.Get(resolved.SessionID) + if sess == nil { + t.Fatal("expected session to exist") + } + if sess.Role != "deploy" { + t.Fatalf("expected role deploy, got %s", sess.Role) + } +} + +func TestDenyFlow(t *testing.T) { + h, _, as := testHandler(t) + cookie := login(t, h) + csrf := csrfFromCookie(t, h, cookie) + + apr, _ := as.Create("deploy", "agent1", nil, []string{"prod/*"}, []string{"list"}, "bearer", "", "10.0.0.1", "", time.Hour, 0) + + form := url.Values{"_csrf": {csrf}} + req := httptest.NewRequest("POST", "/dashboard/approvals/"+apr.ID+"/deny", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d", w.Code) + } + + resolved := as.Get(apr.ID) + if resolved.Status != approval.StatusDenied { + t.Fatalf("expected denied, got %s", resolved.Status) + } +} + +func TestSessionRevoke(t *testing.T) { + h, ss, _ := testHandler(t) + cookie := login(t, h) + csrf := csrfFromCookie(t, h, cookie) + + // Create a session directly + _, sess, err := ss.Create("deploy", "agent1", nil, []string{"prod/*"}, []string{"list"}, "bearer", "", "10.0.0.1", time.Hour) + if err != nil { + t.Fatal(err) + } + + form := url.Values{"_csrf": {csrf}} + req := httptest.NewRequest("POST", "/dashboard/sessions/"+sess.ID+"/revoke", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d", w.Code) + } + + // Verify session is revoked + revoked := ss.Get(sess.ID) + if revoked == nil || !revoked.Revoked { + t.Fatal("expected session to be revoked") + } +} + +func TestLoginRateLimiting(t *testing.T) { + h, _, _ := testHandler(t) + + // Burn through the 5 free failures + for i := 0; i < loginMaxFailures; i++ { + form := url.Values{"credential": {"wrong"}} + req := httptest.NewRequest("POST", "/dashboard/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.RemoteAddr = "10.0.0.99:12345" + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + // These should redirect to login with "invalid credentials" + if w.Code != http.StatusSeeOther { + t.Fatalf("attempt %d: expected 303, got %d", i+1, w.Code) + } + } + + // The next attempt should be rate limited + form := url.Values{"credential": {"wrong"}} + req := httptest.NewRequest("POST", "/dashboard/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.RemoteAddr = "10.0.0.99:12345" + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d", w.Code) + } + loc := w.Header().Get("Location") + if !strings.Contains(loc, "too+many") && !strings.Contains(loc, "too%20many") { + t.Fatalf("expected rate limit message in redirect, got %s", loc) + } + + // A different IP should NOT be rate limited + form2 := url.Values{"credential": {"wrong"}} + req2 := httptest.NewRequest("POST", "/dashboard/login", strings.NewReader(form2.Encode())) + req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req2.RemoteAddr = "10.0.0.100:12345" + w2 := httptest.NewRecorder() + h.ServeHTTP(w2, req2) + + loc2 := w2.Header().Get("Location") + if strings.Contains(loc2, "too+many") || strings.Contains(loc2, "too%20many") { + t.Fatal("different IP should not be rate limited") + } +} + +func TestSecureCookieOnTLS(t *testing.T) { + h, _, _ := testHandler(t) + + // Login via X-Forwarded-Proto: https + form := url.Values{"credential": {"testpass"}} + req := httptest.NewRequest("POST", "/dashboard/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("X-Forwarded-Proto", "https") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d", w.Code) + } + + cookies := w.Result().Cookies() + for _, c := range cookies { + if c.Name == "phoenix_dash" { + if !c.Secure { + t.Fatal("expected Secure flag on cookie when behind TLS proxy") + } + return + } + } + t.Fatal("no phoenix_dash cookie set") +} + +func TestNonSecureCookieOnPlainHTTP(t *testing.T) { + h, _, _ := testHandler(t) + + form := url.Values{"credential": {"testpass"}} + req := httptest.NewRequest("POST", "/dashboard/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + // No TLS, no X-Forwarded-Proto + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + cookies := w.Result().Cookies() + for _, c := range cookies { + if c.Name == "phoenix_dash" { + if c.Secure { + t.Fatal("expected no Secure flag on plain HTTP") + } + return + } + } + t.Fatal("no phoenix_dash cookie set") +} + +// --- Audit event tests --- + +func TestLoginSuccessAudit(t *testing.T) { + h, _, _, buf := testHandlerWithAudit(t) + + form := url.Values{"credential": {"testpass"}} + req := httptest.NewRequest("POST", "/dashboard/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.RemoteAddr = "10.0.0.5:9999" + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d", w.Code) + } + + entries := parseAuditEntries(buf) + e := findAuditEntry(entries, "dashboard.login", "allowed") + if e == nil { + t.Fatal("expected allowed dashboard.login audit entry") + } + if e.Agent != "dashboard@10.0.0.5" { + t.Fatalf("expected agent dashboard@10.0.0.5, got %s", e.Agent) + } + if e.IP != "10.0.0.5" { + t.Fatalf("expected IP 10.0.0.5, got %s", e.IP) + } +} + +func TestLoginFailureAudit(t *testing.T) { + h, _, _, buf := testHandlerWithAudit(t) + + form := url.Values{"credential": {"wrong"}} + req := httptest.NewRequest("POST", "/dashboard/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.RemoteAddr = "10.0.0.5:9999" + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + entries := parseAuditEntries(buf) + e := findAuditEntry(entries, "dashboard.login", "denied") + if e == nil { + t.Fatal("expected denied dashboard.login audit entry") + } + if e.Agent != "dashboard" { + t.Fatalf("expected agent 'dashboard' for pre-login failure, got %s", e.Agent) + } + if e.Reason != "invalid_credentials" { + t.Fatalf("expected reason invalid_credentials, got %s", e.Reason) + } +} + +func TestLoginRateLimitAudit(t *testing.T) { + h, _, _, buf := testHandlerWithAudit(t) + + // Exhaust free attempts + for i := 0; i < loginMaxFailures; i++ { + form := url.Values{"credential": {"wrong"}} + req := httptest.NewRequest("POST", "/dashboard/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.RemoteAddr = "10.0.0.77:9999" + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + } + + // Next attempt should be rate-limited + buf.Reset() // clear prior entries, we only care about the rate-limit one + form := url.Values{"credential": {"wrong"}} + req := httptest.NewRequest("POST", "/dashboard/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.RemoteAddr = "10.0.0.77:9999" + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + entries := parseAuditEntries(buf) + e := findAuditEntry(entries, "dashboard.login", "denied") + if e == nil { + t.Fatal("expected denied audit entry for rate-limited login") + } + if e.Path != "rate_limited" { + t.Fatalf("expected path 'rate_limited', got %s", e.Path) + } +} + +func TestLogoutAudit(t *testing.T) { + h, _, _, buf := testHandlerWithAudit(t) + cookie := login(t, h) + csrf := csrfFromCookie(t, h, cookie) + + buf.Reset() // clear login audit + + form := url.Values{"_csrf": {csrf}} + req := httptest.NewRequest("POST", "/dashboard/logout", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.RemoteAddr = "10.0.0.5:9999" + req.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d", w.Code) + } + + entries := parseAuditEntries(buf) + e := findAuditEntry(entries, "dashboard.logout", "allowed") + if e == nil { + t.Fatal("expected allowed dashboard.logout audit entry") + } + if e.Agent != "dashboard@10.0.0.5" { + t.Fatalf("expected agent dashboard@10.0.0.5, got %s", e.Agent) + } +} + +func TestExpiredCookieAudit(t *testing.T) { + h, _, _, buf := testHandlerWithAudit(t) + + // Login first so there's an active nonce, then craft an expired cookie with it + login(t, h) + + h.nonceMu.Lock() + nonce := h.activeNonce + h.nonceMu.Unlock() + + // Create a cookie that's already expired but has the right nonce + payload := cookiePayload{ + Exp: time.Now().Add(-time.Hour).Unix(), // expired 1h ago + CSRF: "deadbeef", + Nonce: nonce, + } + cookieVal, err := h.signCookie(payload) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/dashboard/", nil) + req.AddCookie(&http.Cookie{Name: "phoenix_dash", Value: cookieVal}) + req.RemoteAddr = "10.0.0.5:9999" + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected redirect, got %d", w.Code) + } + + entries := parseAuditEntries(buf) + e := findAuditEntry(entries, "dashboard.auth", "denied") + if e == nil { + t.Fatal("expected denied dashboard.auth audit entry for expired cookie") + } + if !strings.Contains(e.Reason, "expired") { + t.Fatalf("expected reason containing 'expired', got %s", e.Reason) + } +} + +func TestCSRFFailureAudit(t *testing.T) { + h, _, as, buf := testHandlerWithAudit(t) + cookie := login(t, h) + + apr, _ := as.Create("deploy", "agent1", nil, []string{"prod/*"}, []string{"list"}, "bearer", "", "10.0.0.1", "", time.Hour, 0) + + buf.Reset() + + // POST with wrong CSRF + form := url.Values{"_csrf": {"wrong"}} + req := httptest.NewRequest("POST", "/dashboard/approvals/"+apr.ID+"/approve", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + req.RemoteAddr = "10.0.0.5:9999" + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", w.Code) + } + + entries := parseAuditEntries(buf) + e := findAuditEntry(entries, "dashboard.csrf", "denied") + if e == nil { + t.Fatal("expected denied dashboard.csrf audit entry") + } +} + +func TestLogoutRequiresAuth(t *testing.T) { + h, _, _ := testHandler(t) + + // POST logout without any cookie should redirect to login + form := url.Values{} + req := httptest.NewRequest("POST", "/dashboard/logout", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d", w.Code) + } + loc := w.Header().Get("Location") + if !strings.Contains(loc, "/dashboard/login") { + t.Fatalf("expected redirect to login, got %s", loc) + } +} + +// --- Single-session tests --- + +func TestSingleSessionBlocksSecondLogin(t *testing.T) { + h, _, _ := testHandler(t) // default: allowMultiLogin=false + + // First login succeeds + login(t, h) + + // Second login should be blocked + form := url.Values{"credential": {"testpass"}} + req := httptest.NewRequest("POST", "/dashboard/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d", w.Code) + } + loc := w.Header().Get("Location") + if !strings.Contains(loc, "blocked=1") { + t.Fatalf("expected blocked redirect, got %s", loc) + } +} + +func TestSingleSessionForceLoginTakesOver(t *testing.T) { + h, _, _ := testHandler(t) + + // First login + cookie1 := login(t, h) + + // Force login from second browser + form := url.Values{"credential": {"testpass"}} + req := httptest.NewRequest("POST", "/dashboard/force-login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d", w.Code) + } + + // Old cookie should now be invalid (session superseded) + req2 := httptest.NewRequest("GET", "/dashboard/", nil) + req2.AddCookie(cookie1) + w2 := httptest.NewRecorder() + h.ServeHTTP(w2, req2) + + if w2.Code != http.StatusSeeOther { + t.Fatalf("expected old cookie to redirect to login, got %d", w2.Code) + } +} + +func TestSingleSessionLogoutAllowsNewLogin(t *testing.T) { + h, _, _ := testHandler(t) + + cookie := login(t, h) + csrf := csrfFromCookie(t, h, cookie) + + // Logout + form := url.Values{"_csrf": {csrf}} + req := httptest.NewRequest("POST", "/dashboard/logout", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d", w.Code) + } + + // New login should succeed + cookie2 := login(t, h) + if cookie2.Value == "" { + t.Fatal("expected new cookie after logout") + } +} + +func TestMultiLoginAllowsConcurrentSessions(t *testing.T) { + // Create handler with AllowMultiLogin=true + ss, err := session.NewStore(time.Hour) + if err != nil { + t.Fatal(err) + } + t.Cleanup(ss.Stop) + as := approval.NewStore(5 * time.Minute) + t.Cleanup(as.Stop) + + hash, err := bcrypt.GenerateFromPassword([]byte("testpass"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + + cfg := config.DashboardConfig{ + Enabled: true, + PasswordHash: string(hash), + AllowMultiLogin: true, + } + deps := Deps{ + Sessions: ss, + SessionRoles: map[string]config.RoleConfig{}, + Approvals: as, + StartTime: time.Now(), + } + h, err := New(cfg, deps) + if err != nil { + t.Fatal(err) + } + + // First login + login(t, h) + + // Second login should also succeed + form := url.Values{"credential": {"testpass"}} + req := httptest.NewRequest("POST", "/dashboard/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d", w.Code) + } + loc := w.Header().Get("Location") + if strings.Contains(loc, "blocked") { + t.Fatal("multi-login mode should not block second login") + } +} + +func TestForceLoginRequiresValidCredential(t *testing.T) { + h, _, _ := testHandler(t) + login(t, h) // create active session + + form := url.Values{"credential": {"wrongpass"}} + req := httptest.NewRequest("POST", "/dashboard/force-login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d", w.Code) + } + loc := w.Header().Get("Location") + if !strings.Contains(loc, "error=") { + t.Fatalf("expected error redirect, got %s", loc) + } +} diff --git a/internal/dashboard/static/dashboard.js b/internal/dashboard/static/dashboard.js new file mode 100644 index 0000000..cb0be5d --- /dev/null +++ b/internal/dashboard/static/dashboard.js @@ -0,0 +1,69 @@ +// Phoenix Dashboard — minimal client-side behavior + +(function() { + 'use strict'; + + // Confirm dialogs for destructive actions + document.querySelectorAll('[data-confirm]').forEach(function(btn) { + btn.addEventListener('click', function(e) { + if (!confirm(this.getAttribute('data-confirm'))) { + e.preventDefault(); + } + }); + }); + + // Relative time display + function updateTimes() { + document.querySelectorAll('[data-time]').forEach(function(el) { + var ts = parseInt(el.getAttribute('data-time'), 10); + if (!ts) return; + var diff = Math.floor(Date.now() / 1000) - ts; + if (diff < 0) diff = 0; + var text; + if (diff < 60) { + text = diff + 's ago'; + } else if (diff < 3600) { + text = Math.floor(diff / 60) + 'm ago'; + } else if (diff < 86400) { + var h = Math.floor(diff / 3600); + var m = Math.floor((diff % 3600) / 60); + text = h + 'h ' + m + 'm ago'; + } else { + var d = Math.floor(diff / 86400); + text = d + 'd ago'; + } + el.textContent = text; + }); + } + updateTimes(); + setInterval(updateTimes, 15000); + + // Auto-refresh audit page every 30s + var auditTable = document.getElementById('audit-table'); + if (auditTable) { + setInterval(function() { + var url = window.location.href; + fetch(url, { credentials: 'same-origin' }) + .then(function(r) { return r.text(); }) + .then(function(html) { + var parser = new DOMParser(); + var doc = parser.parseFromString(html, 'text/html'); + var newTable = doc.getElementById('audit-table'); + if (newTable) { + auditTable.innerHTML = newTable.innerHTML; + updateTimes(); + } + }) + .catch(function() {}); // silently fail + }, 30000); + } + + // Auto-dismiss flash messages after 5s + document.querySelectorAll('.flash').forEach(function(el) { + setTimeout(function() { + el.style.transition = 'opacity 0.3s'; + el.style.opacity = '0'; + setTimeout(function() { el.remove(); }, 300); + }, 5000); + }); +})(); diff --git a/internal/dashboard/static/phoenix-icon.svg b/internal/dashboard/static/phoenix-icon.svg new file mode 100644 index 0000000..4ba3074 --- /dev/null +++ b/internal/dashboard/static/phoenix-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/internal/dashboard/static/style.css b/internal/dashboard/static/style.css new file mode 100644 index 0000000..cb573a7 --- /dev/null +++ b/internal/dashboard/static/style.css @@ -0,0 +1,204 @@ +/* Phoenix Dashboard — Dark industrial security operations */ + +:root { + --bg-body: #1a1a1e; + --bg-sidebar: #141417; + --bg-card: #232328; + --bg-input: #2a2a2e; + --border: #333338; + --text-primary: #e0e0e0; + --text-secondary: #888; + --accent: linear-gradient(135deg, #E85D26, #FF8C00); + --accent-solid: #E85D26; + --green: #2ea043; + --red: #da3633; + --orange: #d29922; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg-body); + color: var(--text-primary); + display: flex; + min-height: 100vh; +} + +.mono { font-family: "SF Mono", "Cascadia Code", "Fira Code", Consolas, monospace; font-size: 0.85em; } + +/* Sidebar */ +.sidebar { + width: 220px; + background: var(--bg-sidebar); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: fixed; + top: 0; + left: 0; + height: 100vh; + z-index: 10; +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: 10px; + padding: 20px 16px; + border-bottom: 1px solid var(--border); +} + +.sidebar-icon { width: 28px; height: 28px; } +.sidebar-title { font-size: 1.1em; font-weight: 600; background: var(--accent); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } + +.nav-link { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + color: var(--text-secondary); + text-decoration: none; + border-left: 3px solid transparent; + transition: all 0.15s; + font-size: 0.9em; +} + +.nav-link:hover { color: var(--text-primary); background: rgba(255,255,255,0.03); } +.nav-link.active { color: var(--text-primary); border-image: var(--accent) 1; background: rgba(232,93,38,0.08); } +.nav-icon { width: 16px; text-align: center; } +.badge { background: var(--accent-solid); color: #fff; border-radius: 10px; padding: 1px 7px; font-size: 0.75em; margin-left: auto; } + +.sidebar-footer { margin-top: auto; border-top: 1px solid var(--border); } +.logout-btn { width: 100%; background: none; border: none; cursor: pointer; text-align: left; font-size: 0.9em; } + +/* Content */ +.content { margin-left: 220px; padding: 24px 32px; flex: 1; min-width: 0; } +.page-title { font-size: 1.4em; font-weight: 600; margin-bottom: 20px; } +.section-title { font-size: 1.1em; font-weight: 600; margin: 24px 0 12px; color: var(--text-secondary); cursor: pointer; } + +/* Stat cards */ +.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 24px; } +.stat-card { background: var(--bg-card); border-radius: 8px; border-left: 4px solid; border-image: var(--accent) 1; padding: 20px; } +.stat-value { font-size: 2em; font-weight: 700; background: var(--accent); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } +.stat-label { color: var(--text-secondary); font-size: 0.85em; margin-top: 4px; } + +/* Info card */ +.info-card { background: var(--bg-card); border-radius: 8px; padding: 20px; margin-bottom: 24px; } +.info-card h2 { font-size: 1em; margin-bottom: 12px; color: var(--text-secondary); } +.info-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 0.9em; } +.info-row:last-child { border-bottom: none; } +.info-label { color: var(--text-secondary); } + +/* Tables */ +.table-wrap { overflow-x: auto; margin-bottom: 24px; } +.data-table { width: 100%; border-collapse: collapse; font-size: 0.85em; } +.data-table th { text-align: left; padding: 10px 12px; color: var(--text-secondary); border-bottom: 2px solid; border-image: var(--accent) 1; font-weight: 600; white-space: nowrap; } +.data-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); } +.data-table tbody tr:nth-child(even) { background: rgba(255,255,255,0.02); } +.data-table tbody tr:hover { background: rgba(232,93,38,0.04); } + +.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; } +.status-dot.status-allowed { background: var(--green); } +.status-dot.status-denied { background: var(--red); } + +.status-badge { padding: 2px 8px; border-radius: 4px; font-size: 0.8em; font-weight: 600; } +.status-badge.status-approved { background: rgba(46,160,67,0.15); color: var(--green); } +.status-badge.status-denied { background: rgba(218,54,51,0.15); color: var(--red); } +.status-badge.status-expired { background: rgba(210,153,34,0.15); color: var(--orange); } + +.ttl-expired { color: var(--red); } +.ttl-active { color: var(--green); } + +/* Approval cards */ +.approval-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 16px; margin-bottom: 24px; } +.approval-card { background: var(--bg-card); border-radius: 8px; border-left: 4px solid; border-image: var(--accent) 1; padding: 16px; } +.approval-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } +.approval-role { font-weight: 700; font-size: 1.1em; } +.approval-agent { color: var(--text-secondary); font-size: 0.9em; } +.approval-details { margin-bottom: 12px; } +.detail-row { display: flex; justify-content: space-between; padding: 3px 0; font-size: 0.85em; } +.detail-label { color: var(--text-secondary); min-width: 90px; } +.approval-actions { display: flex; gap: 8px; margin-bottom: 8px; } +.approval-id { font-size: 0.75em; color: var(--text-secondary); } + +/* Role cards */ +.role-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; } +.role-card { background: var(--bg-card); border-radius: 8px; border-left: 4px solid; border-image: var(--accent) 1; padding: 16px; } +.role-name { font-size: 1.1em; margin-bottom: 12px; } +.pill-group { margin-bottom: 8px; } +.pill-label { font-size: 0.75em; color: var(--text-secondary); display: block; margin-bottom: 4px; } +.pill { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.8em; margin: 2px 2px; font-family: "SF Mono", Consolas, monospace; } +.pill-ns { background: rgba(232,93,38,0.15); color: #FF8C00; } +.pill-action { background: rgba(46,160,67,0.15); color: var(--green); } +.pill-bootstrap { background: rgba(56,139,253,0.15); color: #58a6ff; } +.pill-attest { background: rgba(210,153,34,0.15); color: var(--orange); } +.role-flags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } +.flag { padding: 3px 10px; border-radius: 4px; font-size: 0.8em; font-weight: 600; } +.flag-stepup { background: rgba(218,54,51,0.15); color: var(--red); } +.flag-seal { background: rgba(210,153,34,0.15); color: var(--orange); } +.flag-ttl { background: rgba(255,255,255,0.05); color: var(--text-secondary); } + +/* Buttons */ +.btn { + display: inline-flex; align-items: center; justify-content: center; + padding: 8px 16px; border-radius: 6px; font-size: 0.85em; font-weight: 600; + border: none; cursor: pointer; min-height: 44px; min-width: 44px; + transition: opacity 0.15s; +} +.btn:hover { opacity: 0.85; } +.btn-sm { padding: 4px 12px; min-height: 32px; font-size: 0.8em; } +.btn-primary { background: var(--accent); color: #fff; } +.btn-approve { background: var(--green); color: #fff; } +.btn-deny { background: var(--red); color: #fff; } +.btn-revoke { background: transparent; color: var(--orange); border: 1px solid var(--orange); } + +/* Forms / Inputs */ +.input { + background: var(--bg-input); color: var(--text-primary); + border: 1px solid var(--border); border-radius: 6px; + padding: 10px 14px; font-size: 0.9em; width: 100%; + transition: border-color 0.15s, box-shadow 0.15s; +} +.input:focus { outline: none; border-color: var(--accent-solid); box-shadow: 0 0 0 2px rgba(232,93,38,0.2); } +.input-sm { padding: 6px 10px; width: auto; } + +.filter-bar { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; } + +/* Flash messages */ +.flash { padding: 10px 16px; border-radius: 6px; margin-bottom: 16px; font-size: 0.9em; } +.flash-info { background: rgba(232,93,38,0.1); border: 1px solid rgba(232,93,38,0.3); } +.flash-error { background: rgba(218,54,51,0.1); border: 1px solid rgba(218,54,51,0.3); color: var(--red); } + +/* Empty state */ +.empty-state { text-align: center; padding: 48px 0; color: var(--text-secondary); } +.empty-icon { font-size: 2em; display: block; margin-bottom: 8px; color: var(--green); } + +/* Resolved section */ +.resolved-section { margin-top: 24px; } + +/* Login */ +.login-body { justify-content: center; align-items: center; } +.login-card { background: var(--bg-card); border-radius: 12px; padding: 40px; text-align: center; width: 100%; max-width: 360px; } +.login-icon { width: 64px; height: 64px; margin-bottom: 16px; } +.login-title { font-size: 1.4em; margin-bottom: 24px; background: var(--accent); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } +.login-card .input { margin-bottom: 16px; } +.login-card .btn { width: 100%; } +.login-hint { font-size: 0.85em; color: var(--orange); margin-bottom: 16px; line-height: 1.4; } + +/* Mobile */ +@media (max-width: 768px) { + .sidebar { + position: fixed; bottom: 0; top: auto; left: 0; right: 0; + width: 100%; height: auto; flex-direction: row; + border-right: none; border-top: 1px solid var(--border); + overflow-x: auto; + } + .sidebar-brand, .sidebar-footer { display: none; } + .nav-link { flex-direction: column; gap: 2px; padding: 8px 12px; font-size: 0.7em; border-left: none; border-top: 3px solid transparent; } + .nav-link.active { border-image: var(--accent) 1; } + .content { margin-left: 0; padding: 16px; padding-bottom: 80px; } + .approval-grid { grid-template-columns: 1fr; } + .stat-grid { grid-template-columns: repeat(2, 1fr); } + .role-grid { grid-template-columns: 1fr; } +} diff --git a/internal/dashboard/templates/approvals.html b/internal/dashboard/templates/approvals.html new file mode 100644 index 0000000..7835fa2 --- /dev/null +++ b/internal/dashboard/templates/approvals.html @@ -0,0 +1,76 @@ +{{template "layout" .}} +{{define "content"}} +

Approvals

+ +{{if .Flash}}
{{.Flash}}
{{end}} + +{{if .Pending}} +
+{{range .Pending}} +
+
+ {{.Role}} + {{.Agent}} +
+
+
Source IP{{.SourceIP}}
+
Bootstrap{{.BootstrapMethod}}
+ {{if .CertFingerprint}}
Cert{{truncate .CertFingerprint 24}}
{{end}} +
Namespaces{{join .Namespaces ", "}}
+ {{if .RequesterTTY}}
TTY{{.RequesterTTY}}
{{end}} +
Created{{fmtTime .CreatedAt}}
+
Expires{{ttlRemain .ExpiresAt}}
+
+
+
+ + +
+
+ + +
+
+
{{.ID}}
+
+{{end}} +
+{{else}} +
+ +

No pending approvals

+
+{{end}} + +{{if .Resolved}} +
+ Recently Resolved ({{len .Resolved}}) +
+ + + + + + + + + + + + + {{range .Resolved}} + + + + + + + + + {{end}} + +
IDRoleAgentStatusResolved ByTime
{{truncate .ID 16}}{{.Role}}{{.Agent}}{{.Status}}{{.ApprovedBy}}{{fmtTime .CreatedAt}}
+
+
+{{end}} +{{end}} diff --git a/internal/dashboard/templates/audit.html b/internal/dashboard/templates/audit.html new file mode 100644 index 0000000..69efd20 --- /dev/null +++ b/internal/dashboard/templates/audit.html @@ -0,0 +1,56 @@ +{{template "layout" .}} +{{define "content"}} +

Audit Log

+ +
+ + + + +
+ +{{if .Entries}} +
+ + + + + + + + + + + + + + + {{range .Entries}} + + + + + + + + + + + {{end}} + +
TimestampAgentActionPathStatusIPSessionReason
{{fmtTime .Timestamp}}{{.Agent}}{{.Action}}{{truncate .Path 30}} {{.Status}}{{.IP}}{{truncate .SessionID 12}}{{truncate .Reason 40}}
+
+{{else}} +
+

No audit entries

+
+{{end}} +{{end}} diff --git a/internal/dashboard/templates/layout.html b/internal/dashboard/templates/layout.html new file mode 100644 index 0000000..3f85e2e --- /dev/null +++ b/internal/dashboard/templates/layout.html @@ -0,0 +1,44 @@ +{{define "layout"}} + + + + + Phoenix Dashboard + + + + + +
+ {{template "content" .}} +
+ + +{{end}} diff --git a/internal/dashboard/templates/login.html b/internal/dashboard/templates/login.html new file mode 100644 index 0000000..17bbcbe --- /dev/null +++ b/internal/dashboard/templates/login.html @@ -0,0 +1,29 @@ + + + + + + Phoenix - Login + + + + + + + diff --git a/internal/dashboard/templates/overview.html b/internal/dashboard/templates/overview.html new file mode 100644 index 0000000..e7abf14 --- /dev/null +++ b/internal/dashboard/templates/overview.html @@ -0,0 +1,57 @@ +{{template "layout" .}} +{{define "content"}} +

Overview

+ +
+
+
{{.SecretCount}}
+
Secrets
+
+
+
{{.AgentCount}}
+
Agents
+
+
+
{{.SessionCount}}
+
Active Sessions
+
+
+
{{.PendingApprovals}}
+
Pending Approvals
+
+
+ +
+

Server

+
Uptime{{.Uptime}}
+
Sessions{{if .SessionsEnabled}}enabled{{else}}disabled{{end}}
+
+ +{{if .RecentAudit}} +

Recent Activity

+
+ + + + + + + + + + + + {{range .RecentAudit}} + + + + + + + + {{end}} + +
TimeAgentActionPathStatus
{{fmtTime .Timestamp}}{{.Agent}}{{.Action}}{{truncate .Path 40}} {{.Status}}
+
+{{end}} +{{end}} diff --git a/internal/dashboard/templates/roles.html b/internal/dashboard/templates/roles.html new file mode 100644 index 0000000..e8b8d85 --- /dev/null +++ b/internal/dashboard/templates/roles.html @@ -0,0 +1,43 @@ +{{template "layout" .}} +{{define "content"}} +

Roles

+ +{{if .Roles}} +
+{{range .Roles}} +
+

{{.Name}}

+
+ Namespaces + {{range .Config.Namespaces}}{{.}}{{end}} +
+
+ Actions + {{range .Config.Actions}}{{.}}{{end}} + {{if not .Config.Actions}}listread_value{{end}} +
+
+ Bootstrap Trust + {{range .Config.BootstrapTrust}}{{.}}{{end}} +
+
+ {{if .Config.StepUp}}step-up{{end}} + {{if .Config.RequireSealKey}}seal key{{end}} + {{if .Config.MaxTTL}}max TTL: {{.Config.MaxTTL}}{{end}} + {{if .Config.StepUpTTL}}step-up TTL: {{.Config.StepUpTTL}}{{end}} +
+ {{if .Config.Attestation}} +
+ Attestation + {{range .Config.Attestation}}{{.}}{{end}} +
+ {{end}} +
+{{end}} +
+{{else}} +
+

No roles configured

+
+{{end}} +{{end}} diff --git a/internal/dashboard/templates/sessions.html b/internal/dashboard/templates/sessions.html new file mode 100644 index 0000000..fd76df1 --- /dev/null +++ b/internal/dashboard/templates/sessions.html @@ -0,0 +1,61 @@ +{{template "layout" .}} +{{define "content"}} +

Sessions

+ +{{if .Flash}}
{{.Flash}}
{{end}} + +
+ + + +
+ +{{if .Sessions}} +
+ + + + + + + + + + + + + + + {{range .Sessions}} + + + + + + + + + + + {{end}} + +
IDRoleAgentSource IPBootstrapCreatedTTL Remaining
{{truncate .ID 16}}{{.Role}}{{.Agent}}{{.SourceIP}}{{.BootstrapMethod}}{{fmtTime .CreatedAt}} + {{if .Revoked}}revoked{{else}}{{ttlRemain .ExpiresAt}}{{end}} + + {{if not .Revoked}} +
+ + +
+ {{end}} +
+
+{{else}} +
+

No active sessions

+
+{{end}} +{{end}} diff --git a/internal/policy/policy.go b/internal/policy/policy.go index d289a2e..5785860 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -120,6 +120,10 @@ type RequestContext struct { // Seal key validation (set when X-Phoenix-Seal-Key is present and validated) SealKeyPresented bool + + // Session identity (set when authenticated via session token) + SessionID string + SessionRole string } // Engine loads and evaluates attestation policies. diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..8f5f660 --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,237 @@ +// Package session implements v1 session identity for Phoenix. +// +// Agents mint session tokens by authenticating via an existing channel +// (mTLS, bearer, or local) and requesting a role. The session token is +// scoped to the role's namespaces and optionally bound to a seal key +// fingerprint. Tokens are HMAC-SHA256 signed and carry a "phxs_" prefix +// to distinguish them from short-lived attestation tokens. +package session + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +const ( + // TokenPrefix distinguishes session tokens from other token types. + TokenPrefix = "phxs_" + // SessionIDBytes is the number of random bytes in a session ID. + SessionIDBytes = 16 + // SigningKeyBytes is the size of the HMAC signing key. + SigningKeyBytes = 32 + // DefaultTTL is the default session lifetime. + DefaultTTL = 1 * time.Hour +) + +var ( + ErrTokenExpired = errors.New("session token expired") + ErrTokenMalformed = errors.New("malformed session token") + ErrSignatureInvalid = errors.New("invalid session token signature") + ErrSessionRevoked = errors.New("session has been revoked") + ErrSessionNotFound = errors.New("session not found") +) + +// Default actions granted when a role's Actions list is empty. +var DefaultActions = []string{"list", "read_value"} + +// Session represents an active agent session. +type Session struct { + ID string `json:"session_id"` + Role string `json:"role"` + Agent string `json:"agent"` + SealKeyFingerprint string `json:"seal_key_fingerprint,omitempty"` + CertFingerprint string `json:"-"` + Namespaces []string `json:"namespaces"` + Actions []string `json:"actions"` + BootstrapMethod string `json:"bootstrap_method"` + SourceIP string `json:"source_ip"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + Revoked bool `json:"revoked"` +} + +// ActionAllowed returns true if the given action is permitted by this session. +func (s *Session) ActionAllowed(action string) bool { + for _, a := range s.Actions { + if a == action { + return true + } + // "admin" implies everything + if a == "admin" { + return true + } + } + return false +} + +// Claims are the HMAC-signed payload in the session token wire format. +type Claims struct { + SessionID string `json:"sid"` + Role string `json:"role"` + Agent string `json:"agent"` + SealKeyFingerprint string `json:"skfp,omitempty"` + ExpiresAt time.Time `json:"exp"` + IssuedAt time.Time `json:"iat"` +} + +// generateSessionID creates a random hex-encoded session ID. +func generateSessionID() (string, error) { + b := make([]byte, SessionIDBytes) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generating session ID: %w", err) + } + return "ses_" + hex.EncodeToString(b), nil +} + +// mintToken creates a signed session token from claims using the given key. +func mintToken(claims *Claims, signingKey []byte) (string, error) { + payload, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("marshaling session claims: %w", err) + } + + sig := sign(payload, signingKey) + + token := TokenPrefix + + base64.RawURLEncoding.EncodeToString(payload) + + "." + + base64.RawURLEncoding.EncodeToString(sig) + + return token, nil +} + +// validateToken verifies a session token's signature and expiry. +func validateToken(tokenStr string, signingKey []byte) (*Claims, error) { + if !strings.HasPrefix(tokenStr, TokenPrefix) { + return nil, ErrTokenMalformed + } + tokenStr = strings.TrimPrefix(tokenStr, TokenPrefix) + + dot := strings.LastIndexByte(tokenStr, '.') + if dot < 0 { + return nil, ErrTokenMalformed + } + + payloadB64 := tokenStr[:dot] + sigB64 := tokenStr[dot+1:] + + payload, err := base64.RawURLEncoding.DecodeString(payloadB64) + if err != nil { + return nil, ErrTokenMalformed + } + + sig, err := base64.RawURLEncoding.DecodeString(sigB64) + if err != nil { + return nil, ErrTokenMalformed + } + + expected := sign(payload, signingKey) + if !hmac.Equal(sig, expected) { + return nil, ErrSignatureInvalid + } + + var claims Claims + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, ErrTokenMalformed + } + + if time.Now().After(claims.ExpiresAt) { + return nil, ErrTokenExpired + } + + return &claims, nil +} + +// validateTokenNoExpiry verifies signature and parses claims but skips the +// expiry check. Used for audit logging of expired/revoked session tokens +// where we need the agent identity even though the token is no longer valid. +func validateTokenNoExpiry(tokenStr string, signingKey []byte) (*Claims, error) { + if !strings.HasPrefix(tokenStr, TokenPrefix) { + return nil, ErrTokenMalformed + } + tokenStr = strings.TrimPrefix(tokenStr, TokenPrefix) + + dot := strings.LastIndexByte(tokenStr, '.') + if dot < 0 { + return nil, ErrTokenMalformed + } + + payload, err := base64.RawURLEncoding.DecodeString(tokenStr[:dot]) + if err != nil { + return nil, ErrTokenMalformed + } + + sig, err := base64.RawURLEncoding.DecodeString(tokenStr[dot+1:]) + if err != nil { + return nil, ErrTokenMalformed + } + + if !hmac.Equal(sig, sign(payload, signingKey)) { + return nil, ErrSignatureInvalid + } + + var claims Claims + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, ErrTokenMalformed + } + + return &claims, nil +} + +// sign computes HMAC-SHA256 of the payload. +func sign(payload, key []byte) []byte { + mac := hmac.New(sha256.New, key) + mac.Write(payload) + return mac.Sum(nil) +} + +// SealKeyFingerprint computes a fingerprint of a seal public key. +// Format matches certFingerprint in api.go: "sha256:". +func SealKeyFingerprint(pubKey []byte) string { + h := sha256.Sum256(pubKey) + return fmt.Sprintf("sha256:%X", h[:]) +} + +// PathInScope checks if a secret path falls within any of the given +// namespace patterns. Supports the same glob syntax as ACL rules: +// "*" matches everything, "ns/*" matches one level, "ns/**" matches recursively. +func PathInScope(path string, namespaces []string) bool { + for _, ns := range namespaces { + if matchNamespace(ns, path) { + return true + } + } + return false +} + +func matchNamespace(pattern, path string) bool { + if pattern == "*" || pattern == "**" { + return true + } + if strings.HasSuffix(pattern, "/**") { + prefix := strings.TrimSuffix(pattern, "/**") + return strings.HasPrefix(path, prefix+"/") || path == prefix + } + if strings.HasSuffix(pattern, "/*") { + prefix := strings.TrimSuffix(pattern, "/*") + if !strings.HasPrefix(path, prefix+"/") { + return false + } + rest := strings.TrimPrefix(path, prefix+"/") + return !strings.Contains(rest, "/") + } + return pattern == path +} + +// IsLoopback returns true if the IP address is a loopback address. +func IsLoopback(ip string) bool { + return ip == "127.0.0.1" || ip == "::1" +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go new file mode 100644 index 0000000..1af29e3 --- /dev/null +++ b/internal/session/session_test.go @@ -0,0 +1,216 @@ +package session + +import ( + "crypto/rand" + "testing" + "time" +) + +func TestMintValidateRoundtrip(t *testing.T) { + key := make([]byte, SigningKeyBytes) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + + claims := &Claims{ + SessionID: "ses_test123", + Role: "dev", + Agent: "test-agent", + ExpiresAt: time.Now().Add(time.Hour), + IssuedAt: time.Now(), + } + + token, err := mintToken(claims, key) + if err != nil { + t.Fatalf("mintToken: %v", err) + } + + if token[:len(TokenPrefix)] != TokenPrefix { + t.Fatalf("token missing prefix: %s", token[:10]) + } + + got, err := validateToken(token, key) + if err != nil { + t.Fatalf("validateToken: %v", err) + } + + if got.SessionID != claims.SessionID { + t.Errorf("SessionID = %q, want %q", got.SessionID, claims.SessionID) + } + if got.Role != claims.Role { + t.Errorf("Role = %q, want %q", got.Role, claims.Role) + } + if got.Agent != claims.Agent { + t.Errorf("Agent = %q, want %q", got.Agent, claims.Agent) + } +} + +func TestValidateExpiredToken(t *testing.T) { + key := make([]byte, SigningKeyBytes) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + + claims := &Claims{ + SessionID: "ses_expired", + Role: "dev", + Agent: "test-agent", + ExpiresAt: time.Now().Add(-time.Minute), + IssuedAt: time.Now().Add(-time.Hour), + } + + token, err := mintToken(claims, key) + if err != nil { + t.Fatalf("mintToken: %v", err) + } + + _, err = validateToken(token, key) + if err != ErrTokenExpired { + t.Fatalf("expected ErrTokenExpired, got %v", err) + } +} + +func TestValidateTamperedToken(t *testing.T) { + key := make([]byte, SigningKeyBytes) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + + claims := &Claims{ + SessionID: "ses_tamper", + Role: "dev", + Agent: "test-agent", + ExpiresAt: time.Now().Add(time.Hour), + IssuedAt: time.Now(), + } + + token, err := mintToken(claims, key) + if err != nil { + t.Fatalf("mintToken: %v", err) + } + + // Flip a character in the payload portion + tampered := []byte(token) + tampered[len(TokenPrefix)+5] ^= 0xFF + _, err = validateToken(string(tampered), key) + if err == nil { + t.Fatal("expected error for tampered token") + } +} + +func TestValidateWrongKey(t *testing.T) { + key1 := make([]byte, SigningKeyBytes) + key2 := make([]byte, SigningKeyBytes) + if _, err := rand.Read(key1); err != nil { + t.Fatal(err) + } + if _, err := rand.Read(key2); err != nil { + t.Fatal(err) + } + + claims := &Claims{ + SessionID: "ses_wrongkey", + Role: "dev", + Agent: "test-agent", + ExpiresAt: time.Now().Add(time.Hour), + IssuedAt: time.Now(), + } + + token, err := mintToken(claims, key1) + if err != nil { + t.Fatalf("mintToken: %v", err) + } + + _, err = validateToken(token, key2) + if err != ErrSignatureInvalid { + t.Fatalf("expected ErrSignatureInvalid, got %v", err) + } +} + +func TestValidateMalformedTokens(t *testing.T) { + key := make([]byte, SigningKeyBytes) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + + cases := []string{ + "", + "not-a-token", + "phxs_", + "phxs_nodot", + "phxs_bad.bad", + "wrong_prefix.stuff", + } + + for _, tc := range cases { + _, err := validateToken(tc, key) + if err == nil { + t.Errorf("validateToken(%q): expected error, got nil", tc) + } + } +} + +func TestSealKeyFingerprint(t *testing.T) { + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + + fp := SealKeyFingerprint(key) + if fp[:7] != "sha256:" { + t.Fatalf("fingerprint missing sha256: prefix: %s", fp) + } + if len(fp) != 7+64 { // "sha256:" + 64 hex chars + t.Fatalf("fingerprint wrong length: %d", len(fp)) + } + + // Same input -> same output + fp2 := SealKeyFingerprint(key) + if fp != fp2 { + t.Errorf("fingerprint not deterministic: %s != %s", fp, fp2) + } + + // Different input -> different output + key[0] = 0xFF + fp3 := SealKeyFingerprint(key) + if fp == fp3 { + t.Error("different keys produced same fingerprint") + } +} + +func TestSealKeyFingerprintWithBinding(t *testing.T) { + key := make([]byte, SigningKeyBytes) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + + sealKey := make([]byte, 32) + if _, err := rand.Read(sealKey); err != nil { + t.Fatal(err) + } + + fp := SealKeyFingerprint(sealKey) + + claims := &Claims{ + SessionID: "ses_sealbound", + Role: "secure", + Agent: "test-agent", + SealKeyFingerprint: fp, + ExpiresAt: time.Now().Add(time.Hour), + IssuedAt: time.Now(), + } + + token, err := mintToken(claims, key) + if err != nil { + t.Fatalf("mintToken: %v", err) + } + + got, err := validateToken(token, key) + if err != nil { + t.Fatalf("validateToken: %v", err) + } + + if got.SealKeyFingerprint != fp { + t.Errorf("SealKeyFingerprint = %q, want %q", got.SealKeyFingerprint, fp) + } +} diff --git a/internal/session/store.go b/internal/session/store.go new file mode 100644 index 0000000..e6c3216 --- /dev/null +++ b/internal/session/store.go @@ -0,0 +1,261 @@ +package session + +import ( + "crypto/rand" + "fmt" + "sync" + "time" +) + +const ( + // CleanupInterval is how often expired/revoked sessions are purged. + CleanupInterval = 60 * time.Second +) + +// Store manages session lifecycle: creation, validation, revocation, and cleanup. +type Store struct { + mu sync.RWMutex + sessions map[string]*Session // keyed by session ID + signingKey []byte + defaultTTL time.Duration + stopCh chan struct{} + stopped bool +} + +// NewStore creates a new session store with the given default TTL. +// If defaultTTL is zero, DefaultTTL (1h) is used. +// Call Stop() when the store is no longer needed. +func NewStore(defaultTTL time.Duration) (*Store, error) { + if defaultTTL <= 0 { + defaultTTL = DefaultTTL + } + + key := make([]byte, SigningKeyBytes) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("generating session signing key: %w", err) + } + + s := &Store{ + sessions: make(map[string]*Session), + signingKey: key, + defaultTTL: defaultTTL, + stopCh: make(chan struct{}), + } + + go s.cleanupLoop() + return s, nil +} + +// Create mints a new session and returns the signed token. +func (s *Store) Create(role, agent string, sealPubKey []byte, namespaces, actions []string, bootstrapMethod, certFingerprint, sourceIP string, ttl time.Duration) (string, *Session, error) { + if ttl <= 0 { + ttl = s.defaultTTL + } + + id, err := generateSessionID() + if err != nil { + return "", nil, err + } + + now := time.Now().UTC() + var fingerprint string + if len(sealPubKey) > 0 { + fingerprint = SealKeyFingerprint(sealPubKey) + } + + if len(actions) == 0 { + actions = DefaultActions + } + + sess := &Session{ + ID: id, + Role: role, + Agent: agent, + SealKeyFingerprint: fingerprint, + CertFingerprint: certFingerprint, + Namespaces: namespaces, + Actions: actions, + BootstrapMethod: bootstrapMethod, + SourceIP: sourceIP, + CreatedAt: now, + ExpiresAt: now.Add(ttl), + } + + claims := &Claims{ + SessionID: id, + Role: role, + Agent: agent, + SealKeyFingerprint: fingerprint, + ExpiresAt: sess.ExpiresAt, + IssuedAt: now, + } + + token, err := mintToken(claims, s.signingKey) + if err != nil { + return "", nil, err + } + + s.mu.Lock() + s.sessions[id] = sess + s.mu.Unlock() + + return token, sess, nil +} + +// Renew extends an existing session's expiry and mints a new signed token. +// The session ID is preserved so the audit trail remains continuous. +// Returns ErrSessionNotFound, ErrSessionRevoked, or ErrTokenExpired as appropriate. +func (s *Store) Renew(sessionID string, ttl time.Duration) (string, *Session, error) { + if ttl <= 0 { + ttl = s.defaultTTL + } + + s.mu.Lock() + defer s.mu.Unlock() + + sess, ok := s.sessions[sessionID] + if !ok { + return "", nil, ErrSessionNotFound + } + if sess.Revoked { + return "", nil, ErrSessionRevoked + } + if time.Now().After(sess.ExpiresAt) { + return "", nil, ErrTokenExpired + } + + now := time.Now().UTC() + sess.ExpiresAt = now.Add(ttl) + + claims := &Claims{ + SessionID: sess.ID, + Role: sess.Role, + Agent: sess.Agent, + SealKeyFingerprint: sess.SealKeyFingerprint, + ExpiresAt: sess.ExpiresAt, + IssuedAt: now, + } + + token, err := mintToken(claims, s.signingKey) + if err != nil { + return "", nil, err + } + + return token, sess, nil +} + +// Validate verifies a session token and returns the associated session. +// Returns ErrSessionRevoked if the session was explicitly revoked, +// ErrTokenExpired if the TTL elapsed, or ErrSessionNotFound if the +// session ID in the token doesn't match any active session. +func (s *Store) Validate(tokenStr string) (*Session, error) { + claims, err := validateToken(tokenStr, s.signingKey) + if err != nil { + return nil, err + } + + s.mu.RLock() + sess, ok := s.sessions[claims.SessionID] + s.mu.RUnlock() + + if !ok { + return nil, ErrSessionNotFound + } + + if sess.Revoked { + return nil, ErrSessionRevoked + } + + if time.Now().After(sess.ExpiresAt) { + return nil, ErrTokenExpired + } + + return sess, nil +} + +// Revoke marks a session as revoked. Revocation is idempotent. +func (s *Store) Revoke(sessionID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + sess, ok := s.sessions[sessionID] + if !ok { + return ErrSessionNotFound + } + sess.Revoked = true + return nil +} + +// Get returns a session by ID, or nil if not found. +func (s *Store) Get(sessionID string) *Session { + s.mu.RLock() + defer s.mu.RUnlock() + return s.sessions[sessionID] +} + +// List returns all active (non-expired, non-revoked) sessions. +func (s *Store) List() []*Session { + s.mu.RLock() + defer s.mu.RUnlock() + + now := time.Now() + var result []*Session + for _, sess := range s.sessions { + if !sess.Revoked && now.Before(sess.ExpiresAt) { + result = append(result, sess) + } + } + return result +} + +// ParseClaimsInsecure extracts the agent and session ID from a session token +// without checking expiry. The HMAC signature is still verified to prevent +// logging spoofed identities. Returns ok=false if the token is malformed +// or has an invalid signature. +func (s *Store) ParseClaimsInsecure(tokenStr string) (agent, sessionID string, ok bool) { + claims, err := validateTokenNoExpiry(tokenStr, s.signingKey) + if err != nil { + return "", "", false + } + return claims.Agent, claims.SessionID, true +} + +// ActiveCount returns the number of active sessions. +func (s *Store) ActiveCount() int { + return len(s.List()) +} + +// Stop halts the background cleanup goroutine. +func (s *Store) Stop() { + s.mu.Lock() + defer s.mu.Unlock() + if !s.stopped { + close(s.stopCh) + s.stopped = true + } +} + +func (s *Store) cleanupLoop() { + ticker := time.NewTicker(CleanupInterval) + defer ticker.Stop() + for { + select { + case <-s.stopCh: + return + case <-ticker.C: + s.cleanup() + } + } +} + +func (s *Store) cleanup() { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + for id, sess := range s.sessions { + if sess.Revoked || now.After(sess.ExpiresAt) { + delete(s.sessions, id) + } + } +} diff --git a/internal/session/store_test.go b/internal/session/store_test.go new file mode 100644 index 0000000..3e69666 --- /dev/null +++ b/internal/session/store_test.go @@ -0,0 +1,446 @@ +package session + +import ( + "crypto/rand" + "strings" + "sync" + "testing" + "time" +) + +func newTestStore(t *testing.T, ttl time.Duration) *Store { + t.Helper() + s, err := NewStore(ttl) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + t.Cleanup(s.Stop) + return s +} + +func TestStoreCreateValidate(t *testing.T) { + s := newTestStore(t, time.Hour) + + token, sess, err := s.Create("dev", "agent1", nil, []string{"dev/*"}, nil, "bearer", "", "127.0.0.1", 0) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if !strings.HasPrefix(token, TokenPrefix) { + t.Errorf("token missing prefix") + } + if !strings.HasPrefix(sess.ID, "ses_") { + t.Errorf("session ID missing ses_ prefix: %s", sess.ID) + } + if sess.Role != "dev" { + t.Errorf("Role = %q, want %q", sess.Role, "dev") + } + if sess.Agent != "agent1" { + t.Errorf("Agent = %q, want %q", sess.Agent, "agent1") + } + + got, err := s.Validate(token) + if err != nil { + t.Fatalf("Validate: %v", err) + } + if got.ID != sess.ID { + t.Errorf("session ID mismatch: %s vs %s", got.ID, sess.ID) + } +} + +func TestStoreCreateWithSealKey(t *testing.T) { + s := newTestStore(t, time.Hour) + + sealKey := make([]byte, 32) + if _, err := rand.Read(sealKey); err != nil { + t.Fatal(err) + } + + _, sess, err := s.Create("secure", "agent1", sealKey, []string{"prod/*"}, nil, "mtls", "", "192.168.0.10", 0) + if err != nil { + t.Fatalf("Create: %v", err) + } + + expectedFP := SealKeyFingerprint(sealKey) + if sess.SealKeyFingerprint != expectedFP { + t.Errorf("SealKeyFingerprint = %q, want %q", sess.SealKeyFingerprint, expectedFP) + } +} + +func TestStoreRevoke(t *testing.T) { + s := newTestStore(t, time.Hour) + + token, sess, err := s.Create("dev", "agent1", nil, []string{"dev/*"}, nil, "bearer", "", "127.0.0.1", 0) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if err := s.Revoke(sess.ID); err != nil { + t.Fatalf("Revoke: %v", err) + } + + _, err = s.Validate(token) + if err != ErrSessionRevoked { + t.Fatalf("expected ErrSessionRevoked, got %v", err) + } + + // Double revoke is idempotent + if err := s.Revoke(sess.ID); err != nil { + t.Fatalf("double Revoke: %v", err) + } +} + +func TestStoreRevokeNotFound(t *testing.T) { + s := newTestStore(t, time.Hour) + + err := s.Revoke("ses_nonexistent") + if err != ErrSessionNotFound { + t.Fatalf("expected ErrSessionNotFound, got %v", err) + } +} + +func TestStoreExpiredSession(t *testing.T) { + s := newTestStore(t, 1*time.Millisecond) + + token, _, err := s.Create("dev", "agent1", nil, []string{"dev/*"}, nil, "bearer", "", "127.0.0.1", 1*time.Millisecond) + if err != nil { + t.Fatalf("Create: %v", err) + } + + time.Sleep(5 * time.Millisecond) + + _, err = s.Validate(token) + if err != ErrTokenExpired { + t.Fatalf("expected ErrTokenExpired, got %v", err) + } +} + +func TestStoreGet(t *testing.T) { + s := newTestStore(t, time.Hour) + + _, sess, err := s.Create("dev", "agent1", nil, []string{"dev/*"}, nil, "bearer", "", "127.0.0.1", 0) + if err != nil { + t.Fatalf("Create: %v", err) + } + + got := s.Get(sess.ID) + if got == nil { + t.Fatal("Get returned nil") + } + if got.ID != sess.ID { + t.Errorf("ID mismatch: %s vs %s", got.ID, sess.ID) + } + + if s.Get("ses_nonexistent") != nil { + t.Error("Get returned non-nil for nonexistent session") + } +} + +func TestStoreList(t *testing.T) { + s := newTestStore(t, time.Hour) + + for i := 0; i < 3; i++ { + _, _, err := s.Create("dev", "agent1", nil, []string{"dev/*"}, nil, "bearer", "", "127.0.0.1", 0) + if err != nil { + t.Fatalf("Create %d: %v", i, err) + } + } + + list := s.List() + if len(list) != 3 { + t.Fatalf("List() returned %d sessions, want 3", len(list)) + } + + if s.ActiveCount() != 3 { + t.Fatalf("ActiveCount() = %d, want 3", s.ActiveCount()) + } +} + +func TestStoreListExcludesRevoked(t *testing.T) { + s := newTestStore(t, time.Hour) + + _, sess1, _ := s.Create("dev", "agent1", nil, []string{"dev/*"}, nil, "bearer", "", "127.0.0.1", 0) + s.Create("dev", "agent2", nil, []string{"dev/*"}, nil, "bearer", "", "127.0.0.1", 0) + + s.Revoke(sess1.ID) + + list := s.List() + if len(list) != 1 { + t.Fatalf("List() returned %d sessions, want 1", len(list)) + } +} + +func TestStoreConcurrentAccess(t *testing.T) { + s := newTestStore(t, time.Hour) + + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + token, _, err := s.Create("dev", "agent", nil, []string{"dev/*"}, nil, "bearer", "", "127.0.0.1", 0) + if err != nil { + t.Errorf("Create: %v", err) + return + } + _, err = s.Validate(token) + if err != nil { + t.Errorf("Validate: %v", err) + } + }() + } + wg.Wait() + + if s.ActiveCount() != 50 { + t.Errorf("ActiveCount() = %d, want 50", s.ActiveCount()) + } +} + +func TestStoreCleanup(t *testing.T) { + s := newTestStore(t, time.Hour) + + // Create one that will be "expired" and one active + _, _, err := s.Create("dev", "agent1", nil, []string{"dev/*"}, nil, "bearer", "", "127.0.0.1", 0) + if err != nil { + t.Fatalf("Create: %v", err) + } + + // Manually expire the first session + s.mu.Lock() + for _, sess := range s.sessions { + sess.ExpiresAt = time.Now().Add(-time.Second) + break + } + s.mu.Unlock() + + // Create an active one + _, _, err = s.Create("dev", "agent2", nil, []string{"dev/*"}, nil, "bearer", "", "127.0.0.1", 0) + if err != nil { + t.Fatalf("Create: %v", err) + } + + // Run cleanup directly + s.cleanup() + + s.mu.RLock() + count := len(s.sessions) + s.mu.RUnlock() + + if count != 1 { + t.Errorf("after cleanup: %d sessions, want 1", count) + } +} + +func TestStoreDefaultActions(t *testing.T) { + s := newTestStore(t, time.Hour) + + // nil actions -> defaults to ["list", "read_value"] + _, sess, err := s.Create("dev", "agent1", nil, []string{"dev/*"}, nil, "bearer", "", "127.0.0.1", 0) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if !sess.ActionAllowed("list") { + t.Error("expected list to be allowed") + } + if !sess.ActionAllowed("read_value") { + t.Error("expected read_value to be allowed") + } + if sess.ActionAllowed("write") { + t.Error("expected write to be denied") + } + if sess.ActionAllowed("delete") { + t.Error("expected delete to be denied") + } +} + +func TestStoreExplicitActions(t *testing.T) { + s := newTestStore(t, time.Hour) + + _, sess, err := s.Create("deployer", "agent1", nil, []string{"deploy/*"}, []string{"list", "read_value", "write"}, "bearer", "", "127.0.0.1", 0) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if !sess.ActionAllowed("write") { + t.Error("expected write to be allowed") + } + if sess.ActionAllowed("delete") { + t.Error("expected delete to be denied") + } + if sess.ActionAllowed("admin") { + t.Error("expected admin to be denied") + } +} + +func TestStoreAdminAction(t *testing.T) { + s := newTestStore(t, time.Hour) + + _, sess, err := s.Create("admin", "agent1", nil, []string{"*"}, []string{"admin"}, "bearer", "", "127.0.0.1", 0) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if !sess.ActionAllowed("write") { + t.Error("admin should allow write") + } + if !sess.ActionAllowed("delete") { + t.Error("admin should allow delete") + } + if !sess.ActionAllowed("list") { + t.Error("admin should allow list") + } +} + +func TestStoreRenew(t *testing.T) { + s := newTestStore(t, time.Hour) + + token, sess, err := s.Create("dev", "agent1", nil, []string{"dev/*"}, nil, "bearer", "", "127.0.0.1", 0) + if err != nil { + t.Fatalf("Create: %v", err) + } + + origExpiry := sess.ExpiresAt + time.Sleep(5 * time.Millisecond) + + newToken, renewed, err := s.Renew(sess.ID, 2*time.Hour) + if err != nil { + t.Fatalf("Renew: %v", err) + } + + if newToken == token { + t.Error("expected new token to differ from original") + } + if renewed.ID != sess.ID { + t.Errorf("session ID changed: %s -> %s", sess.ID, renewed.ID) + } + if !renewed.ExpiresAt.After(origExpiry) { + t.Errorf("ExpiresAt not extended: %v -> %v", origExpiry, renewed.ExpiresAt) + } + + // New token should validate + got, err := s.Validate(newToken) + if err != nil { + t.Fatalf("Validate new token: %v", err) + } + if got.ID != sess.ID { + t.Errorf("validated session ID mismatch") + } +} + +func TestStoreRenewExpired(t *testing.T) { + s := newTestStore(t, time.Millisecond) + + _, sess, err := s.Create("dev", "agent1", nil, []string{"dev/*"}, nil, "bearer", "", "127.0.0.1", time.Millisecond) + if err != nil { + t.Fatalf("Create: %v", err) + } + + time.Sleep(5 * time.Millisecond) + + _, _, err = s.Renew(sess.ID, time.Hour) + if err != ErrTokenExpired { + t.Fatalf("expected ErrTokenExpired, got %v", err) + } +} + +func TestStoreRenewRevoked(t *testing.T) { + s := newTestStore(t, time.Hour) + + _, sess, err := s.Create("dev", "agent1", nil, []string{"dev/*"}, nil, "bearer", "", "127.0.0.1", 0) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if err := s.Revoke(sess.ID); err != nil { + t.Fatalf("Revoke: %v", err) + } + + _, _, err = s.Renew(sess.ID, time.Hour) + if err != ErrSessionRevoked { + t.Fatalf("expected ErrSessionRevoked, got %v", err) + } +} + +func TestStoreRenewNotFound(t *testing.T) { + s := newTestStore(t, time.Hour) + + _, _, err := s.Renew("ses_nonexistent", time.Hour) + if err != ErrSessionNotFound { + t.Fatalf("expected ErrSessionNotFound, got %v", err) + } +} + +func TestStoreDefaultTTL(t *testing.T) { + s := newTestStore(t, 0) + + _, sess, err := s.Create("dev", "agent1", nil, []string{"dev/*"}, nil, "bearer", "", "127.0.0.1", 0) + if err != nil { + t.Fatalf("Create: %v", err) + } + + expectedDuration := DefaultTTL + actualDuration := sess.ExpiresAt.Sub(sess.CreatedAt) + // Allow 1 second of drift + if actualDuration < expectedDuration-time.Second || actualDuration > expectedDuration+time.Second { + t.Errorf("session duration = %v, want ~%v", actualDuration, expectedDuration) + } +} + +func TestParseClaimsInsecure(t *testing.T) { + store, err := NewStore(1 * time.Second) // very short TTL + if err != nil { + t.Fatalf("new store: %v", err) + } + defer store.Stop() + + token, sess, err := store.Create("dev", "test-agent", nil, []string{"test/*"}, nil, "bearer", "", "127.0.0.1", 1*time.Second) + if err != nil { + t.Fatalf("create: %v", err) + } + + // Immediately, claims should be parseable + agent, sessID, ok := store.ParseClaimsInsecure(token) + if !ok { + t.Fatal("expected ParseClaimsInsecure to succeed for valid token") + } + if agent != "test-agent" { + t.Errorf("agent = %q, want test-agent", agent) + } + if sessID != sess.ID { + t.Errorf("sessionID = %q, want %q", sessID, sess.ID) + } + + // Wait for token to expire + time.Sleep(2 * time.Second) + + // Validate should fail + _, err = store.Validate(token) + if err == nil { + t.Fatal("expected Validate to fail for expired token") + } + + // ParseClaimsInsecure should still succeed + agent, sessID, ok = store.ParseClaimsInsecure(token) + if !ok { + t.Fatal("expected ParseClaimsInsecure to succeed for expired token") + } + if agent != "test-agent" { + t.Errorf("agent = %q, want test-agent (expired)", agent) + } + if sessID != sess.ID { + t.Errorf("sessionID = %q, want %q (expired)", sessID, sess.ID) + } + + // Tampered token should fail + _, _, ok = store.ParseClaimsInsecure(token + "tampered") + if ok { + t.Error("expected ParseClaimsInsecure to fail for tampered token") + } + + // Garbage should fail + _, _, ok = store.ParseClaimsInsecure("garbage") + if ok { + t.Error("expected ParseClaimsInsecure to fail for garbage input") + } +} diff --git a/internal/version/version.go b/internal/version/version.go index 5dea86f..9bb2df8 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -4,4 +4,4 @@ // go build -ldflags "-X github.com/phoenixsec/phoenix/internal/version.Version=1.0.0" package version -var Version = "0.11.0" +var Version = "0.13.3" diff --git a/sdk/go/phoenix/client.go b/sdk/go/phoenix/client.go index ae9c66d..850bff5 100644 --- a/sdk/go/phoenix/client.go +++ b/sdk/go/phoenix/client.go @@ -17,11 +17,20 @@ import ( // Error represents a Phoenix API error. type Error struct { - Message string - Status int + Message string + Status int + Code string // structured denial code (e.g., SCOPE_EXCEEDED, ACTION_DENIED) + Remediation string // hint for resolving the error } func (e *Error) Error() string { + if e.Code != "" { + msg := fmt.Sprintf("phoenix: HTTP %d [%s]: %s", e.Status, e.Code, e.Message) + if e.Remediation != "" { + msg += "\n hint: " + e.Remediation + } + return msg + } if e.Status > 0 { return fmt.Sprintf("phoenix: HTTP %d: %s", e.Status, e.Message) } @@ -50,8 +59,10 @@ type Client struct { Server string Token string HTTPClient *http.Client + Role string // session role (set after MintSession) sealPubKey string // base64-encoded public key for sealed requests sealPriv *[32]byte // private key for decrypting sealed responses + sessionExp time.Time // token expiry for auto-renewal } // New creates a new Phoenix client. Server and token default to @@ -69,13 +80,33 @@ func New(server, token string) *Client { token = os.Getenv("PHOENIX_TOKEN") } - return &Client{ + c := &Client{ Server: server, Token: token, HTTPClient: &http.Client{ Timeout: 10 * time.Second, }, } + + return c +} + +// NewWithRole creates a client and mints a scoped session for the given role. +// The bootstrap token (from token param or PHOENIX_TOKEN env) is used only for +// the mint request and then replaced with the scoped session token. +// Returns an error if minting fails (fail-closed: no fallback to bootstrap token). +func NewWithRole(server, token, role string) (*Client, error) { + c := New(server, token) + if role == "" { + role = os.Getenv("PHOENIX_ROLE") + } + if role == "" { + return c, nil + } + if err := c.MintSession(role); err != nil { + return nil, fmt.Errorf("session mint for role %q: %w", role, err) + } + return c, nil } // SetSealKey loads a seal private key from a file, enabling sealed mode. @@ -172,7 +203,99 @@ func (c *Client) ResolveBatch(refs []string) (*ResolveResult, error) { return &result, nil } +// MintSession mints a session token for the given role using the current +// token as bootstrap auth. On success, replaces c.Token with the session token. +func (c *Client) MintSession(role string) error { + mintBody := map[string]string{"role": role} + if c.sealPubKey != "" { + mintBody["seal_public_key"] = c.sealPubKey + } + + var result struct { + SessionToken string `json:"session_token"` + ExpiresAt string `json:"expires_at"` + Role string `json:"role"` + } + if err := c.doRequest("POST", "/v1/session/mint", mintBody, &result); err != nil { + return err + } + + c.Token = result.SessionToken + c.Role = result.Role + if exp, err := time.Parse(time.RFC3339, result.ExpiresAt); err == nil { + c.sessionExp = exp + } + return nil +} + +// RenewSession renews the current session token. +// Only works if the client holds a session token (from MintSession). +func (c *Client) RenewSession() error { + var result struct { + SessionToken string `json:"session_token"` + ExpiresAt string `json:"expires_at"` + } + if err := c.doRequest("POST", "/v1/session/renew", map[string]string{}, &result); err != nil { + return err + } + c.Token = result.SessionToken + if exp, err := time.Parse(time.RFC3339, result.ExpiresAt); err == nil { + c.sessionExp = exp + } + return nil +} + +// SessionInfo contains details about an active session. +type SessionInfo struct { + SessionID string `json:"session_id"` + Role string `json:"role"` + Agent string `json:"agent"` + Namespaces []string `json:"namespaces"` + Actions []string `json:"actions"` + BootstrapMethod string `json:"bootstrap_method"` + SourceIP string `json:"source_ip"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at"` + Revoked bool `json:"revoked"` +} + +// ListSessions returns sessions visible to the caller. +func (c *Client) ListSessions() ([]SessionInfo, error) { + var result struct { + Sessions []SessionInfo `json:"sessions"` + } + if err := c.doRequest("GET", "/v1/sessions", nil, &result); err != nil { + return nil, err + } + return result.Sessions, nil +} + +// RevokeSession revokes a session by ID. +func (c *Client) RevokeSession(sessionID string) error { + return c.doRequest("POST", "/v1/sessions/"+sessionID+"/revoke", map[string]string{}, nil) +} + +// IsApprovalRequired returns true if the error indicates approval is needed. +func (e *Error) IsApprovalRequired() bool { return e.Code == "APPROVAL_REQUIRED" } + +// IsSessionExpired returns true if the error indicates the session has expired. +func (e *Error) IsSessionExpired() bool { return e.Code == "SESSION_EXPIRED" } + +// IsScopeExceeded returns true if the error indicates the request is outside session scope. +func (e *Error) IsScopeExceeded() bool { return e.Code == "SCOPE_EXCEEDED" } + +// IsActionDenied returns true if the error indicates the action is not permitted. +func (e *Error) IsActionDenied() bool { return e.Code == "ACTION_DENIED" } + +// IsSessionRevoked returns true if the error indicates the session was revoked. +func (e *Error) IsSessionRevoked() bool { return e.Code == "SESSION_REVOKED" } + func (c *Client) doRequest(method, path string, body interface{}, out interface{}) error { + // Auto-renew session if nearing expiry (within 5 min) + if c.Role != "" && !c.sessionExp.IsZero() && time.Until(c.sessionExp) < 5*time.Minute { + _ = c.RenewSession() // best-effort + } + var bodyReader io.Reader if body != nil { data, err := json.Marshal(body) @@ -208,11 +331,22 @@ func (c *Client) doRequest(method, path string, body interface{}, out interface{ } if resp.StatusCode != http.StatusOK { - var errResp struct { - Error string `json:"error"` + var structured struct { + Error string `json:"error"` + Code string `json:"code"` + Detail string `json:"detail"` + Remediation string `json:"remediation"` + } + if json.Unmarshal(respBody, &structured) == nil && structured.Code != "" { + return &Error{ + Message: structured.Detail, + Status: resp.StatusCode, + Code: structured.Code, + Remediation: structured.Remediation, + } } - if json.Unmarshal(respBody, &errResp) == nil && errResp.Error != "" { - return &Error{Message: errResp.Error, Status: resp.StatusCode} + if json.Unmarshal(respBody, &structured) == nil && structured.Error != "" { + return &Error{Message: structured.Error, Status: resp.StatusCode} } return &Error{Message: fmt.Sprintf("HTTP %d", resp.StatusCode), Status: resp.StatusCode} } diff --git a/sdk/go/phoenix/client_test.go b/sdk/go/phoenix/client_test.go index 5f60c48..5ce474a 100644 --- a/sdk/go/phoenix/client_test.go +++ b/sdk/go/phoenix/client_test.go @@ -2,8 +2,10 @@ package phoenix import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -236,8 +238,123 @@ func TestResolveNoValueReturned(t *testing.T) { } func TestNewDefaults(t *testing.T) { + t.Setenv("PHOENIX_SERVER", "") + t.Setenv("PHOENIX_TOKEN", "") c := New("", "") if c.Server != "http://127.0.0.1:9090" { t.Fatalf("expected default server, got %q", c.Server) } } + +func TestNewWithRoleFailsClosedOnMintError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/session/mint" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{ + "error": "mint denied", + "code": "BOOTSTRAP_FAILED", + }) + })) + defer srv.Close() + + c, err := NewWithRole(srv.URL, "bootstrap-token", "dev") + if err == nil { + t.Fatal("expected mint failure") + } + if c != nil { + t.Fatal("expected nil client on mint failure") + } + if got := fmt.Sprint(err); got == "" || !strings.Contains(got, "session mint for role") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestErrorHelpers(t *testing.T) { + tests := []struct { + name string + err Error + check func(*Error) bool + expect bool + }{ + {"approval required true", Error{Code: "APPROVAL_REQUIRED"}, (*Error).IsApprovalRequired, true}, + {"approval required false", Error{Code: "OTHER"}, (*Error).IsApprovalRequired, false}, + {"session expired true", Error{Code: "SESSION_EXPIRED"}, (*Error).IsSessionExpired, true}, + {"session expired false", Error{Code: "OTHER"}, (*Error).IsSessionExpired, false}, + {"scope exceeded true", Error{Code: "SCOPE_EXCEEDED"}, (*Error).IsScopeExceeded, true}, + {"scope exceeded false", Error{Code: "OTHER"}, (*Error).IsScopeExceeded, false}, + {"action denied true", Error{Code: "ACTION_DENIED"}, (*Error).IsActionDenied, true}, + {"action denied false", Error{Code: "OTHER"}, (*Error).IsActionDenied, false}, + {"session revoked true", Error{Code: "SESSION_REVOKED"}, (*Error).IsSessionRevoked, true}, + {"session revoked false", Error{Code: "OTHER"}, (*Error).IsSessionRevoked, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.check(&tt.err) + if got != tt.expect { + t.Errorf("got %v, want %v", got, tt.expect) + } + }) + } +} + +func TestListSessions(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/sessions" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Method != "GET" { + t.Errorf("unexpected method: %s", r.Method) + } + json.NewEncoder(w).Encode(map[string]interface{}{ + "sessions": []map[string]interface{}{ + { + "session_id": "ses_abc123", + "role": "dev", + "agent": "test-agent", + "created_at": "2026-01-01T00:00:00Z", + "expires_at": "2026-01-01T01:00:00Z", + }, + }, + }) + })) + defer srv.Close() + + c := New(srv.URL, "test-token") + sessions, err := c.ListSessions() + if err != nil { + t.Fatalf("ListSessions: %v", err) + } + if len(sessions) != 1 { + t.Fatalf("expected 1 session, got %d", len(sessions)) + } + if sessions[0].SessionID != "ses_abc123" { + t.Errorf("session_id = %q, want ses_abc123", sessions[0].SessionID) + } + if sessions[0].Role != "dev" { + t.Errorf("role = %q, want dev", sessions[0].Role) + } +} + +func TestRevokeSession(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/sessions/ses_abc123/revoke" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Method != "POST" { + t.Errorf("unexpected method: %s", r.Method) + } + json.NewEncoder(w).Encode(map[string]string{ + "status": "revoked", + "session_id": "ses_abc123", + }) + })) + defer srv.Close() + + c := New(srv.URL, "test-token") + err := c.RevokeSession("ses_abc123") + if err != nil { + t.Fatalf("RevokeSession: %v", err) + } +} diff --git a/sdk/typescript/src/client.test.js b/sdk/typescript/src/client.test.js index 81fcfb5..fdb3097 100644 --- a/sdk/typescript/src/client.test.js +++ b/sdk/typescript/src/client.test.js @@ -130,8 +130,16 @@ describe("PhoenixClient", () => { }); it("defaults to standard server URL", () => { - const c = new PhoenixClient(); - assert.equal(c.server, "http://127.0.0.1:9090"); + const orig = process.env.PHOENIX_SERVER; + delete process.env.PHOENIX_SERVER; + try { + const c = new PhoenixClient(); + assert.equal(c.server, "http://127.0.0.1:9090"); + } finally { + if (orig !== undefined) { + process.env.PHOENIX_SERVER = orig; + } + } }); });