From 8e2a805c49995c9d426886248d533a50394109e5 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sun, 19 Jul 2026 22:47:30 +0200 Subject: [PATCH 1/3] fix(api): propagate authenticated principal to handlers Attach the resolved AuthUser principal to the request context before dispatch and reuse it in the current-user permissions path. Cover admin, user API key, and session credentials without duplicate validation. --- internal/api/handler.go | 69 +++++++++--- internal/api/handler_auth.go | 42 ++++---- internal/api/handler_purchases.go | 28 ++--- internal/api/middleware.go | 102 +++++++++++++----- internal/api/router.go | 20 +++- internal/api/router_authuser_test.go | 150 ++++++++++++++++++++++++++- 6 files changed, 324 insertions(+), 87 deletions(-) diff --git a/internal/api/handler.go b/internal/api/handler.go index 521a70f8e..b95b774d5 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -229,6 +229,10 @@ const apiKeyAdminUserID = "admin-api-key" // permissions and is denied (fail closed). Returns the session on success so // callers can read session.UserID for account filtering. func (h *Handler) requirePermission(ctx context.Context, req *events.LambdaFunctionURLRequest, action, resource string) (*Session, error) { + if principal, ok := principalFromContext(ctx); ok { + return h.requirePrincipalPermission(ctx, req, principal, action, resource) + } + apiKey := extractAPIKey(req) if h.checkAdminAPIKey(apiKey) { return &Session{UserID: apiKeyAdminUserID}, nil @@ -261,6 +265,33 @@ func (h *Handler) requirePermission(ctx context.Context, req *events.LambdaFunct return nil, NewClientError(401, "invalid session") } + return h.requireSessionPermission(ctx, session, action, resource) +} + +func (h *Handler) requirePrincipalPermission(ctx context.Context, req *events.LambdaFunctionURLRequest, principal *Principal, action, resource string) (*Session, error) { + switch principal.Kind { + case PrincipalAdminAPIKey: + return &Session{UserID: apiKeyAdminUserID}, nil + case PrincipalUserAPIKey: + session, err := h.authorizeAPIKey(ctx, extractAPIKey(req), action, resource) + if err != nil { + return nil, err + } + if session == nil { + return nil, NewClientError(401, "API key permission validation failed") + } + return session, nil + case PrincipalSession: + return h.requireSessionPermission(ctx, principal.Session, action, resource) + default: + return nil, NewClientError(401, "unsupported authentication principal") + } +} + +func (h *Handler) requireSessionPermission(ctx context.Context, session *Session, action, resource string) (*Session, error) { + if session == nil || session.UserID == "" { + return nil, NewClientError(401, "invalid session") + } has, err := h.auth.HasPermissionAPI(ctx, session.UserID, action, resource) if err != nil { return nil, fmt.Errorf("permission check failed: %w", err) @@ -423,12 +454,13 @@ func (h *Handler) HandleRequest(ctx context.Context, req *events.LambdaFunctionU logging.Debugf("API Request: %s %s", method, redactURL(path)) // Validate request - if response := h.validateRequest(ctx, req, method, path, corsHeaders); response != nil { + requestCtx, response := h.validateRequestContext(ctx, req, method, path, corsHeaders) + if response != nil { return response, nil } // Route and execute request - return h.executeRequest(ctx, method, path, req, corsHeaders) + return h.executeRequest(requestCtx, method, path, req, corsHeaders) } // buildResponseHeaders creates response headers with security and CORS settings. @@ -451,43 +483,56 @@ func (h *Handler) buildResponseHeaders() map[string]string { // validateRequest validates the incoming request and returns error response if validation fails. func (h *Handler) validateRequest(ctx context.Context, req *events.LambdaFunctionURLRequest, method, path string, corsHeaders map[string]string) *events.LambdaFunctionURLResponse { + _, response := h.validateRequestContext(ctx, req, method, path, corsHeaders) + return response +} + +func (h *Handler) validateRequestContext(ctx context.Context, req *events.LambdaFunctionURLRequest, method, path string, corsHeaders map[string]string) (context.Context, *events.LambdaFunctionURLResponse) { // Validate request body size if err := validateRequestBodySize(req.Body); err != nil { logging.Warnf("Request body size exceeded: %d bytes", len(req.Body)) - return h.buildResponse(413, corsHeaders, map[string]string{"error": "Request body too large"}, nil) + return ctx, h.buildResponse(413, corsHeaders, map[string]string{"error": "Request body too large"}, nil) } // Validate Content-Type if err := validateContentType(req); err != nil { - return h.buildResponse(415, corsHeaders, map[string]string{"error": err.Error()}, nil) + return ctx, h.buildResponse(415, corsHeaders, map[string]string{"error": err.Error()}, nil) } // Validate authentication and CSRF - if response := h.validateSecurity(ctx, req, method, path, corsHeaders); response != nil { - return response + requestCtx, response := h.validateSecurityContext(ctx, req, method, path, corsHeaders) + if response != nil { + return ctx, response } - return nil + return requestCtx, nil } // validateSecurity validates authentication and CSRF token. func (h *Handler) validateSecurity(ctx context.Context, req *events.LambdaFunctionURLRequest, method, path string, corsHeaders map[string]string) *events.LambdaFunctionURLResponse { + _, response := h.validateSecurityContext(ctx, req, method, path, corsHeaders) + return response +} + +func (h *Handler) validateSecurityContext(ctx context.Context, req *events.LambdaFunctionURLRequest, method, path string, corsHeaders map[string]string) (context.Context, *events.LambdaFunctionURLResponse) { if h.isPublicEndpoint(path) { - return nil + return ctx, nil } - if !h.authenticate(ctx, req) { - return h.buildResponse(401, corsHeaders, map[string]string{"error": "Unauthorized"}, nil) + principal, err := h.authenticatePrincipal(ctx, req) + if err != nil { + return ctx, h.buildResponse(401, corsHeaders, map[string]string{"error": "Unauthorized"}, nil) } + ctx = contextWithPrincipal(ctx, principal) if h.requiresCSRFValidation(method, path, req) { if err := h.validateCSRF(ctx, req); err != nil { logging.Warnf("CSRF validation failed: %v", err) - return h.buildResponse(403, corsHeaders, map[string]string{"error": "CSRF validation failed"}, nil) + return ctx, h.buildResponse(403, corsHeaders, map[string]string{"error": "CSRF validation failed"}, nil) } } - return nil + return ctx, nil } // executeRequest routes and executes the API request. diff --git a/internal/api/handler_auth.go b/internal/api/handler_auth.go index eac3776f1..c8f58a75b 100644 --- a/internal/api/handler_auth.go +++ b/internal/api/handler_auth.go @@ -82,15 +82,9 @@ func (h *Handler) getCurrentUser(ctx context.Context, req *events.LambdaFunction return nil, fmt.Errorf("authentication service not configured") } - // Get token from Authorization header - token := h.extractBearerToken(req) - if token == "" { - return nil, NewClientError(401, "no authorization token provided") - } - - session, err := h.auth.ValidateSession(ctx, token) + session, err := h.requireSessionPrincipal(ctx, req) if err != nil { - return nil, NewClientError(401, "invalid session") + return nil, err } user, err := h.auth.GetUser(ctx, session.UserID) @@ -176,6 +170,19 @@ func (h *Handler) getCurrentUserPermissions(ctx context.Context, req *events.Lam // special-case it (see getCurrentUserPermissions for the {admin, *} // short-circuit). func (h *Handler) resolveAuthenticatedUserID(ctx context.Context, req *events.LambdaFunctionURLRequest) (string, error) { + if principal, ok := principalFromContext(ctx); ok { + if principal.Kind == PrincipalAdminAPIKey { + return apiKeyAdminUserID, nil + } + if principal.UserID == "" { + return "", NewClientError(401, "authenticated principal has no user ID") + } + return principal.UserID, nil + } + return h.resolveAuthenticatedUserIDFromRequest(ctx, req) +} + +func (h *Handler) resolveAuthenticatedUserIDFromRequest(ctx context.Context, req *events.LambdaFunctionURLRequest) (string, error) { // Admin API key first (stateless, no per-user lookup). apiKey := extractAPIKey(req) if h.checkAdminAPIKey(apiKey) { @@ -370,15 +377,9 @@ func (h *Handler) updateProfile(ctx context.Context, req *events.LambdaFunctionU return nil, fmt.Errorf("authentication service not configured") } - // Get current user from token - token := h.extractBearerToken(req) - if token == "" { - return nil, NewClientError(401, "no authorization token provided") - } - - session, err := h.auth.ValidateSession(ctx, token) + session, err := h.requireSessionPrincipal(ctx, req) if err != nil { - return nil, NewClientError(401, "invalid session") + return nil, err } // Parse request body @@ -475,14 +476,9 @@ func (h *Handler) changePassword(ctx context.Context, req *events.LambdaFunction return nil, err } - token := h.extractBearerToken(req) - if token == "" { - return nil, NewClientError(401, "no authorization token provided") - } - - session, err := h.auth.ValidateSession(ctx, token) + session, err := h.requireSessionPrincipal(ctx, req) if err != nil { - return nil, NewClientError(401, "invalid session") + return nil, err } var pwdReq ChangePasswordRequest diff --git a/internal/api/handler_purchases.go b/internal/api/handler_purchases.go index 5e8e28db9..d3a184c6f 100644 --- a/internal/api/handler_purchases.go +++ b/internal/api/handler_purchases.go @@ -334,20 +334,15 @@ func (h *Handler) runPlannedPurchase(ctx context.Context, req *events.LambdaFunc // (creator-scope cancel, issue #1400). Ownership is enforced separately by // authorizePlannedPurchaseCancel; this gate is the minimum-verb check. func (h *Handler) requireDeleteOrCancelPurchasePermission(ctx context.Context, req *events.LambdaFunctionURLRequest) (*Session, error) { - apiKey := extractAPIKey(req) - if h.checkAdminAPIKey(apiKey) { + if principal, ok := principalFromContext(ctx); ok && principal.Kind == PrincipalAdminAPIKey { return &Session{UserID: apiKeyAdminUserID}, nil } - if h.auth == nil { - return nil, fmt.Errorf("authentication service not configured") - } - token := h.extractBearerToken(req) - if token == "" { - return nil, NewClientError(401, "no authorization token provided") + if h.checkAdminAPIKey(extractAPIKey(req)) { + return &Session{UserID: apiKeyAdminUserID}, nil } - session, err := h.auth.ValidateSession(ctx, token) + session, err := h.requireSessionPrincipal(ctx, req) if err != nil { - return nil, NewClientError(401, "invalid session") + return nil, err } for _, verb := range []string{auth.ActionDelete, auth.ActionCancelAny, auth.ActionCancelOwn} { has, checkErr := h.auth.HasPermissionAPI(ctx, session.UserID, verb, auth.ResourcePurchases) @@ -1465,18 +1460,7 @@ func (h *Handler) authorizeSessionCancel(ctx context.Context, session *Session, // cancel-own check; falling through to the API-key admin role would let // a key impersonate ownership we cannot verify. func (h *Handler) requireSession(ctx context.Context, req *events.LambdaFunctionURLRequest) (*Session, error) { - if h.auth == nil { - return nil, fmt.Errorf("authentication service not configured") - } - token := h.extractBearerToken(req) - if token == "" { - return nil, NewClientError(401, "no authorization token provided") - } - session, err := h.auth.ValidateSession(ctx, token) - if err != nil || session == nil { - return nil, NewClientError(401, "invalid session") - } - return session, nil + return h.requireSessionPrincipal(ctx, req) } // retryThreshold is the number of attempts after which a retry is diff --git a/internal/api/middleware.go b/internal/api/middleware.go index bb56a5c05..613d9d045 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -73,11 +73,22 @@ type Principal struct { // Field order groups the pointer-bearing Session ahead of the trailing // string so the struct satisfies govet fieldalignment. Kind PrincipalKind - Session *Session // non-nil only for PrincipalSession + Session *Session // non-nil when a valid bearer session was presented UserID string // empty for PrincipalAdminAPIKey Email string // empty for PrincipalAdminAPIKey; populated for session/user-api-key } +type principalContextKey struct{} + +func contextWithPrincipal(ctx context.Context, principal *Principal) context.Context { + return context.WithValue(ctx, principalContextKey{}, principal) +} + +func principalFromContext(ctx context.Context) (*Principal, bool) { + principal, ok := ctx.Value(principalContextKey{}).(*Principal) + return principal, ok && principal != nil +} + // authenticate checks authentication via admin API key, user API key, or Bearer token. func (h *Handler) authenticate(ctx context.Context, req *events.LambdaFunctionURLRequest) bool { apiKey := extractAPIKey(req) @@ -108,12 +119,16 @@ func (h *Handler) authenticatePrincipal(ctx context.Context, req *events.LambdaF return nil, NewClientError(401, "authentication required") } - if p := h.principalFromUserAPIKey(ctx, apiKey); p != nil { - return p, nil + userPrincipal := h.principalFromUserAPIKey(ctx, apiKey) + sessionPrincipal := h.principalFromBearerToken(ctx, req) + if userPrincipal != nil { + if sessionPrincipal != nil { + userPrincipal.Session = sessionPrincipal.Session + } + return userPrincipal, nil } - - if p := h.principalFromBearerToken(ctx, req); p != nil { - return p, nil + if sessionPrincipal != nil { + return sessionPrincipal, nil } return nil, NewClientError(401, "authentication required") @@ -312,6 +327,9 @@ func (h *Handler) validateCSRF(ctx context.Context, req *events.LambdaFunctionUR // protection doesn't apply. An invalid API key returns false — the caller // then falls through to session-based CSRF validation. func (h *Handler) apiKeyBypassCSRF(ctx context.Context, req *events.LambdaFunctionURLRequest) bool { + if principal, ok := principalFromContext(ctx); ok { + return principal.Kind == PrincipalAdminAPIKey || principal.Kind == PrincipalUserAPIKey + } apiKey := req.Headers["x-api-key"] if apiKey == "" { apiKey = req.Headers["X-API-Key"] @@ -389,17 +407,38 @@ func redactQueryParam(u, param string) string { // of any kind (admin API key, user API key, or session bearer token). // // Used as a defense-in-depth check by Router.Route for AuthUser routes: -// validateSecurity → authenticate already runs before dispatch, but if a -// future refactor reorders middleware or a new route bypasses -// validateSecurity, this check still rejects unauthenticated requests at -// the router level. Returns the resolved Principal on success, a 401 -// ClientError otherwise. Callers should use the returned Principal rather -// than re-resolving the caller's identity through a second ValidateSession -// or ValidateUserAPIKeyAPI call. +// HandleRequest normally places authenticatePrincipal's result in the request +// context before dispatch. Router.Route still calls this helper so standalone +// router use remains protected; an existing context principal is reused and a +// missing one is resolved here. Returns a 401 ClientError on failure. func (h *Handler) requireAuth(ctx context.Context, req *events.LambdaFunctionURLRequest) (*Principal, error) { + if principal, ok := principalFromContext(ctx); ok { + return principal, nil + } return h.authenticatePrincipal(ctx, req) } +func (h *Handler) requireSessionPrincipal(ctx context.Context, req *events.LambdaFunctionURLRequest) (*Session, error) { + if principal, ok := principalFromContext(ctx); ok { + if principal.Session == nil { + return nil, NewClientError(401, "session authentication required") + } + return principal.Session, nil + } + if h.auth == nil { + return nil, fmt.Errorf("authentication service not configured") + } + token := h.extractBearerToken(req) + if token == "" { + return nil, NewClientError(401, "no authorization token provided") + } + session, err := h.auth.ValidateSession(ctx, token) + if err != nil || session == nil { + return nil, NewClientError(401, "invalid session") + } + return session, nil +} + // requireAdmin gates the coarse admin-only routes (AuthAdmin). "Admin" is now // defined as holding the full-access {admin, *} capability, i.e. membership in // the Administrators group. Accepts both the stateless admin API key (which @@ -407,26 +446,35 @@ func (h *Handler) requireAuth(ctx context.Context, req *events.LambdaFunctionURL // permissions include {admin, *}. Fail closed: a missing auth service, an // invalid session, or a permission-lookup error denies access. func (h *Handler) requireAdmin(ctx context.Context, req *events.LambdaFunctionURLRequest) (*Session, error) { - // Check admin API key first (stateless auth) - apiKey := extractAPIKey(req) - if h.checkAdminAPIKey(apiKey) { - return &Session{UserID: apiKeyAdminUserID}, nil + var session *Session + if principal, ok := principalFromContext(ctx); ok { + if principal.Kind == PrincipalAdminAPIKey { + return &Session{UserID: apiKeyAdminUserID}, nil + } + if principal.Session == nil { + return nil, NewClientError(401, "session authentication required") + } + session = principal.Session + } else { + // Check admin API key first (stateless auth). + apiKey := extractAPIKey(req) + if h.checkAdminAPIKey(apiKey) { + return &Session{UserID: apiKeyAdminUserID}, nil + } + if h.auth == nil { + return nil, fmt.Errorf("authentication service not configured") + } + var err error + session, err = h.requireSessionPrincipal(ctx, req) + if err != nil { + return nil, err + } } if h.auth == nil { return nil, fmt.Errorf("authentication service not configured") } - token := h.extractBearerToken(req) - if token == "" { - return nil, NewClientError(401, "no authorization token provided") - } - - session, err := h.auth.ValidateSession(ctx, token) - if err != nil { - return nil, NewClientError(401, "invalid session") - } - // HasPermissionAPI(admin, *) returns true only for users who hold the // full-access capability, i.e. Administrators-group members. Any other // user (including zero-group users) is denied. diff --git a/internal/api/router.go b/internal/api/router.go index 7e152cd45..77fb4b723 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -381,13 +381,29 @@ func (r *Router) Route(ctx context.Context, method, path string, req *events.Lam if r.matches(route, method, path) { switch route.Auth { case AuthAdmin: - if _, err := r.h.requireAdmin(ctx, req); err != nil { + _, hadPrincipal := principalFromContext(ctx) + session, err := r.h.requireAdmin(ctx, req) + if err != nil { return nil, err } + if !hadPrincipal { + if session.UserID == apiKeyAdminUserID { + ctx = contextWithPrincipal(ctx, &Principal{Kind: PrincipalAdminAPIKey}) + } else { + ctx = contextWithPrincipal(ctx, &Principal{ + Kind: PrincipalSession, + Session: session, + UserID: session.UserID, + Email: session.Email, + }) + } + } case AuthUser: - if _, err := r.h.requireAuth(ctx, req); err != nil { + principal, err := r.h.requireAuth(ctx, req) + if err != nil { return nil, err } + ctx = contextWithPrincipal(ctx, principal) case AuthPublic: // no auth check; relied upon by middleware via isPublicEndpoint default: diff --git a/internal/api/router_authuser_test.go b/internal/api/router_authuser_test.go index f1d36a01f..ab6b2a52f 100644 --- a/internal/api/router_authuser_test.go +++ b/internal/api/router_authuser_test.go @@ -72,7 +72,7 @@ func TestRouterAuthUser_ValidUserSession_Accepts(t *testing.T) { mockAuth := new(MockAuthService) userSession := &Session{UserID: "11111111-1111-1111-1111-111111111111"} mockAuth.On("ValidateSession", ctx, "user-token").Return(userSession, nil) - mockAuth.On("Logout", ctx, "user-token").Return(nil) + mockAuth.On("Logout", mock.Anything, "user-token").Return(nil) h := &Handler{auth: mockAuth} r := NewRouter(h) @@ -85,6 +85,154 @@ func TestRouterAuthUser_ValidUserSession_Accepts(t *testing.T) { assert.NotNil(t, result) } +func TestRouterAuthUser_SessionPrincipalAvoidsSecondValidation(t *testing.T) { + ctx := context.Background() + mockAuth := new(MockAuthService) + session := &Session{UserID: "session-user"} + mockAuth.On("ValidateSession", ctx, "session-token").Return(session, nil).Once() + mockAuth.On("GetUserPermissionsAPI", mock.Anything, "session-user").Return([]auth.APIPermission{}, nil).Once() + h := &Handler{auth: mockAuth} + r := NewRouter(h) + req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"Authorization": "Bearer session-token"}} + + _, err := r.Route(ctx, "GET", "/api/auth/me/permissions", req) + + require.NoError(t, err) + mockAuth.AssertNumberOfCalls(t, "ValidateSession", 1) + mockAuth.AssertExpectations(t) +} + +func TestRouterAuthUser_UserAPIKeyPrincipalAvoidsSecondValidation(t *testing.T) { + ctx := context.Background() + mockAuth := new(MockAuthService) + user := &auth.User{ID: "api-key-user", Email: "user@example.com"} + mockAuth.On("ValidateUserAPIKeyAPI", ctx, "user-key").Return(nil, user, nil).Once() + mockAuth.On("GetUserPermissionsAPI", mock.Anything, "api-key-user").Return([]auth.APIPermission{}, nil).Once() + h := &Handler{auth: mockAuth} + r := NewRouter(h) + req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"X-API-Key": "user-key"}} + + _, err := r.Route(ctx, "GET", "/api/auth/me/permissions", req) + + require.NoError(t, err) + mockAuth.AssertNumberOfCalls(t, "ValidateUserAPIKeyAPI", 1) + mockAuth.AssertExpectations(t) +} + +func TestRouterAuthUser_AdminAPIKeyPrincipalReachesHandler(t *testing.T) { + ctx := context.Background() + mockAuth := new(MockAuthService) + h := &Handler{auth: mockAuth, apiKey: "admin-key"} + r := NewRouter(h) + req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"X-API-Key": "admin-key"}} + + result, err := r.Route(ctx, "GET", "/api/auth/me/permissions", req) + + require.NoError(t, err) + permissions, ok := result.(*UserPermissionsResponse) + require.True(t, ok) + require.True(t, permissions.IsAdmin) + mockAuth.AssertNotCalled(t, "ValidateUserAPIKeyAPI", mock.Anything, mock.Anything) + mockAuth.AssertNotCalled(t, "ValidateSession", mock.Anything, mock.Anything) +} + +func TestHandleRequest_SessionPrincipalValidatedOnce(t *testing.T) { + ctx := context.Background() + mockAuth := new(MockAuthService) + session := &Session{UserID: "session-user"} + mockAuth.On("ValidateSession", ctx, "session-token").Return(session, nil).Once() + mockAuth.On("GetUserPermissionsAPI", mock.Anything, "session-user").Return([]auth.APIPermission{}, nil).Once() + h := &Handler{auth: mockAuth} + req := &events.LambdaFunctionURLRequest{ + Headers: map[string]string{"Authorization": "Bearer session-token"}, + RequestContext: events.LambdaFunctionURLRequestContext{ + HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{Method: "GET", Path: "/api/auth/me/permissions"}, + }, + } + + response, err := h.HandleRequest(ctx, req) + + require.NoError(t, err) + require.Equal(t, 200, response.StatusCode) + mockAuth.AssertNumberOfCalls(t, "ValidateSession", 1) + mockAuth.AssertExpectations(t) +} + +func TestHandleRequest_UserAPIKeyPrincipalValidatedOnce(t *testing.T) { + ctx := context.Background() + mockAuth := new(MockAuthService) + user := &auth.User{ID: "api-key-user", Email: "user@example.com"} + mockAuth.On("ValidateUserAPIKeyAPI", ctx, "user-key").Return(nil, user, nil).Once() + mockAuth.On("GetUserPermissionsAPI", mock.Anything, "api-key-user").Return([]auth.APIPermission{}, nil).Once() + h := &Handler{auth: mockAuth} + req := &events.LambdaFunctionURLRequest{ + Headers: map[string]string{"X-API-Key": "user-key"}, + RequestContext: events.LambdaFunctionURLRequestContext{ + HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{Method: "GET", Path: "/api/auth/me/permissions"}, + }, + } + + response, err := h.HandleRequest(ctx, req) + + require.NoError(t, err) + require.Equal(t, 200, response.StatusCode) + mockAuth.AssertNumberOfCalls(t, "ValidateUserAPIKeyAPI", 1) + mockAuth.AssertExpectations(t) +} + +func TestHandleRequest_MixedCredentialsSessionOnlyHandlerUsesSession(t *testing.T) { + ctx := context.Background() + mockAuth := new(MockAuthService) + apiUser := &auth.User{ID: "api-key-user", Email: "key@example.com"} + session := &Session{UserID: "session-user", Email: "session@example.com"} + mockAuth.On("ValidateUserAPIKeyAPI", ctx, "user-key").Return(nil, apiUser, nil).Once() + mockAuth.On("ValidateSession", ctx, "session-token").Return(session, nil).Once() + mockAuth.On("GetUser", mock.Anything, "session-user").Return(&User{ID: "session-user", Email: "session@example.com"}, nil).Once() + h := &Handler{auth: mockAuth} + req := &events.LambdaFunctionURLRequest{ + Headers: map[string]string{"X-API-Key": "user-key", "Authorization": "Bearer session-token"}, + RequestContext: events.LambdaFunctionURLRequestContext{ + HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{Method: "GET", Path: "/api/auth/me"}, + }, + } + + response, err := h.HandleRequest(ctx, req) + + require.NoError(t, err) + require.Equal(t, 200, response.StatusCode) + mockAuth.AssertNumberOfCalls(t, "ValidateUserAPIKeyAPI", 1) + mockAuth.AssertNumberOfCalls(t, "ValidateSession", 1) + mockAuth.AssertExpectations(t) +} + +func TestHandleRequest_MixedCredentialsAuthAdminRetainsAPIKeyRestrictions(t *testing.T) { + ctx := context.Background() + mockAuth := new(MockAuthService) + apiUser := &auth.User{ID: "api-key-user", Email: "key@example.com"} + session := &Session{UserID: "admin-session-user", Email: "admin@example.com"} + mockAuth.On("ValidateUserAPIKeyAPI", ctx, "user-key").Return(nil, apiUser, nil).Once() + mockAuth.On("ValidateSession", ctx, "admin-session-token").Return(session, nil).Once() + mockAuth.On("HasPermissionAPI", mock.Anything, "admin-session-user", auth.ActionAdmin, auth.ResourceAll).Return(true, nil).Once() + mockAuth.On("HasAPIKeyPermissionAPI", mock.Anything, "user-key", auth.ActionView, auth.ResourceUsers). + Return("api-key-user", "key-id", false, nil).Once() + h := &Handler{auth: mockAuth} + req := &events.LambdaFunctionURLRequest{ + Headers: map[string]string{"X-API-Key": "user-key", "Authorization": "Bearer admin-session-token"}, + RequestContext: events.LambdaFunctionURLRequestContext{ + HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{Method: "GET", Path: "/api/users"}, + }, + } + + response, err := h.HandleRequest(ctx, req) + + require.NoError(t, err) + require.Equal(t, 403, response.StatusCode) + mockAuth.AssertNumberOfCalls(t, "ValidateUserAPIKeyAPI", 1) + mockAuth.AssertNumberOfCalls(t, "ValidateSession", 1) + mockAuth.AssertNotCalled(t, "ListUsersAPI", mock.Anything) + mockAuth.AssertExpectations(t) +} + // TestRouterAuthPublic_NoCredentials_Accepts verifies that AuthPublic // routes still dispatch with no credentials — the new switch in // Router.Route must not regress public-endpoint behavior. From 4e4a12d824294a688fb319d74bf67d164252a3ed Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 20 Jul 2026 18:31:01 +0200 Subject: [PATCH 2/3] test(api): accept authenticated request context in dashboard test The authentication follow-up propagates the resolved principal through the request context. Match that enriched context in the existing end-to-end dashboard test while preserving its business argument assertions. --- internal/api/handler_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index 72fa2a9a4..20fb8135f 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -918,11 +918,11 @@ func TestHandler_HandleRequest_GetDashboardSummary(t *testing.T) { DefaultCoverage: 80.0, } - mockScheduler.On("ListRecommendations", ctx, mock.Anything).Return(recommendations, nil) - mockStore.On("GetGlobalConfig", ctx).Return(globalCfg, nil) + mockScheduler.On("ListRecommendations", mock.Anything, mock.Anything).Return(recommendations, nil) + mockStore.On("GetGlobalConfig", mock.Anything).Return(globalCfg, nil) // No account_id / account_ids filter → calculateCommitmentMetrics fetches the // uncapped active set across all accounts via GetActivePurchaseHistory. - mockStore.On("GetActivePurchaseHistory", ctx, mock.Anything, mock.Anything, mock.Anything).Return([]config.PurchaseHistoryRecord{}, nil) + mockStore.On("GetActivePurchaseHistory", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]config.PurchaseHistoryRecord{}, nil) handler := &Handler{ scheduler: mockScheduler, From 232b5402f85bef51832e7f743819ba9f492d23ab Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 20 Jul 2026 18:35:36 +0200 Subject: [PATCH 3/3] test(api): accept principal context in request integration tests HandleRequest now propagates the authenticated principal through context. Keep end-to-end mocks strict on business arguments while accepting the enriched request context passed to downstream services. --- internal/api/handler_test.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index 20fb8135f..20c47345f 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -379,7 +379,7 @@ func TestHandler_HandleRequest_PutConfig(t *testing.T) { }, nil) mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) mockAuth.grantAdmin() - mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) + mockAuth.On("ValidateCSRFToken", mock.Anything, mock.Anything, mock.Anything).Return(nil) handler := &Handler{config: mockStore, auth: mockAuth, apiKey: "test-key"} @@ -975,11 +975,11 @@ func TestHandler_HandleRequest_GetUpcomingPurchases(t *testing.T) { }, } - mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return(plans, nil) + mockStore.On("ListPurchasePlans", mock.Anything, config.PurchasePlanFilter{}).Return(plans, nil) // New: handler now enumerates pending executions per PR #213. Fixture // supplies one pending exec for the plan above so the integration test // still observes a single upcoming row. - mockStore.On("GetPendingExecutions", ctx).Return([]config.PurchaseExecution{ + mockStore.On("GetPendingExecutions", mock.Anything).Return([]config.PurchaseExecution{ { ExecutionID: "exec-int-1", PlanID: plans[0].ID, @@ -1034,8 +1034,8 @@ func TestHandler_HandleRequest_GetPlannedPurchases(t *testing.T) { {ID: "11111111-1111-1111-1111-111111111111", Name: "Test Plan", Services: map[string]config.ServiceConfig{"aws/rds": {Provider: "aws", Service: "rds"}}}, } - mockStore.On("GetPlannedExecutions", ctx, mock.Anything, mock.Anything).Return(executions, nil) - mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return(plans, nil) + mockStore.On("GetPlannedExecutions", mock.Anything, mock.Anything, mock.Anything).Return(executions, nil) + mockStore.On("ListPurchasePlans", mock.Anything, config.PurchasePlanFilter{}).Return(plans, nil) handler := &Handler{config: mockStore, auth: mockAuth, corsAllowedOrigin: "*", apiKey: "test-key"} @@ -1068,7 +1068,7 @@ func TestHandler_HandleRequest_PausePlannedPurchase(t *testing.T) { mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) paused := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "paused"} - mockStore.On("TransitionExecutionStatus", ctx, "11111111-1111-1111-1111-111111111111", []string{"pending", "running"}, "paused", mock.Anything).Return(paused, nil) + mockStore.On("TransitionExecutionStatus", mock.Anything, "11111111-1111-1111-1111-111111111111", []string{"pending", "running"}, "paused", mock.Anything).Return(paused, nil) handler := &Handler{config: mockStore, auth: mockAuth, corsAllowedOrigin: "*", apiKey: "test-key"} @@ -1103,7 +1103,7 @@ func TestHandler_HandleRequest_ResumePlannedPurchase(t *testing.T) { mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) resumed := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "pending"} - mockStore.On("TransitionExecutionStatus", ctx, "11111111-1111-1111-1111-111111111111", []string{"paused"}, "pending", mock.Anything).Return(resumed, nil) + mockStore.On("TransitionExecutionStatus", mock.Anything, "11111111-1111-1111-1111-111111111111", []string{"paused"}, "pending", mock.Anything).Return(resumed, nil) handler := &Handler{config: mockStore, auth: mockAuth, corsAllowedOrigin: "*", apiKey: "test-key"} @@ -1138,7 +1138,7 @@ func TestHandler_HandleRequest_RunPlannedPurchase(t *testing.T) { mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) transitioned := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "running"} - mockStore.On("TransitionExecutionStatus", ctx, "11111111-1111-1111-1111-111111111111", []string{"pending", "paused"}, "running", mock.Anything).Return(transitioned, nil) + mockStore.On("TransitionExecutionStatus", mock.Anything, "11111111-1111-1111-1111-111111111111", []string{"pending", "paused"}, "running", mock.Anything).Return(transitioned, nil) handler := &Handler{config: mockStore, auth: mockAuth, corsAllowedOrigin: "*", apiKey: "test-key"} @@ -1173,7 +1173,7 @@ func TestHandler_HandleRequest_DeletePlannedPurchase(t *testing.T) { mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) canceled := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "canceled"} - mockStore.On("TransitionExecutionStatus", ctx, "11111111-1111-1111-1111-111111111111", []string{"pending", "paused"}, "canceled", mock.Anything).Return(canceled, nil) + mockStore.On("TransitionExecutionStatus", mock.Anything, "11111111-1111-1111-1111-111111111111", []string{"pending", "paused"}, "canceled", mock.Anything).Return(canceled, nil) handler := &Handler{config: mockStore, auth: mockAuth, corsAllowedOrigin: "*", apiKey: "test-key"} @@ -1212,9 +1212,9 @@ func TestHandler_HandleRequest_CreatePlannedPurchases(t *testing.T) { RampSchedule: config.RampSchedule{StepIntervalDays: 7}, } - mockStore.On("GetPurchasePlan", ctx, "11111111-1111-1111-1111-111111111111").Return(plan, nil) - mockStore.On("SavePurchaseExecution", ctx, mock.Anything).Return(nil) - mockStore.On("UpdatePurchasePlan", ctx, mock.Anything).Return(nil) + mockStore.On("GetPurchasePlan", mock.Anything, "11111111-1111-1111-1111-111111111111").Return(plan, nil) + mockStore.On("SavePurchaseExecution", mock.Anything, mock.Anything).Return(nil) + mockStore.On("UpdatePurchasePlan", mock.Anything, mock.Anything).Return(nil) handler := &Handler{config: mockStore, auth: mockAuth, corsAllowedOrigin: "*", apiKey: "test-key"} @@ -1281,7 +1281,7 @@ func TestHandler_HandleRequest_DeleteUser_SelfDeletion(t *testing.T) { adminSession := &Session{UserID: adminUserID, Email: "admin@example.com"} mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) mockAuth.grantAdmin() - mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) + mockAuth.On("ValidateCSRFToken", mock.Anything, mock.Anything, mock.Anything).Return(nil) handler := &Handler{auth: mockAuth, apiKey: "test-key"}