Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ CloudPAM is an intelligent IP Address Management (IPAM) platform designed to man

The project is in **Phase 4** of a 5-phase, 20-week roadmap. See `IMPLEMENTATION_ROADMAP.md` for the complete plan.

**Current State** (Sprint 18 complete):
**Current State** (Sprint 19 complete):
- Auth hardening: auth-always default, CSRF protection, password policy (NIST 800-63B), session limits, login rate limiting, trusted proxies, security settings UI, API key scope elevation prevention (Sprint 19)
- AI Planning: LLM-powered conversational planning with SSE streaming, plan generation and apply (Sprints 17-18)
- AWS Organizations discovery: org-mode agent, cross-account AssumeRole, bulk ingest, Terraform/CF modules, wizard org mode (Sprint 16b)
- Recommendation generator: allocation & compliance recs, scoring, apply/dismiss workflow (Sprint 15)
Expand Down Expand Up @@ -236,7 +237,7 @@ The storage layer uses build tags to switch between implementations:
`internal/api/server.go` implements the REST API and serves the embedded UI:

- **Server struct**: wraps `http.ServeMux` and `storage.Store`
- **Route registration**: `RegisterRoutes()` sets up all endpoints
- **Route registration**: `RegisterProtectedRoutes()` sets up all endpoints with RBAC
- **API endpoints**:
- `/healthz` - health check endpoint
- `/api/v1/pools` - pool CRUD
Expand All @@ -256,6 +257,9 @@ The storage layer uses build tags to switch between implementations:
- `/api/v1/auth/me` - current user/key identity (GET)
- `/api/v1/auth/users` - user management (GET/POST)
- `/api/v1/auth/users/{id}` - user CRUD (GET/PATCH/DELETE)
- `/api/v1/auth/users/{id}/revoke-sessions` - revoke all sessions for a user (POST)
- `/api/v1/auth/setup` - first-boot admin account creation (POST)
- `/api/v1/settings/security` - security settings (GET/PATCH)
- `/api/v1/search` - unified search with CIDR containment queries
- `/api/v1/discovery/resources` - list discovered cloud resources (filterable)
- `/api/v1/discovery/resources/{id}` - get single discovered resource
Expand All @@ -281,7 +285,7 @@ The storage layer uses build tags to switch between implementations:
- `/metrics` - Prometheus metrics endpoint
- `/openapi.yaml` - OpenAPI spec served from embedded `docs/spec_embed.go`
- `/` - serves unified React SPA via `handleSPA()` with client-side routing fallback
- **Middleware**: `LoggingMiddleware` logs requests and captures Sentry performance traces
- **Middleware**: `LoggingMiddleware`, `CSRFMiddleware`, `RateLimitMiddleware`, `RequestIDMiddleware`, `DualAuthMiddleware` (session + API key), `LoginRateLimitMiddleware`
- **Error handling**: uses `apiError` struct with `error` and `detail` fields; 5xx errors are reported to Sentry

### Graceful Shutdown
Expand Down Expand Up @@ -389,10 +393,10 @@ When adding endpoints:
- `DATABASE_URL`: PostgreSQL connection string (default `postgres://cloudpam:cloudpam@localhost:5432/cloudpam?sslmode=disable`)

### Auth
- `CLOUDPAM_AUTH_ENABLED`: Enable RBAC auth (`true` or `1` to enable)
- `CLOUDPAM_ADMIN_USERNAME`: Bootstrap admin username
- `CLOUDPAM_ADMIN_PASSWORD`: Bootstrap admin password
- `CLOUDPAM_ADMIN_PASSWORD`: Bootstrap admin password (min 12 chars)
- `CLOUDPAM_ADMIN_EMAIL`: Bootstrap admin email (default: `{username}@localhost`)
- `CLOUDPAM_TRUSTED_PROXIES`: Comma-separated CIDRs for trusted reverse proxies (e.g., `10.0.0.0/8,172.16.0.0/12`)

### Observability
- `CLOUDPAM_LOG_LEVEL`: Log level - debug, info, warn, error (default: `info`)
Expand Down Expand Up @@ -452,6 +456,9 @@ Common workflows:
- Schema apply: `POST /api/v1/schema/apply` with JSON body `{"pools":[...], "status":"planned", "tags":{}, "skip_conflicts":false}`
- Audit log: `GET /api/v1/audit?limit=50&offset=0&action=create&resource_type=pool`
- API key management: `POST /api/v1/auth/keys`, `GET /api/v1/auth/keys`, `DELETE /api/v1/auth/keys/{id}`
- Revoke user sessions: `POST /api/v1/auth/users/{id}/revoke-sessions`
- First-boot setup: `POST /api/v1/auth/setup` with `{"username":"...","password":"...","email":"..."}`
- Security settings: `GET /api/v1/settings/security`, `PATCH /api/v1/settings/security` with JSON body
- Search: `GET /api/v1/search?q=prod&cidr_contains=10.1.2.5&type=pool,account`
- Discovery resources: `GET /api/v1/discovery/resources?account_id=1&status=active&resource_type=vpc`
- Discovery resource detail: `GET /api/v1/discovery/resources/{id}`
Expand Down Expand Up @@ -586,8 +593,9 @@ cloudpam/
│ │ ├── types.go # Pool, Account types
│ │ ├── discovery.go # Discovery domain types
│ │ ├── recommendations.go # Recommendation types
│ │ ├── settings.go # SecuritySettings type
│ │ └── models.go # Extended models (planned)
│ ├── http/ # HTTP server, routes, handlers
│ ├── api/ # HTTP server, routes, handlers
│ │ ├── server.go # Server struct, route registration, helpers
│ │ ├── pool_handlers.go # Pool CRUD, hierarchy, stats
│ │ ├── account_handlers.go # Account CRUD
Expand All @@ -597,9 +605,11 @@ cloudpam/
│ │ ├── discovery_handlers.go # Discovery API (resources, sync)
│ │ ├── auth_handlers.go # Auth (login, logout, keys, users)
│ │ ├── user_handlers.go # User management
│ │ ├── settings_handlers.go # Security settings API
│ │ ├── csrf.go # CSRF double-submit cookie middleware
│ │ ├── recommendation_handlers.go # Recommendation API (generate, apply, dismiss)
│ │ ├── ai_handlers.go # AI Planning API (chat, sessions, plan apply)
│ │ ├── middleware.go # Middleware (logging, auth, rate limit)
│ │ ├── middleware.go # Middleware (logging, auth, rate limit, trusted proxies)
│ │ ├── context.go # Request context helpers
│ │ ├── cidr.go # IPv4 CIDR validation utilities
│ │ └── *_test.go # Tests
Expand All @@ -609,11 +619,14 @@ cloudpam/
│ │ ├── discovery_memory.go # In-memory DiscoveryStore
│ │ ├── recommendations.go # RecommendationStore interface
│ │ ├── recommendations_memory.go # In-memory RecommendationStore
│ │ ├── settings.go # SettingsStore interface
│ │ ├── settings_memory.go # In-memory SettingsStore
│ │ ├── errors.go # Sentinel errors (ErrNotFound, etc.)
│ │ ├── sqlite/ # SQLite implementation
│ │ │ ├── sqlite.go
│ │ │ ├── discovery.go # SQLite DiscoveryStore
│ │ │ ├── recommendations.go # SQLite RecommendationStore
│ │ │ ├── settings.go # SQLite SettingsStore
│ │ │ └── migrator.go
│ │ └── postgres/ # PostgreSQL implementation
│ │ ├── postgres.go
Expand All @@ -628,6 +641,7 @@ cloudpam/
│ │ ├── rbac.go # Roles, permissions, RBAC middleware
│ │ ├── keys.go # API key types and store interfaces
│ │ ├── users.go # User types and store interfaces
│ │ ├── password.go # Password hashing and validation (NIST 800-63B)
│ │ ├── sessions.go # Session management
│ │ └── sqlite.go # SQLite implementations
│ ├── audit/ # Audit logging
Expand All @@ -637,7 +651,7 @@ cloudpam/
│ │ └── llm/ # LLM provider abstraction (OpenAI-compatible)
│ ├── observability/ # Logging, metrics, tracing
│ └── docs/ # Internal documentation handlers
├── migrations/ # SQL migrations (0001-0013)
├── migrations/ # SQL migrations (0001-0016)
│ ├── embed.go
│ ├── 0001_init.sql
│ ├── 0002_accounts_meta.sql
Expand Down
71 changes: 44 additions & 27 deletions cmd/cloudpam/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
awscollector "cloudpam/internal/discovery/aws"
"cloudpam/internal/api"
"cloudpam/internal/observability"
"cloudpam/internal/storage"
"cloudpam/internal/planning"
"cloudpam/internal/planning/llm"

Expand Down Expand Up @@ -112,6 +113,18 @@ func main() {
)
}

// Parse trusted proxies for X-Forwarded-For handling
var proxyConfig *api.TrustedProxyConfig
if proxiesEnv := os.Getenv("CLOUDPAM_TRUSTED_PROXIES"); proxiesEnv != "" {
var err error
proxyConfig, err = api.ParseTrustedProxies(proxiesEnv)
if err != nil {
logger.Error("invalid CLOUDPAM_TRUSTED_PROXIES", "error", err)
} else {
logger.Info("trusted proxies configured", "count", len(proxyConfig.CIDRs))
}
}

mux := http.NewServeMux()
auditLogger := selectAuditLogger(logger)
keyStore := selectKeyStore(logger)
Expand Down Expand Up @@ -170,34 +183,32 @@ func main() {
aiSrv := api.NewAIPlanningServer(srv, aiService, convStore)
logger.Info("ai planning subsystem initialized")

// When CLOUDPAM_AUTH_ENABLED is set (or fresh install needs setup), use protected routes with RBAC.
// Otherwise use unprotected routes for development.
authEnabled := os.Getenv("CLOUDPAM_AUTH_ENABLED") == "true" || os.Getenv("CLOUDPAM_AUTH_ENABLED") == "1"
needsSetup := len(existingUsers) == 0
if authEnabled || needsSetup {
srv.RegisterProtectedRoutes(keyStore, sessionStore, userStore, logger.Slog())
authSrv := api.NewAuthServerWithStores(srv, keyStore, sessionStore, userStore, auditLogger)
authSrv.RegisterProtectedAuthRoutes(logger.Slog())
userSrv := api.NewUserServer(srv, keyStore, userStore, sessionStore, auditLogger)
userSrv.RegisterProtectedUserRoutes(logger.Slog())
dualMW := api.DualAuthMiddleware(keyStore, sessionStore, userStore, true, logger.Slog())
discoverySrv.RegisterProtectedDiscoveryRoutes(dualMW, logger.Slog())
analysisSrv.RegisterProtectedAnalysisRoutes(dualMW, logger.Slog())
recSrv.RegisterProtectedRecommendationRoutes(dualMW, logger.Slog())
aiSrv.RegisterProtectedAIPlanningRoutes(dualMW, logger.Slog())
logger.Info("authentication enabled (RBAC enforced)")
// Initialize settings subsystem
settingsStore := storage.NewMemorySettingsStore()
settingsSrv := api.NewSettingsServer(srv, settingsStore)
logger.Info("settings subsystem initialized")

// Auth is always enabled — register protected routes with RBAC.
srv.RegisterProtectedRoutes(keyStore, sessionStore, userStore, logger.Slog())
authSrv := api.NewAuthServerWithStores(srv, keyStore, sessionStore, userStore, auditLogger)
authSrv.RegisterProtectedAuthRoutes(logger.Slog())
userSrv := api.NewUserServer(srv, keyStore, userStore, sessionStore, auditLogger)
loginRL := api.LoginRateLimitMiddleware(api.LoginRateLimitConfig{
AttemptsPerMinute: 5,
ProxyConfig: proxyConfig,
})
userSrv.RegisterProtectedUserRoutes(logger.Slog(), api.WithLoginRateLimit(loginRL))
dualMW := api.DualAuthMiddleware(keyStore, sessionStore, userStore, true, logger.Slog())
discoverySrv.RegisterProtectedDiscoveryRoutes(dualMW, logger.Slog())
analysisSrv.RegisterProtectedAnalysisRoutes(dualMW, logger.Slog())
recSrv.RegisterProtectedRecommendationRoutes(dualMW, logger.Slog())
aiSrv.RegisterProtectedAIPlanningRoutes(dualMW, logger.Slog())
settingsSrv.RegisterProtectedSettingsRoutes(dualMW, logger.Slog())

if len(existingUsers) == 0 {
logger.Info("first-boot setup required", "hint", "visit the UI to create an admin account")
} else {
srv.RegisterRoutes()
authSrv := api.NewAuthServerWithStores(srv, keyStore, sessionStore, userStore, auditLogger)
authSrv.RegisterAuthRoutes()
userSrv := api.NewUserServer(srv, keyStore, userStore, sessionStore, auditLogger)
userSrv.RegisterUserRoutes()
discoverySrv.RegisterDiscoveryRoutes()
analysisSrv.RegisterAnalysisRoutes()
recSrv.RegisterRecommendationRoutes()
aiSrv.RegisterAIPlanningRoutes()
logger.Info("authentication disabled (all routes open)",
"hint", "set CLOUDPAM_AUTH_ENABLED=true to enable RBAC")
logger.Info("authentication enforced", "users", len(existingUsers))
}

// Background session cleanup every 15 minutes.
Expand All @@ -221,6 +232,7 @@ func main() {
observability.MetricsMiddleware(metrics),
api.RequestIDMiddleware(),
api.LoggingMiddleware(logger.Slog()),
api.CSRFMiddleware(),
api.RateLimitMiddleware(rateCfg, logger.Slog()),
)
server := &http.Server{
Expand Down Expand Up @@ -295,6 +307,11 @@ func bootstrapAdmin(logger observability.Logger, userStore auth.UserStore, usern
return
}

if err := auth.ValidatePassword(password, 0); err != nil {
logger.Error("bootstrap admin password does not meet requirements", "error", err)
return
}

hash, err := auth.HashPassword(password)
if err != nil {
logger.Error("failed to hash admin password", "error", err)
Expand Down
40 changes: 40 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,45 @@ All notable changes to CloudPAM will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.7.0] - Auth Hardening Sprint 19

### BREAKING
- `CLOUDPAM_AUTH_ENABLED` env var removed — authentication is always enforced
- First boot always shows setup wizard; use `CLOUDPAM_ADMIN_USERNAME`/`CLOUDPAM_ADMIN_PASSWORD` to auto-seed admin
- Password minimum length increased from 8 to 12 characters
- Bearer tokens without `cpam_` prefix no longer accepted as session auth

### Added
- Security settings API (`GET/PATCH /api/v1/settings/security`) with admin-only RBAC
- Security settings UI page under Config > Security (session, password, login, network sections)
- CSRF protection middleware (double-submit cookie pattern for session-authenticated requests)
- Per-IP login rate limiting (5 attempts/minute default) on `/api/v1/auth/login`
- Trusted proxy configuration via `CLOUDPAM_TRUSTED_PROXIES` env var
- Configurable session duration and max concurrent sessions per user (default: 24h, 10 sessions)
- `POST /api/v1/auth/users/{id}/revoke-sessions` endpoint (admin or self-service)
- `ListByUserID` added to `SessionStore` interface (memory, SQLite, PostgreSQL)
- `SettingsStore` interface with memory and SQLite implementations
- Migration `0016_settings.sql` (settings table)
- `ValidatePassword()` with configurable min (12) and max (72, bcrypt limit)
- `RoleLevel()` helper for privilege comparison
- Coming soon placeholders for Roles & Permissions and SSO/OIDC in settings UI

### Fixed
- API key scope elevation: callers can no longer create keys with higher privileges than their own role
- Audit actor attribution: `logAudit()` extracts user/API key from auth context instead of hardcoding "anonymous"
- Import routes (`/api/v1/import/accounts`, `/api/v1/import/pools`) now registered in protected mode
- `X-Forwarded-For` no longer trusted blindly — only from configured trusted proxies

### Removed
- `RegisterRoutes()` (unprotected route variant)
- Bearer-as-session-token auth path (Strategy 3 in `DualAuthMiddleware`)

### Security
- CSRF token validation on all session-authenticated state-changing requests
- Login rate limiting prevents brute-force attacks
- Session eviction enforces max concurrent sessions per user
- Password max length enforced at 72 chars to prevent bcrypt truncation

## [0.6.1] - Rename internal/http to internal/api

### Changed
Expand Down Expand Up @@ -629,6 +668,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- IPv4 only (IPv6 planned)
- Block detection marks exact CIDR matches as used

[0.7.0]: https://github.com/BadgerOps/cloudpam/compare/v0.6.1...v0.7.0
[0.6.1]: https://github.com/BadgerOps/cloudpam/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/BadgerOps/cloudpam/compare/v0.5.0...v0.6.0
[Unreleased]: https://github.com/BadgerOps/cloudpam/compare/v0.3.2...HEAD
Expand Down
2 changes: 1 addition & 1 deletion internal/api/analysis_handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func setupAnalysisServer() (*stdhttp.ServeMux, *storage.MemoryStore) {
Output: io.Discard,
})
srv := NewServer(mux, st, logger, nil, nil)
srv.RegisterRoutes()
srv.registerUnprotectedTestRoutes()
analysisSvc := planning.NewAnalysisService(st)
analysisSrv := NewAnalysisServer(srv, analysisSvc)
analysisSrv.RegisterAnalysisRoutes()
Expand Down
12 changes: 12 additions & 0 deletions internal/api/auth_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,18 @@ func (as *AuthServer) createAPIKey(w http.ResponseWriter, r *http.Request) {
}
}

// Prevent scope elevation: callers cannot create keys with higher privileges than their own role.
// Only enforced when the caller is authenticated (callerRole != RoleNone).
callerRole := auth.GetEffectiveRole(r.Context())
if callerRole != auth.RoleNone {
requestedRole := auth.GetRoleFromScopes(input.Scopes)
if auth.RoleLevel(requestedRole) > auth.RoleLevel(callerRole) {
as.writeErr(r.Context(), w, http.StatusForbidden, "scope elevation denied",
"requested scopes require a higher privilege level than your current role")
return
}
}

// Calculate expiration
var expiresAt *time.Time
if input.ExpiresInDays != nil && *input.ExpiresInDays > 0 {
Expand Down
52 changes: 49 additions & 3 deletions internal/api/auth_handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@ func setupAuthTestServer() (*AuthServer, *auth.MemoryKeyStore, *audit.MemoryAudi

keyStore := auth.NewMemoryKeyStore()

authSrv := NewAuthServer(srv, keyStore, auditLogger)
srv.RegisterRoutes()
authSrv.RegisterAuthRoutes()
sessionStore := auth.NewMemorySessionStore()
userStore := auth.NewMemoryUserStore()

authSrv := NewAuthServerWithStores(srv, keyStore, sessionStore, userStore, auditLogger)
srv.registerUnprotectedTestRoutes()
authSrv.registerUnprotectedAuthTestRoutes()

return authSrv, keyStore, auditLogger
}
Expand Down Expand Up @@ -405,6 +408,49 @@ func TestAudit_List_Filtering(t *testing.T) {
}
}

// doAuthJSONWithRole is like doAuthJSON but injects a role into the request context.
func doAuthJSONWithRole(t *testing.T, mux *stdhttp.ServeMux, method, path, body string, role auth.Role, code int) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
ctx := auth.ContextWithRole(req.Context(), role)
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != code {
t.Fatalf("%s %s: expected code %d, got %d: %s", method, path, code, rr.Code, rr.Body.String())
}
return rr
}

func TestCreateAPIKey_ScopeElevation(t *testing.T) {
as, _, _ := setupAuthTestServer()

// Operator (level 3) tries to create admin-level key (scope "*" = level 4) -> 403
body := `{"name": "Admin Key", "scopes": ["*"]}`
rr := doAuthJSONWithRole(t, as.mux, stdhttp.MethodPost, "/api/v1/auth/keys", body, auth.RoleOperator, stdhttp.StatusForbidden)
if !strings.Contains(rr.Body.String(), "scope elevation denied") {
t.Errorf("Expected 'scope elevation denied' error, got: %s", rr.Body.String())
}

// Operator (level 3) creates operator-level key (scope "pools:write" = level 3) -> 201
body = `{"name": "Operator Key", "scopes": ["pools:read"]}`
doAuthJSONWithRole(t, as.mux, stdhttp.MethodPost, "/api/v1/auth/keys", body, auth.RoleOperator, stdhttp.StatusCreated)

// Viewer (level 2) tries to create operator-level key (scope "pools:write" = level 3) -> 403
body = `{"name": "Writer Key", "scopes": ["pools:write"]}`
rr = doAuthJSONWithRole(t, as.mux, stdhttp.MethodPost, "/api/v1/auth/keys", body, auth.RoleViewer, stdhttp.StatusForbidden)
if !strings.Contains(rr.Body.String(), "scope elevation denied") {
t.Errorf("Expected 'scope elevation denied' error, got: %s", rr.Body.String())
}

// Admin (level 4) creates admin-level key (scope "*" = level 4) -> 201
body = `{"name": "Admin Key", "scopes": ["*"]}`
doAuthJSONWithRole(t, as.mux, stdhttp.MethodPost, "/api/v1/auth/keys", body, auth.RoleAdmin, stdhttp.StatusCreated)
}

func TestAudit_MethodNotAllowed(t *testing.T) {
as, _, _ := setupAuthTestServer()

Expand Down
Loading
Loading