From 53badc03f7f88833e172234e20fd2a4b4d2763d4 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 1 Jun 2026 21:40:58 +0200 Subject: [PATCH 1/4] feat(auth): group-membership-only authorization, remove roles, require >=1 group (closes #907) Collapse the dual authorization model (role + groups) down to group membership only. Every authorization decision now derives from the union of the user's groups' permissions; the users.role and sessions.role columns are removed. Role -> group mapping (migration 000057): - admin -> Administrators (00000000-0000-5000-8000-000000000001) - user -> Standard Users (00000000-0000-5000-8000-000000000005) - readonly -> Read-Only Users (00000000-0000-5000-8000-000000000006) Migration 000057 seeds the two role-mirror groups, backfills group_ids from role for any zero-group user, nets any still-zero-group user into Read-Only Users (fail-safe), then enforces group_ids NOT NULL + cardinality(group_ids) >= 1 via a CHECK constraint before dropping the role columns last. The DB can no longer represent a zero-group user. How the role short-circuits were replaced: - session.Role == "admin" checks -> HasPermissionAPI(admin, *) or the specific verb (cancel-any/own, retry-any/own, approve-any) so admins resolve capabilities through the Administrators group, not a literal role. requireAdmin now checks HasPermissionAPI(admin, *) and fails closed (missing auth service / invalid session / lookup error -> deny). - The stateless bootstrap admin API key (a configured secret with no user row) keeps its established full-access bypass; user-backed sessions and API keys resolve permissions purely from group membership. - Registration reviewer resolution derives admins from Administrators-group membership instead of role == "admin". Service-layer guards (issue #907): - CreateUser/UpdateUser reject zero-group membership (ErrNoGroups, 400). - Last-admin protection: cannot remove the last Administrators-group member via update or delete (ErrLastAdmin). - Self-escalation guard: a non-privileged actor editing their own groups cannot add a group they lack manage-users permission for (ErrSelfEscalation, 403). Internal callers (empty actor) are trusted. Security tests (service_group_only_authz_test.go): admin-equivalence, non-admin denial, zero-group fail-closed, lookup-error fail-closed, create/update reject-zero-groups, last-admin protection (update + delete), self-escalation denied, privileged-self-edit allowed. Frontend (role selector -> required group multi-select, permission-derived UI gating) is tracked as a follow-up; this PR is backend + migration only and MUST NOT be deployed before the frontend follow-up lands, because the API stops returning user.role and the current UI gates on it. --- internal/api/coverage_extras_test.go | 6 +- internal/api/coverage_gaps_test.go | 18 +- internal/api/handler.go | 32 ++- internal/api/handler_accounts_router_test.go | 1 + internal/api/handler_accounts_test.go | 11 +- internal/api/handler_analytics_test.go | 7 +- internal/api/handler_apikeys_test.go | 50 ++-- internal/api/handler_auth.go | 2 +- internal/api/handler_auth_test.go | 21 +- internal/api/handler_config_test.go | 26 +- internal/api/handler_coverage_test.go | 70 +++-- internal/api/handler_dashboard_test.go | 4 +- internal/api/handler_federation_test.go | 6 +- internal/api/handler_groups_test.go | 19 +- internal/api/handler_history_test.go | 3 +- internal/api/handler_inventory_test.go | 2 +- .../api/handler_per_account_perms_test.go | 1 - internal/api/handler_plans_test.go | 52 ++-- internal/api/handler_purchases.go | 14 +- internal/api/handler_purchases_test.go | 200 +++++++------ internal/api/handler_registrations.go | 22 +- .../handler_registrations_recipients_test.go | 13 +- internal/api/handler_ri_exchange.go | 8 +- internal/api/handler_ri_exchange_test.go | 11 +- internal/api/handler_router_test.go | 12 +- internal/api/handler_test.go | 54 ++-- internal/api/handler_users.go | 30 +- internal/api/handler_users_test.go | 43 +-- internal/api/middleware.go | 20 +- internal/api/middleware_test.go | 2 - internal/api/mocks_test.go | 22 +- .../api/router_660_permission_flips_test.go | 5 +- internal/api/router_authuser_test.go | 4 +- internal/api/router_handlers_test.go | 72 +++-- internal/api/types.go | 18 +- internal/auth/errors.go | 17 +- internal/auth/interfaces.go | 4 + internal/auth/service.go | 1 - internal/auth/service_api.go | 26 +- internal/auth/service_api_test.go | 57 ++-- internal/auth/service_apikeys.go | 48 ++-- internal/auth/service_apikeys_api_test.go | 30 +- internal/auth/service_apikeys_test.go | 257 +++++++---------- internal/auth/service_group.go | 54 ++-- .../auth/service_group_only_authz_test.go | 271 ++++++++++++++++++ internal/auth/service_group_test.go | 146 ++++++---- internal/auth/service_helpers.go | 2 - internal/auth/service_lockout_test.go | 1 - .../auth/service_password_callback_test.go | 1 - internal/auth/service_test.go | 21 +- internal/auth/service_user.go | 128 +++++++-- internal/auth/service_user_test.go | 125 ++++---- internal/auth/store_postgres.go | 109 ++++--- internal/auth/store_postgres_test.go | 98 +++---- internal/auth/test_helpers.go | 7 +- internal/auth/types.go | 38 ++- .../000057_drop_user_role_to_groups.down.sql | 35 +++ .../000057_drop_user_role_to_groups.up.sql | 106 +++++++ .../000057_drop_user_role_to_groups_test.go | 151 ++++++++++ .../backfill_admin_group_ids_test.go | 13 +- internal/mocks/stores.go | 6 + internal/server/adapter_test.go | 43 +-- internal/server/app.go | 9 +- internal/server/health_test.go | 4 + 64 files changed, 1776 insertions(+), 913 deletions(-) create mode 100644 internal/auth/service_group_only_authz_test.go create mode 100644 internal/database/postgres/migrations/000057_drop_user_role_to_groups.down.sql create mode 100644 internal/database/postgres/migrations/000057_drop_user_role_to_groups.up.sql create mode 100644 internal/database/postgres/migrations/000057_drop_user_role_to_groups_test.go diff --git a/internal/api/coverage_extras_test.go b/internal/api/coverage_extras_test.go index f68acea5c..63c311aba 100644 --- a/internal/api/coverage_extras_test.go +++ b/internal/api/coverage_extras_test.go @@ -262,8 +262,9 @@ func TestHandler_updateRIExchangeConfig_GetGlobalConfigError(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetGlobalConfig", ctx).Return(nil, errors.New("db error")) h := &Handler{auth: mockAuth, config: mockStore} @@ -280,8 +281,9 @@ func TestHandler_updateRIExchangeConfig_SaveError(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) mockStore.On("SaveGlobalConfig", ctx, mock.Anything).Return(errors.New("save failed")) diff --git a/internal/api/coverage_gaps_test.go b/internal/api/coverage_gaps_test.go index 999de28dc..c131c8c29 100644 --- a/internal/api/coverage_gaps_test.go +++ b/internal/api/coverage_gaps_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + "github.com/LeanerCloud/CUDly/internal/auth" "github.com/LeanerCloud/CUDly/internal/config" "github.com/LeanerCloud/CUDly/internal/email" "github.com/aws/aws-lambda-go/events" @@ -304,7 +305,7 @@ func TestHandler_requireAdmin_AdminAPIKey(t *testing.T) { } session, err := h.requireAdmin(context.Background(), req) require.NoError(t, err) - assert.Equal(t, "admin", session.Role) + assert.Equal(t, apiKeyAdminUserID, session.UserID) } func TestHandler_requireAdmin_NoAuthService(t *testing.T) { @@ -343,8 +344,11 @@ func TestHandler_requireAdmin_InvalidSession(t *testing.T) { func TestHandler_requireAdmin_NonAdmin(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - userSession := &Session{UserID: "uid", Role: "user"} + userSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "user-token").Return(userSession, nil) + // A non-admin holds no {admin, *} capability, so HasPermissionAPI(admin, *) + // returns false and requireAdmin must deny. + mockAuth.On("HasPermissionAPI", ctx, "uid", auth.ActionAdmin, auth.ResourceAll).Return(false, nil) h := &Handler{auth: mockAuth} req := &events.LambdaFunctionURLRequest{ Headers: map[string]string{"Authorization": "Bearer user-token"}, @@ -352,20 +356,26 @@ func TestHandler_requireAdmin_NonAdmin(t *testing.T) { _, err := h.requireAdmin(ctx, req) assert.Error(t, err) assert.Contains(t, err.Error(), "admin access required") + mockAuth.AssertExpectations(t) } func TestHandler_requireAdmin_AdminRole(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-uid", Role: "admin"} + adminSession := &Session{UserID: "admin-uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() + // An Administrators-group member holds {admin, *}; HasPermissionAPI(admin, *) + // returns true and requireAdmin grants access. + mockAuth.On("HasPermissionAPI", ctx, "admin-uid", auth.ActionAdmin, auth.ResourceAll).Return(true, nil) h := &Handler{auth: mockAuth} req := &events.LambdaFunctionURLRequest{ Headers: map[string]string{"Authorization": "Bearer admin-token"}, } session, err := h.requireAdmin(ctx, req) require.NoError(t, err) - assert.Equal(t, "admin", session.Role) + assert.Equal(t, "admin-uid", session.UserID) + mockAuth.AssertExpectations(t) } // --------------------------------------------------------------------------- diff --git a/internal/api/handler.go b/internal/api/handler.go index 1f6e1884e..bf3cc3eac 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -181,14 +181,24 @@ func NewHandler(cfg HandlerConfig) *Handler { return h } -// requirePermission validates authentication and checks if the user has the -// specified permission. Admin API keys and admin-role users bypass the check. -// Returns the session on success so callers can read session.UserID for -// account filtering. +// apiKeyAdminUserID is the sentinel UserID assigned to the stateless admin +// API-key session. It has no backing user row in the auth store, so any code +// that would otherwise resolve group-derived permissions for it must treat it +// as full-access up front (the API key is an infrastructure credential, not a +// user). See requirePermission / requireAdmin / getAllowedAccounts. +const apiKeyAdminUserID = "admin-api-key" + +// requirePermission validates authentication and checks if the user holds the +// specified permission. The stateless admin API key bypasses the per-user +// permission lookup (it is a full-access infrastructure credential). Every +// other caller is checked against their group-derived permissions: a member of +// the Administrators group holds {admin, *} and passes any check, while a user +// with no groups holds no 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) { apiKey := extractAPIKey(req) if h.checkAdminAPIKey(apiKey) { - return &Session{Role: "admin", UserID: "admin-api-key"}, nil + return &Session{UserID: apiKeyAdminUserID}, nil } if h.auth == nil { @@ -205,10 +215,6 @@ func (h *Handler) requirePermission(ctx context.Context, req *events.LambdaFunct return nil, NewClientError(401, "invalid session") } - if session.Role == "admin" { - return session, nil - } - has, err := h.auth.HasPermissionAPI(ctx, session.UserID, action, resource) if err != nil { return nil, fmt.Errorf("permission check failed: %w", err) @@ -221,10 +227,12 @@ func (h *Handler) requirePermission(ctx context.Context, req *events.LambdaFunct } // getAllowedAccounts returns the list of account IDs the user is allowed to -// access. Empty slice means all access. Admin users always get all access. +// access. Empty slice means all access (Administrators-group members carry the +// "*" wildcard, which GetAllowedAccountsAPI surfaces as unrestricted). The +// stateless admin API key has no user row, so it short-circuits to all access. func (h *Handler) getAllowedAccounts(ctx context.Context, session *Session) ([]string, error) { - if session.Role == "admin" { - return nil, nil // admin = all access + if session.UserID == apiKeyAdminUserID { + return nil, nil // stateless admin API key = all access } if h.auth == nil { return nil, nil diff --git a/internal/api/handler_accounts_router_test.go b/internal/api/handler_accounts_router_test.go index 7dcf66d94..749a05c3c 100644 --- a/internal/api/handler_accounts_router_test.go +++ b/internal/api/handler_accounts_router_test.go @@ -23,6 +23,7 @@ func routerReq(method, path, body string) (*events.LambdaFunctionURLRequest, str func setupRouterForDispatch(ctx context.Context) *Router { mockAuth := new(MockAuthService) mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminAccountSession(), nil) + mockAuth.grantAdmin() store := new(MockConfigStore) h := &Handler{auth: mockAuth, config: store} return NewRouter(h) diff --git a/internal/api/handler_accounts_test.go b/internal/api/handler_accounts_test.go index 4332fdbec..5bab7ad12 100644 --- a/internal/api/handler_accounts_test.go +++ b/internal/api/handler_accounts_test.go @@ -22,7 +22,6 @@ func adminAccountSession() *Session { return &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } } @@ -54,6 +53,7 @@ func setupAdminMock(ctx context.Context) *MockConfigStore { func setupAdminAuth(ctx context.Context, mockAuth *MockAuthService) { mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminAccountSession(), nil) + mockAuth.grantAdmin() } // --- listAccounts --- @@ -1219,13 +1219,15 @@ func TestDiscoverOrgAccounts_NotFound(t *testing.T) { func TestDiscoverOrgAccounts_RejectsNonAdmin(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - // Non-admin session: ValidateSession returns Role="user", which - // requireAdmin (middleware.go:227-256) explicitly rejects with 403. + // Non-admin session: the user is not an Administrators-group member, so + // HasPermissionAPI(admin,*) returns false and requireAdmin + // (middleware.go) rejects with 403 (issue #907 group-only authz). mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{ UserID: "regular-user", Email: "user@example.com", - Role: "user", }, nil) + mockAuth.On("HasPermissionAPI", ctx, "regular-user", "admin", "*"). + Return(false, nil) handler := &Handler{auth: mockAuth, config: setupAdminMock(ctx)} @@ -1548,7 +1550,6 @@ func scopedUserSession() *Session { return &Session{ UserID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", Email: "viewer@example.com", - Role: "user", } } diff --git a/internal/api/handler_analytics_test.go b/internal/api/handler_analytics_test.go index 099f88f04..06b0d720c 100644 --- a/internal/api/handler_analytics_test.go +++ b/internal/api/handler_analytics_test.go @@ -55,8 +55,8 @@ func adminAnalyticsReq(ctx context.Context) (*MockAuthService, *events.LambdaFun mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", }, nil) + mockAuth.grantAdmin() return mockAuth, &events.LambdaFunctionURLRequest{ Headers: map[string]string{"Authorization": "Bearer admin-token"}, } @@ -161,7 +161,6 @@ func TestHandler_getHistoryAnalytics_ScopedUser_RequiresAccountID(t *testing.T) mockAuth := new(MockAuthService) mockAuth.On("ValidateSession", ctx, "viewer-token").Return(&Session{ UserID: "viewer-1", - Role: "user", }, nil) mockAuth.On("HasPermissionAPI", ctx, "viewer-1", "view", "purchases").Return(true, nil) mockAuth.On("GetAllowedAccountsAPI", ctx, "viewer-1").Return([]string{"Production"}, nil) @@ -316,8 +315,10 @@ func TestHandler_triggerAnalyticsCollection_NonAdmin(t *testing.T) { mockAuth := new(MockAuthService) mockAuth.On("ValidateSession", ctx, "user-token").Return(&Session{ UserID: "user-1", - Role: "user", }, nil) + // Not an Administrators-group member: HasPermissionAPI(admin,*) is false, + // so requireAdmin rejects with 403 (issue #907 group-only authz). + mockAuth.On("HasPermissionAPI", ctx, "user-1", "admin", "*").Return(false, nil) handler := &Handler{auth: mockAuth, analyticsCollector: mockCollector} req := &events.LambdaFunctionURLRequest{ diff --git a/internal/api/handler_apikeys_test.go b/internal/api/handler_apikeys_test.go index 2722fe256..6b7a2bf4f 100644 --- a/internal/api/handler_apikeys_test.go +++ b/internal/api/handler_apikeys_test.go @@ -41,8 +41,9 @@ func TestHandler_listAPIKeys_Success(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "user-123", Email: "user@example.com", Role: "admin"} + session := &Session{UserID: "user-123", Email: "user@example.com"} mockAuth.On("ValidateSession", ctx, "test-token").Return(session, nil) + mockAuth.grantAdmin() expectedKeys := []map[string]interface{}{ {"key_id": "key-1", "name": "Test Key 1"}, @@ -114,8 +115,9 @@ func TestHandler_listAPIKeys_ServiceError(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "user-123", Email: "user@example.com", Role: "admin"} + session := &Session{UserID: "user-123", Email: "user@example.com"} mockAuth.On("ValidateSession", ctx, "test-token").Return(session, nil) + mockAuth.grantAdmin() mockAuth.On("ListUserAPIKeysAPI", ctx, "user-123").Return(nil, errors.New("database error")) handler := &Handler{auth: mockAuth} @@ -136,8 +138,9 @@ func TestHandler_createAPIKey_Success(t *testing.T) { mockAuth := new(MockAuthService) mockRateLimiter := new(MockRateLimiter) - session := &Session{UserID: "user-123", Email: "user@example.com", Role: "admin"} + session := &Session{UserID: "user-123", Email: "user@example.com"} mockAuth.On("ValidateSession", ctx, "test-token").Return(session, nil) + mockAuth.grantAdmin() mockRateLimiter.On("AllowWithUser", ctx, "user-123", "admin").Return(true, nil) expectedResult := map[string]string{"api_key": "new-key-value", "key_id": "key-123"} @@ -204,8 +207,9 @@ func TestHandler_createAPIKey_RateLimited(t *testing.T) { mockAuth := new(MockAuthService) mockRateLimiter := new(MockRateLimiter) - session := &Session{UserID: "user-123", Email: "user@example.com", Role: "admin"} + session := &Session{UserID: "user-123", Email: "user@example.com"} mockAuth.On("ValidateSession", ctx, "test-token").Return(session, nil) + mockAuth.grantAdmin() mockRateLimiter.On("AllowWithUser", ctx, "user-123", "admin").Return(false, nil) handler := &Handler{auth: mockAuth, rateLimiter: mockRateLimiter} @@ -226,8 +230,9 @@ func TestHandler_createAPIKey_InvalidBody(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "user-123", Email: "user@example.com", Role: "admin"} + session := &Session{UserID: "user-123", Email: "user@example.com"} mockAuth.On("ValidateSession", ctx, "test-token").Return(session, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -247,8 +252,9 @@ func TestHandler_createAPIKey_ServiceError(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "user-123", Email: "user@example.com", Role: "admin"} + session := &Session{UserID: "user-123", Email: "user@example.com"} mockAuth.On("ValidateSession", ctx, "test-token").Return(session, nil) + mockAuth.grantAdmin() mockAuth.On("CreateAPIKeyAPI", ctx, "user-123", mock.Anything).Return(nil, errors.New("creation failed")) handler := &Handler{auth: mockAuth} @@ -269,8 +275,9 @@ func TestHandler_deleteAPIKey_Success(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "user-123", Email: "user@example.com", Role: "admin"} + session := &Session{UserID: "user-123", Email: "user@example.com"} mockAuth.On("ValidateSession", ctx, "test-token").Return(session, nil) + mockAuth.grantAdmin() mockAuth.On("DeleteAPIKeyAPI", ctx, "user-123", "11111111-1111-1111-1111-111111111111").Return(nil) handler := &Handler{auth: mockAuth} @@ -321,8 +328,9 @@ func TestHandler_deleteAPIKey_InvalidPath(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "user-123", Email: "user@example.com", Role: "admin"} + session := &Session{UserID: "user-123", Email: "user@example.com"} mockAuth.On("ValidateSession", ctx, "test-token").Return(session, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -346,8 +354,9 @@ func TestHandler_deleteAPIKey_InvalidUUID(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "user-123", Email: "user@example.com", Role: "admin"} + session := &Session{UserID: "user-123", Email: "user@example.com"} mockAuth.On("ValidateSession", ctx, "test-token").Return(session, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -371,8 +380,9 @@ func TestHandler_deleteAPIKey_ServiceError(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "user-123", Email: "user@example.com", Role: "admin"} + session := &Session{UserID: "user-123", Email: "user@example.com"} mockAuth.On("ValidateSession", ctx, "test-token").Return(session, nil) + mockAuth.grantAdmin() mockAuth.On("DeleteAPIKeyAPI", ctx, "user-123", "11111111-1111-1111-1111-111111111111").Return(errors.New("delete failed")) handler := &Handler{auth: mockAuth} @@ -397,8 +407,9 @@ func TestHandler_revokeAPIKey_Success(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "user-123", Email: "user@example.com", Role: "admin"} + session := &Session{UserID: "user-123", Email: "user@example.com"} mockAuth.On("ValidateSession", ctx, "test-token").Return(session, nil) + mockAuth.grantAdmin() mockAuth.On("RevokeAPIKeyAPI", ctx, "user-123", "11111111-1111-1111-1111-111111111111").Return(nil) handler := &Handler{auth: mockAuth} @@ -449,8 +460,9 @@ func TestHandler_revokeAPIKey_InvalidPath(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "user-123", Email: "user@example.com", Role: "admin"} + session := &Session{UserID: "user-123", Email: "user@example.com"} mockAuth.On("ValidateSession", ctx, "test-token").Return(session, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -474,8 +486,9 @@ func TestHandler_revokeAPIKey_InvalidUUID(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "user-123", Email: "user@example.com", Role: "admin"} + session := &Session{UserID: "user-123", Email: "user@example.com"} mockAuth.On("ValidateSession", ctx, "test-token").Return(session, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -499,8 +512,9 @@ func TestHandler_revokeAPIKey_ServiceError(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "user-123", Email: "user@example.com", Role: "admin"} + session := &Session{UserID: "user-123", Email: "user@example.com"} mockAuth.On("ValidateSession", ctx, "test-token").Return(session, nil) + mockAuth.grantAdmin() mockAuth.On("RevokeAPIKeyAPI", ctx, "user-123", "11111111-1111-1111-1111-111111111111").Return(errors.New("revoke failed")) handler := &Handler{auth: mockAuth} @@ -528,7 +542,7 @@ func TestHandler_listAPIKeys_PermissionDenied(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "viewer-1", Email: "viewer@example.com", Role: "user"} + session := &Session{UserID: "viewer-1", Email: "viewer@example.com"} mockAuth.On("ValidateSession", ctx, "viewer-token").Return(session, nil) mockAuth.On("HasPermissionAPI", ctx, "viewer-1", "view", "api-keys").Return(false, nil) @@ -548,7 +562,7 @@ func TestHandler_createAPIKey_PermissionDenied(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "viewer-1", Email: "viewer@example.com", Role: "user"} + session := &Session{UserID: "viewer-1", Email: "viewer@example.com"} mockAuth.On("ValidateSession", ctx, "viewer-token").Return(session, nil) mockAuth.On("HasPermissionAPI", ctx, "viewer-1", "create", "api-keys").Return(false, nil) @@ -569,7 +583,7 @@ func TestHandler_deleteAPIKey_PermissionDenied(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "viewer-1", Email: "viewer@example.com", Role: "user"} + session := &Session{UserID: "viewer-1", Email: "viewer@example.com"} mockAuth.On("ValidateSession", ctx, "viewer-token").Return(session, nil) mockAuth.On("HasPermissionAPI", ctx, "viewer-1", "delete", "api-keys").Return(false, nil) @@ -594,7 +608,7 @@ func TestHandler_revokeAPIKey_PermissionDenied(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "viewer-1", Email: "viewer@example.com", Role: "user"} + session := &Session{UserID: "viewer-1", Email: "viewer@example.com"} mockAuth.On("ValidateSession", ctx, "viewer-token").Return(session, nil) // Revoke reuses the "delete" verb in the permission model. mockAuth.On("HasPermissionAPI", ctx, "viewer-1", "delete", "api-keys").Return(false, nil) diff --git a/internal/api/handler_auth.go b/internal/api/handler_auth.go index 017b7b0ac..0d3f25819 100644 --- a/internal/api/handler_auth.go +++ b/internal/api/handler_auth.go @@ -98,7 +98,7 @@ func (h *Handler) getCurrentUser(ctx context.Context, req *events.LambdaFunction return &CurrentUserResponse{ ID: user.ID, Email: user.Email, - Role: user.Role, + Groups: user.Groups, MFAEnabled: user.MFAEnabled, }, nil } diff --git a/internal/api/handler_auth_test.go b/internal/api/handler_auth_test.go index f7fe30b48..e43b70cc3 100644 --- a/internal/api/handler_auth_test.go +++ b/internal/api/handler_auth_test.go @@ -22,7 +22,6 @@ func TestHandler_login_Success(t *testing.T) { User: &UserInfo{ ID: "12345678-1234-1234-1234-123456789abc", Email: "test@example.com", - Role: "admin", }, } @@ -184,12 +183,11 @@ func TestHandler_getCurrentUser_Success(t *testing.T) { session := &Session{ UserID: "12345678-1234-1234-1234-123456789abc", Email: "test@example.com", - Role: "admin", } user := &User{ ID: "12345678-1234-1234-1234-123456789abc", Email: "test@example.com", - Role: "admin", + Groups: []string{"00000000-0000-5000-8000-000000000001"}, MFAEnabled: true, } @@ -209,7 +207,7 @@ func TestHandler_getCurrentUser_Success(t *testing.T) { assert.Equal(t, "12345678-1234-1234-1234-123456789abc", result.ID) assert.Equal(t, "test@example.com", result.Email) - assert.Equal(t, "admin", result.Role) + assert.Equal(t, []string{"00000000-0000-5000-8000-000000000001"}, result.Groups) assert.True(t, result.MFAEnabled) } @@ -271,7 +269,6 @@ func TestHandler_getCurrentUser_UserNotFound(t *testing.T) { session := &Session{ UserID: "12345678-1234-1234-1234-123456789abc", Email: "test@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "test-token").Return(session, nil) @@ -385,7 +382,6 @@ func TestHandler_setupAdmin_Success(t *testing.T) { User: &UserInfo{ ID: "admin-123", Email: "admin@example.com", - Role: "admin", }, } @@ -720,7 +716,6 @@ func TestHandler_updateProfile_Success(t *testing.T) { session := &Session{ UserID: "12345678-1234-1234-1234-123456789abc", Email: "old@example.com", - Role: "user", } mockAuth.On("ValidateSession", ctx, "test-token").Return(session, nil) @@ -809,7 +804,6 @@ func TestHandler_changePassword_Success(t *testing.T) { session := &Session{ UserID: "11111111-1111-1111-1111-111111111111", Email: "user@example.com", - Role: "user", } mockAuth.On("ValidateSession", ctx, "test-token").Return(session, nil) @@ -859,7 +853,6 @@ func TestHandler_changePassword_InvalidBody(t *testing.T) { session := &Session{ UserID: "11111111-1111-1111-1111-111111111111", Email: "user@example.com", - Role: "user", } mockAuth.On("ValidateSession", ctx, "test-token").Return(session, nil) @@ -1105,7 +1098,7 @@ func TestHandler_login_InvalidMFACode_ReturnsCodedSentinel(t *testing.T) { func TestHandler_mfaSetup_HappyPath(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "user-1", Email: "u@x.com", Role: "user"} + session := &Session{UserID: "user-1", Email: "u@x.com"} mockAuth.On("ValidateSession", ctx, "tok").Return(session, nil) mockAuth.On("MFASetupAPI", ctx, "user-1", "pw"). Return("SECRET123", "otpauth://totp/CUDly:u@x.com?secret=SECRET123", nil) @@ -1120,7 +1113,7 @@ func TestHandler_mfaSetup_HappyPath(t *testing.T) { func TestHandler_mfaSetup_WrongPassword(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "user-1", Email: "u@x.com", Role: "user"} + session := &Session{UserID: "user-1", Email: "u@x.com"} mockAuth.On("ValidateSession", ctx, "tok").Return(session, nil) mockAuth.On("MFASetupAPI", ctx, "user-1", "wrong"). Return("", "", errors.New("invalid password")) @@ -1137,7 +1130,7 @@ func TestHandler_mfaSetup_WrongPassword(t *testing.T) { func TestHandler_mfaEnable_HappyPath(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "user-1", Email: "u@x.com", Role: "user"} + session := &Session{UserID: "user-1", Email: "u@x.com"} mockAuth.On("ValidateSession", ctx, "tok").Return(session, nil) mockAuth.On("MFAEnableAPI", ctx, "user-1", "123456"). Return([]string{"AAAA-BBBB", "CCCC-DDDD"}, nil) @@ -1164,7 +1157,7 @@ func TestHandler_mfaEnable_NoSession(t *testing.T) { func TestHandler_mfaDisable_HappyPath(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "user-1", Email: "u@x.com", Role: "user"} + session := &Session{UserID: "user-1", Email: "u@x.com"} mockAuth.On("ValidateSession", ctx, "tok").Return(session, nil) mockAuth.On("MFADisableAPI", ctx, "user-1", "pw", "123456").Return(nil) @@ -1176,7 +1169,7 @@ func TestHandler_mfaDisable_HappyPath(t *testing.T) { func TestHandler_mfaRegenerateRecoveryCodes_HappyPath(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - session := &Session{UserID: "user-1", Email: "u@x.com", Role: "user"} + session := &Session{UserID: "user-1", Email: "u@x.com"} mockAuth.On("ValidateSession", ctx, "tok").Return(session, nil) mockAuth.On("MFARegenerateRecoveryCodesAPI", ctx, "user-1", "123456"). Return([]string{"AAAA-BBBB"}, nil) diff --git a/internal/api/handler_config_test.go b/internal/api/handler_config_test.go index c8ccfe7d0..247cd9803 100644 --- a/internal/api/handler_config_test.go +++ b/internal/api/handler_config_test.go @@ -45,10 +45,10 @@ func TestHandler_updateConfig(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("SaveGlobalConfig", ctx, mock.AnythingOfType("*config.GlobalConfig")).Return(nil) // Mock ListServiceConfigs for propagation of global defaults mockStore.On("ListServiceConfigs", ctx).Return([]config.ServiceConfig{}, nil) @@ -83,10 +83,10 @@ func TestHandler_updateConfig_InvalidBody(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{corsAllowedOrigin: "*", auth: mockAuth} @@ -161,10 +161,10 @@ func TestHandler_updateServiceConfig(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetServiceConfig", ctx, "aws", "rds").Return(nil, nil) mockStore.On("SaveServiceConfig", ctx, mock.AnythingOfType("*config.ServiceConfig")).Return(nil) @@ -190,10 +190,10 @@ func TestHandler_updateServiceConfig_InvalidBody(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{corsAllowedOrigin: "*", auth: mockAuth} @@ -218,9 +218,9 @@ func TestHandler_updateServiceConfig_CommitmentOptsReject(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetServiceConfig", ctx, "aws", "rds").Return(nil, nil) // Probe data says RDS 3yr no-upfront doesn't exist. Save must 400. @@ -263,9 +263,9 @@ func TestHandler_updateServiceConfig_CommitmentOptsAccept(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetServiceConfig", ctx, "aws", "rds").Return(nil, nil) mockStore.On("SaveServiceConfig", ctx, mock.AnythingOfType("*config.ServiceConfig")).Return(nil) @@ -298,10 +298,10 @@ func TestHandler_updateServiceConfig_NoSlash(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{config: mockStore, auth: mockAuth} @@ -371,10 +371,10 @@ func TestHandler_updateConfig_ValidationError(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() // updateConfig calls GetGlobalConfig before validation to preserve persisted // values for fields omitted from the request body (PR #308 CR pass-2). The // validation error fires after the merge, so the mock is still required. @@ -407,10 +407,10 @@ func TestHandler_updateConfig_SaveError(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("SaveGlobalConfig", ctx, mock.AnythingOfType("*config.GlobalConfig")).Return(assert.AnError) // updateConfig calls GetGlobalConfig before save to preserve persisted // values for fields omitted from the request body (PR #308 CR pass-2). @@ -441,10 +441,10 @@ func TestHandler_updateServiceConfig_SaveError(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetServiceConfig", ctx, "aws", "rds").Return(nil, nil) mockStore.On("SaveServiceConfig", ctx, mock.AnythingOfType("*config.ServiceConfig")).Return(assert.AnError) @@ -480,7 +480,6 @@ func TestHandler_updateConfig_WithPropagation(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } serviceConfigs := []config.ServiceConfig{ @@ -489,6 +488,7 @@ func TestHandler_updateConfig_WithPropagation(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("SaveGlobalConfig", ctx, mock.AnythingOfType("*config.GlobalConfig")).Return(nil) // updateConfig calls GetGlobalConfig before save to preserve persisted // values for fields omitted from the request body (PR #308 CR pass-2). @@ -524,7 +524,6 @@ func TestHandler_updateConfig_PropagationServiceSaveError(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } serviceConfigs := []config.ServiceConfig{ @@ -532,6 +531,7 @@ func TestHandler_updateConfig_PropagationServiceSaveError(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("SaveGlobalConfig", ctx, mock.AnythingOfType("*config.GlobalConfig")).Return(nil) // updateConfig calls GetGlobalConfig before save to preserve persisted // values for fields omitted from the request body (PR #308 CR pass-2). @@ -566,10 +566,10 @@ func TestHandler_updateConfig_PropagationListError(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("SaveGlobalConfig", ctx, mock.AnythingOfType("*config.GlobalConfig")).Return(nil) // updateConfig calls GetGlobalConfig before save to preserve persisted // values for fields omitted from the request body (PR #308 CR pass-2). diff --git a/internal/api/handler_coverage_test.go b/internal/api/handler_coverage_test.go index ed74fde32..28b867863 100644 --- a/internal/api/handler_coverage_test.go +++ b/internal/api/handler_coverage_test.go @@ -206,6 +206,7 @@ func TestRouter_Handlers_Coverage(t *testing.T) { t.Run("getCurrentUserHandler", func(t *testing.T) { mockAuth := new(MockAuthService) mockAuth.On("ValidateSession", ctx, "test-token").Return(&Session{UserID: "user-1"}, nil) + mockAuth.grantAdmin() mockAuth.On("GetUser", ctx, "user-1").Return(&User{ID: "user-1", Email: "test@example.com"}, nil) h := &Handler{auth: mockAuth} @@ -283,6 +284,7 @@ func TestRouter_Handlers_Coverage(t *testing.T) { t.Run("updateProfileHandler", func(t *testing.T) { mockAuth := new(MockAuthService) mockAuth.On("ValidateSession", ctx, "test-token").Return(&Session{UserID: "user-1"}, nil) + mockAuth.grantAdmin() mockAuth.On("UpdateUserProfile", ctx, "user-1", mock.Anything, mock.Anything, mock.Anything).Return(nil) h := &Handler{auth: mockAuth} @@ -301,6 +303,7 @@ func TestRouter_Handlers_Coverage(t *testing.T) { t.Run("changePasswordHandler", func(t *testing.T) { mockAuth := new(MockAuthService) mockAuth.On("ValidateSession", ctx, "test-token").Return(&Session{UserID: "user-1"}, nil) + mockAuth.grantAdmin() // Passwords need to be base64 encoded // "oldpass" -> "b2xkcGFzcw==", "newpass" -> "bmV3cGFzcw==" mockAuth.On("ChangePasswordAPI", ctx, "user-1", "oldpass", "newpass").Return(nil) @@ -320,7 +323,8 @@ func TestRouter_Handlers_Coverage(t *testing.T) { t.Run("listAPIKeysHandler", func(t *testing.T) { mockAuth := new(MockAuthService) - mockAuth.On("ValidateSession", ctx, "test-token").Return(&Session{UserID: "user-1", Role: "admin"}, nil) + mockAuth.On("ValidateSession", ctx, "test-token").Return(&Session{UserID: "user-1"}, nil) + mockAuth.grantAdmin() mockAuth.On("ListUserAPIKeysAPI", ctx, "user-1").Return([]interface{}{}, nil) h := &Handler{auth: mockAuth} @@ -337,7 +341,8 @@ func TestRouter_Handlers_Coverage(t *testing.T) { t.Run("createAPIKeyHandler", func(t *testing.T) { mockAuth := new(MockAuthService) - mockAuth.On("ValidateSession", ctx, "test-token").Return(&Session{UserID: "user-1", Role: "admin"}, nil) + mockAuth.On("ValidateSession", ctx, "test-token").Return(&Session{UserID: "user-1"}, nil) + mockAuth.grantAdmin() mockAuth.On("CreateAPIKeyAPI", ctx, "user-1", mock.Anything).Return(map[string]string{"key_id": "key-1"}, nil) h := &Handler{auth: mockAuth} @@ -355,7 +360,8 @@ func TestRouter_Handlers_Coverage(t *testing.T) { t.Run("deleteAPIKeyHandler", func(t *testing.T) { mockAuth := new(MockAuthService) - mockAuth.On("ValidateSession", ctx, "test-token").Return(&Session{UserID: "user-1", Role: "admin"}, nil) + mockAuth.On("ValidateSession", ctx, "test-token").Return(&Session{UserID: "user-1"}, nil) + mockAuth.grantAdmin() mockAuth.On("DeleteAPIKeyAPI", ctx, "user-1", "11111111-1111-1111-1111-111111111111").Return(nil) h := &Handler{auth: mockAuth} @@ -377,7 +383,8 @@ func TestRouter_Handlers_Coverage(t *testing.T) { t.Run("revokeAPIKeyHandler", func(t *testing.T) { mockAuth := new(MockAuthService) - mockAuth.On("ValidateSession", ctx, "test-token").Return(&Session{UserID: "user-1", Role: "admin"}, nil) + mockAuth.On("ValidateSession", ctx, "test-token").Return(&Session{UserID: "user-1"}, nil) + mockAuth.grantAdmin() mockAuth.On("RevokeAPIKeyAPI", ctx, "user-1", "11111111-1111-1111-1111-111111111111").Return(nil) h := &Handler{auth: mockAuth} @@ -399,7 +406,8 @@ func TestRouter_Handlers_Coverage(t *testing.T) { t.Run("listUsersHandler", func(t *testing.T) { mockAuth := new(MockAuthService) - mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "admin", Role: "admin"}, nil) + mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "admin"}, nil) + mockAuth.grantAdmin() mockAuth.On("ListUsersAPI", ctx).Return([]interface{}{}, nil) h := &Handler{auth: mockAuth} @@ -416,7 +424,8 @@ func TestRouter_Handlers_Coverage(t *testing.T) { t.Run("createUserHandler", func(t *testing.T) { mockAuth := new(MockAuthService) - mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "admin", Role: "admin"}, nil) + mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "admin"}, nil) + mockAuth.grantAdmin() mockAuth.On("CreateUserAPI", ctx, mock.Anything).Return(map[string]string{"id": "new-user"}, nil) h := &Handler{auth: mockAuth} @@ -425,7 +434,7 @@ func TestRouter_Handlers_Coverage(t *testing.T) { // Password needs to be base64 encoded: "pass123" -> "cGFzczEyMw==" req := &events.LambdaFunctionURLRequest{ Headers: map[string]string{"Authorization": "Bearer admin-token"}, - Body: `{"email": "newuser@example.com", "password": "cGFzczEyMw==", "role": "user"}`, + Body: `{"email": "newuser@example.com", "password": "cGFzczEyMw==", "groups": ["00000000-0000-5000-8000-000000000005"]}`, } result, err := router.createUserHandler(ctx, req, nil) @@ -435,7 +444,8 @@ func TestRouter_Handlers_Coverage(t *testing.T) { t.Run("getUserHandler", func(t *testing.T) { mockAuth := new(MockAuthService) - mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "admin", Role: "admin"}, nil) + mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "admin"}, nil) + mockAuth.grantAdmin() mockAuth.On("GetUser", ctx, "11111111-1111-1111-1111-111111111111").Return(&User{ID: "11111111-1111-1111-1111-111111111111"}, nil) h := &Handler{auth: mockAuth} @@ -453,8 +463,9 @@ func TestRouter_Handlers_Coverage(t *testing.T) { t.Run("updateUserHandler", func(t *testing.T) { mockAuth := new(MockAuthService) - mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "admin", Role: "admin"}, nil) - mockAuth.On("UpdateUserAPI", ctx, "11111111-1111-1111-1111-111111111111", mock.Anything).Return(map[string]string{}, nil) + mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "admin"}, nil) + mockAuth.grantAdmin() + mockAuth.On("UpdateUserAPI", ctx, "admin", "11111111-1111-1111-1111-111111111111", mock.Anything).Return(map[string]string{}, nil) h := &Handler{auth: mockAuth} router := NewRouter(h) @@ -472,7 +483,8 @@ func TestRouter_Handlers_Coverage(t *testing.T) { t.Run("listGroupsHandler", func(t *testing.T) { mockAuth := new(MockAuthService) - mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "admin", Role: "admin"}, nil) + mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "admin"}, nil) + mockAuth.grantAdmin() mockAuth.On("ListGroupsAPI", ctx).Return([]interface{}{}, nil) h := &Handler{auth: mockAuth} @@ -489,7 +501,8 @@ func TestRouter_Handlers_Coverage(t *testing.T) { t.Run("createGroupHandler", func(t *testing.T) { mockAuth := new(MockAuthService) - mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "admin", Role: "admin"}, nil) + mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "admin"}, nil) + mockAuth.grantAdmin() mockAuth.On("CreateGroupAPI", ctx, mock.Anything).Return(map[string]string{"id": "new-group"}, nil) h := &Handler{auth: mockAuth} @@ -507,7 +520,8 @@ func TestRouter_Handlers_Coverage(t *testing.T) { t.Run("getGroupHandler", func(t *testing.T) { mockAuth := new(MockAuthService) - mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "admin", Role: "admin"}, nil) + mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "admin"}, nil) + mockAuth.grantAdmin() mockAuth.On("GetGroupAPI", ctx, "11111111-1111-1111-1111-111111111111").Return(map[string]interface{}{"id": "11111111-1111-1111-1111-111111111111"}, nil) h := &Handler{auth: mockAuth} @@ -525,7 +539,8 @@ func TestRouter_Handlers_Coverage(t *testing.T) { t.Run("updateGroupHandler", func(t *testing.T) { mockAuth := new(MockAuthService) - mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "admin", Role: "admin"}, nil) + mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "admin"}, nil) + mockAuth.grantAdmin() mockAuth.On("UpdateGroupAPI", ctx, "11111111-1111-1111-1111-111111111111", mock.Anything).Return(map[string]string{}, nil) h := &Handler{auth: mockAuth} @@ -544,7 +559,8 @@ func TestRouter_Handlers_Coverage(t *testing.T) { t.Run("deleteGroupHandler", func(t *testing.T) { mockAuth := new(MockAuthService) - mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "admin", Role: "admin"}, nil) + mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "admin"}, nil) + mockAuth.grantAdmin() mockAuth.On("DeleteGroup", ctx, "11111111-1111-1111-1111-111111111111").Return(nil) h := &Handler{auth: mockAuth} @@ -626,8 +642,9 @@ func TestHandler_listPlans_Error(t *testing.T) { mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Role: "admin"} + adminSession := &Session{UserID: "admin-id"} mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("ListPurchasePlans", mock.Anything, mock.Anything).Return(nil, errors.New("db error")) handler := &Handler{config: mockStore, auth: mockAuth} @@ -645,8 +662,9 @@ func TestHandler_getPlan_NotFound(t *testing.T) { mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Role: "admin"} + adminSession := &Session{UserID: "admin-id"} mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetPurchasePlan", mock.Anything, "11111111-1111-1111-1111-111111111111").Return(nil, errors.New("not found")) handler := &Handler{config: mockStore, auth: mockAuth} @@ -667,8 +685,9 @@ func TestHandler_deleteUser_CannotDeleteSelf(t *testing.T) { // Use a valid UUID format adminID := "11111111-1111-1111-1111-111111111111" - adminSession := &Session{UserID: adminID, Role: "admin"} + adminSession := &Session{UserID: adminID} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -685,8 +704,9 @@ func TestHandler_deleteUser_Error(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Role: "admin"} + adminSession := &Session{UserID: "admin-id"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockAuth.On("DeleteUser", ctx, "other-user-id").Return(errors.New("delete failed")) handler := &Handler{auth: mockAuth} @@ -705,8 +725,9 @@ func TestHandler_deleteGroup_DeleteFailed(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Role: "admin"} + adminSession := &Session{UserID: "admin-id"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockAuth.On("DeleteGroup", ctx, "group-1").Return(errors.New("delete failed")) handler := &Handler{auth: mockAuth} @@ -834,8 +855,9 @@ func TestHandler_createGroup_InvalidBody(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Role: "admin"} + adminSession := &Session{UserID: "admin-id"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -871,8 +893,9 @@ func TestHandler_deletePlan_Success(t *testing.T) { mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Role: "admin"} + adminSession := &Session{UserID: "admin-id"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("DeletePurchasePlan", mock.Anything, "11111111-1111-1111-1111-111111111111").Return(nil) handler := &Handler{config: mockStore, auth: mockAuth} @@ -909,8 +932,9 @@ func TestHandler_deletePlan_Error(t *testing.T) { mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Role: "admin"} + adminSession := &Session{UserID: "admin-id"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("DeletePurchasePlan", mock.Anything, "11111111-1111-1111-1111-111111111111").Return(errors.New("not found")) handler := &Handler{config: mockStore, auth: mockAuth} diff --git a/internal/api/handler_dashboard_test.go b/internal/api/handler_dashboard_test.go index 43c673ee5..2ceb67b34 100644 --- a/internal/api/handler_dashboard_test.go +++ b/internal/api/handler_dashboard_test.go @@ -32,8 +32,8 @@ func adminDashboardReq(ctx context.Context) (*MockAuthService, *events.LambdaFun mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", }, nil) + mockAuth.grantAdmin() return mockAuth, &events.LambdaFunctionURLRequest{ Headers: map[string]string{"Authorization": "Bearer admin-token"}, } @@ -468,7 +468,6 @@ func TestHandler_getUpcomingPurchases_ScopedUser(t *testing.T) { mockAuth.On("ValidateSession", ctx, "viewer-token").Return(&Session{ UserID: "viewer-1", - Role: "user", }, nil) mockAuth.On("HasPermissionAPI", ctx, "viewer-1", "view", "purchases").Return(true, nil) mockAuth.On("GetAllowedAccountsAPI", ctx, "viewer-1").Return([]string{"Production"}, nil) @@ -514,7 +513,6 @@ func TestHandler_getUpcomingPurchases_ScopedUser_SkipsUnattributed(t *testing.T) mockAuth.On("ValidateSession", ctx, "viewer-token").Return(&Session{ UserID: "viewer-1", - Role: "user", }, nil) mockAuth.On("HasPermissionAPI", ctx, "viewer-1", "view", "purchases").Return(true, nil) mockAuth.On("GetAllowedAccountsAPI", ctx, "viewer-1").Return([]string{"Production"}, nil) diff --git a/internal/api/handler_federation_test.go b/internal/api/handler_federation_test.go index 8ffae83d6..10896294c 100644 --- a/internal/api/handler_federation_test.go +++ b/internal/api/handler_federation_test.go @@ -38,8 +38,8 @@ func federationHandler() *Handler { mockAuth.On("ValidateSession", mock.Anything, "admin-token").Return(&Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", }, nil) + mockAuth.grantAdmin() h := NewHandler(HandlerConfig{ConfigStore: new(MockConfigStore), AuthService: mockAuth}) mockSourceIdentity(h, defaultTestSourceIdentity()) return h @@ -681,8 +681,8 @@ func TestGetFederationIaC_PreservesPlusInSessionEmail(t *testing.T) { mockAuth.On("ValidateSession", mock.Anything, "admin-token").Return(&Session{ UserID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", Email: "user+tag@example.com", - Role: "admin", }, nil) + mockAuth.grantAdmin() h := NewHandler(HandlerConfig{ConfigStore: new(MockConfigStore), AuthService: mockAuth}) // Pre-warm the source identity to satisfy validateSourceIdentity (#41). mockSourceIdentity(h, defaultTestSourceIdentity()) @@ -708,8 +708,8 @@ func TestGetFederationIaC_NoSessionEmail_ShipsBundleWithEmptyContact(t *testing. mockAuth.On("ValidateSession", mock.Anything, "admin-token").Return(&Session{ UserID: "cccccccc-cccc-cccc-cccc-cccccccccccc", Email: "", // empty — admin API key path - Role: "admin", }, nil) + mockAuth.grantAdmin() h := NewHandler(HandlerConfig{ConfigStore: new(MockConfigStore), AuthService: mockAuth}) // Pre-warm the source identity to satisfy validateSourceIdentity (#41). mockSourceIdentity(h, defaultTestSourceIdentity()) diff --git a/internal/api/handler_groups_test.go b/internal/api/handler_groups_test.go index 8109cd00e..1a49d4969 100644 --- a/internal/api/handler_groups_test.go +++ b/internal/api/handler_groups_test.go @@ -17,7 +17,6 @@ func TestHandler_listGroups_Success(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } groups := []interface{}{ @@ -26,6 +25,7 @@ func TestHandler_listGroups_Success(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockAuth.On("ListGroupsAPI", ctx).Return(groups, nil) handler := &Handler{auth: mockAuth} @@ -50,7 +50,6 @@ func TestHandler_createGroup_Success(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } createdGroup := map[string]interface{}{ @@ -59,6 +58,7 @@ func TestHandler_createGroup_Success(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockAuth.On("CreateGroupAPI", ctx, mock.Anything).Return(createdGroup, nil) handler := &Handler{auth: mockAuth} @@ -82,7 +82,6 @@ func TestHandler_getGroup_Success(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } group := map[string]interface{}{ @@ -91,6 +90,7 @@ func TestHandler_getGroup_Success(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockAuth.On("GetGroupAPI", ctx, "11111111-1111-1111-1111-111111111111").Return(group, nil) handler := &Handler{auth: mockAuth} @@ -113,7 +113,6 @@ func TestHandler_updateGroup_Success(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } updatedGroup := map[string]interface{}{ @@ -122,6 +121,7 @@ func TestHandler_updateGroup_Success(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockAuth.On("UpdateGroupAPI", ctx, "11111111-1111-1111-1111-111111111111", mock.Anything).Return(updatedGroup, nil) handler := &Handler{auth: mockAuth} @@ -145,10 +145,10 @@ func TestHandler_deleteGroup_Success(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockAuth.On("DeleteGroup", ctx, "11111111-1111-1111-1111-111111111111").Return(nil) handler := &Handler{auth: mockAuth} @@ -169,8 +169,9 @@ func TestHandler_createGroup_InvalidJSON(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Role: "admin"} + adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -189,8 +190,9 @@ func TestHandler_updateGroup_InvalidJSON(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Role: "admin"} + adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -209,8 +211,9 @@ func TestHandler_deleteGroup_Error(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Role: "admin"} + adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockAuth.On("DeleteGroup", ctx, "11111111-1111-1111-1111-111111111111").Return(assert.AnError) handler := &Handler{auth: mockAuth} diff --git a/internal/api/handler_history_test.go b/internal/api/handler_history_test.go index e58799f41..a0144ba53 100644 --- a/internal/api/handler_history_test.go +++ b/internal/api/handler_history_test.go @@ -21,8 +21,8 @@ func adminHistoryReq(ctx context.Context) (*MockAuthService, *events.LambdaFunct mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", }, nil) + mockAuth.grantAdmin() req := &events.LambdaFunctionURLRequest{ Headers: map[string]string{"Authorization": "Bearer admin-token"}, } @@ -336,7 +336,6 @@ func TestHandler_getHistory_PermissionDenied(t *testing.T) { mockAuth := new(MockAuthService) mockAuth.On("ValidateSession", ctx, "viewer-token").Return(&Session{ UserID: "viewer-1", - Role: "user", }, nil) mockAuth.On("HasPermissionAPI", ctx, "viewer-1", "view", "purchases").Return(false, nil) diff --git a/internal/api/handler_inventory_test.go b/internal/api/handler_inventory_test.go index 047dafec9..17ae2de60 100644 --- a/internal/api/handler_inventory_test.go +++ b/internal/api/handler_inventory_test.go @@ -20,8 +20,8 @@ func adminInventoryReq(ctx context.Context) (*MockAuthService, *events.LambdaFun mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", }, nil) + mockAuth.grantAdmin() req := &events.LambdaFunctionURLRequest{ Headers: map[string]string{"Authorization": "Bearer admin-token"}, } diff --git a/internal/api/handler_per_account_perms_test.go b/internal/api/handler_per_account_perms_test.go index eff74f43f..56a600438 100644 --- a/internal/api/handler_per_account_perms_test.go +++ b/internal/api/handler_per_account_perms_test.go @@ -64,7 +64,6 @@ func scopedAuthMock(ctx context.Context) *MockAuthService { m.On("ValidateSession", ctx, permsScopedToken).Return(&Session{ UserID: permsScopedUserID, Email: "scoped@example.com", - Role: "user", }, nil) // Grant every permission so role-gating doesn't interfere with what we // actually want to test (account-level scoping). diff --git a/internal/api/handler_plans_test.go b/internal/api/handler_plans_test.go index 62524b04c..3e49d712b 100644 --- a/internal/api/handler_plans_test.go +++ b/internal/api/handler_plans_test.go @@ -22,7 +22,6 @@ func TestHandler_listPlans(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } plans := []config.PurchasePlan{ @@ -31,6 +30,7 @@ func TestHandler_listPlans(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return(plans, nil) handler := &Handler{config: mockStore, auth: mockAuth} @@ -54,7 +54,6 @@ func TestHandler_listPlans_AccountIDsFilter(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } plans := []config.PurchasePlan{ @@ -65,6 +64,7 @@ func TestHandler_listPlans_AccountIDsFilter(t *testing.T) { expectedFilter := config.PurchasePlanFilter{AccountIDs: []string{accountID}} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("ListPurchasePlans", ctx, expectedFilter).Return(plans, nil) handler := &Handler{config: mockStore, auth: mockAuth} @@ -90,12 +90,12 @@ func TestHandler_createPlan(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } targetAccountID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("CreatePurchasePlan", ctx, mock.AnythingOfType("*config.PurchasePlan")).Return(nil) mockStore.On("SetPlanAccounts", ctx, mock.AnythingOfType("string"), []string{targetAccountID}).Return(nil) @@ -131,10 +131,10 @@ func TestHandler_createPlan_InvalidBody(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{corsAllowedOrigin: "*", auth: mockAuth} @@ -161,9 +161,9 @@ func TestHandler_createPlan_RejectsEmptyTargetAccounts(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() cases := []struct { name string @@ -210,11 +210,11 @@ func TestHandler_createPlan_RollbackDeleteFailureSurfacesOriginalError(t *testin adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } targetAccountID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("CreatePurchasePlan", ctx, mock.AnythingOfType("*config.PurchasePlan")).Return(nil) setAccountsErr := errors.New("setplanaccounts boom") mockStore.On("SetPlanAccounts", ctx, mock.AnythingOfType("string"), []string{targetAccountID}).Return(setAccountsErr) @@ -258,9 +258,9 @@ func TestHandler_createPlan_RejectsInvalidTargetAccountUUID(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{config: mockStore, auth: mockAuth} body := `{"name": "P", "provider": "aws", "service": "rds", "target_accounts": ["not-a-uuid"]}` @@ -285,7 +285,6 @@ func TestHandler_getPlan(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } plan := &config.PurchasePlan{ @@ -295,6 +294,7 @@ func TestHandler_getPlan(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetPurchasePlan", ctx, "12345678-1234-1234-1234-123456789abc").Return(plan, nil) handler := &Handler{config: mockStore, auth: mockAuth} @@ -319,7 +319,6 @@ func TestHandler_updatePlan(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } existingPlan := &config.PurchasePlan{ @@ -329,6 +328,7 @@ func TestHandler_updatePlan(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetPurchasePlan", ctx, "12345678-1234-1234-1234-123456789abc").Return(existingPlan, nil) mockStore.On("UpdatePurchasePlan", ctx, mock.AnythingOfType("*config.PurchasePlan")).Return(nil) @@ -357,10 +357,10 @@ func TestHandler_deletePlan(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("DeletePurchasePlan", ctx, "12345678-1234-1234-1234-123456789abc").Return(nil) handler := &Handler{config: mockStore, auth: mockAuth} @@ -384,10 +384,10 @@ func TestHandler_updatePlan_InvalidBody(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{corsAllowedOrigin: "*", auth: mockAuth} @@ -414,7 +414,6 @@ func TestHandler_createPlannedPurchases(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } plan := &config.PurchasePlan{ @@ -427,6 +426,7 @@ func TestHandler_createPlannedPurchases(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetPurchasePlan", ctx, "11111111-1111-1111-1111-111111111111").Return(plan, nil) mockStore.On("SavePurchaseExecution", ctx, mock.AnythingOfType("*config.PurchaseExecution")).Return(nil).Times(3) mockStore.On("UpdatePurchasePlan", ctx, mock.AnythingOfType("*config.PurchasePlan")).Return(nil) @@ -462,7 +462,6 @@ func TestHandler_createPlannedPurchases_MidLoopFailureRollsBack(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } plan := &config.PurchasePlan{ @@ -475,6 +474,7 @@ func TestHandler_createPlannedPurchases_MidLoopFailureRollsBack(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetPurchasePlan", ctx, "11111111-1111-1111-1111-111111111111").Return(plan, nil) // Explicit WithTx expectation: this is the regression CR flagged. The @@ -554,10 +554,10 @@ func TestHandler_createPlannedPurchases_InvalidCount(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -581,10 +581,10 @@ func TestHandler_createPlannedPurchases_InvalidDate(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -610,10 +610,10 @@ func TestHandler_createPlannedPurchases_InvalidJSON(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -638,10 +638,10 @@ func TestHandler_createPlannedPurchases_PlanNotFound(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetPurchasePlan", ctx, "99999999-9999-9999-9999-999999999999").Return(nil, errors.New("plan not found")) handler := &Handler{config: mockStore, auth: mockAuth} @@ -717,7 +717,6 @@ func TestHandler_patchPlan_Success(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } existingPlan := &config.PurchasePlan{ @@ -733,6 +732,7 @@ func TestHandler_patchPlan_Success(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetPurchasePlan", ctx, "11111111-1111-1111-1111-111111111111").Return(existingPlan, nil) mockStore.On("UpdatePurchasePlan", ctx, mock.MatchedBy(func(p *config.PurchasePlan) bool { return p.Enabled == true && p.Name == "Original Name" @@ -762,7 +762,6 @@ func TestHandler_patchPlan_UpdateMultipleFields(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } existingPlan := &config.PurchasePlan{ @@ -777,6 +776,7 @@ func TestHandler_patchPlan_UpdateMultipleFields(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetPurchasePlan", ctx, "11111111-1111-1111-1111-111111111111").Return(existingPlan, nil) mockStore.On("UpdatePurchasePlan", ctx, mock.MatchedBy(func(p *config.PurchasePlan) bool { return p.Enabled == true && p.Name == "New Name" && p.AutoPurchase == true && p.NotificationDaysBefore == 5 @@ -825,10 +825,10 @@ func TestHandler_patchPlan_InvalidBody(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -852,10 +852,10 @@ func TestHandler_patchPlan_NotFound(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetPurchasePlan", ctx, "99999999-9999-9999-9999-999999999999").Return(nil, errors.New("not found")) handler := &Handler{config: mockStore, auth: mockAuth} @@ -880,10 +880,10 @@ func TestHandler_patchPlan_NilPlan(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetPurchasePlan", ctx, "99999999-9999-9999-9999-999999999999").Return(nil, nil) handler := &Handler{config: mockStore, auth: mockAuth} @@ -908,7 +908,6 @@ func TestHandler_patchPlan_EmptyName(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } existingPlan := &config.PurchasePlan{ @@ -918,6 +917,7 @@ func TestHandler_patchPlan_EmptyName(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetPurchasePlan", ctx, "11111111-1111-1111-1111-111111111111").Return(existingPlan, nil) handler := &Handler{config: mockStore, auth: mockAuth} @@ -942,7 +942,6 @@ func TestHandler_patchPlan_InvalidNotificationDays(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } existingPlan := &config.PurchasePlan{ @@ -952,6 +951,7 @@ func TestHandler_patchPlan_InvalidNotificationDays(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetPurchasePlan", ctx, "11111111-1111-1111-1111-111111111111").Return(existingPlan, nil) handler := &Handler{config: mockStore, auth: mockAuth} @@ -976,7 +976,6 @@ func TestHandler_patchPlan_NegativeNotificationDays(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } existingPlan := &config.PurchasePlan{ @@ -986,6 +985,7 @@ func TestHandler_patchPlan_NegativeNotificationDays(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetPurchasePlan", ctx, "11111111-1111-1111-1111-111111111111").Return(existingPlan, nil) handler := &Handler{config: mockStore, auth: mockAuth} @@ -1010,7 +1010,6 @@ func TestHandler_patchPlan_UpdateError(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } existingPlan := &config.PurchasePlan{ @@ -1020,6 +1019,7 @@ func TestHandler_patchPlan_UpdateError(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetPurchasePlan", ctx, "11111111-1111-1111-1111-111111111111").Return(existingPlan, nil) mockStore.On("UpdatePurchasePlan", ctx, mock.AnythingOfType("*config.PurchasePlan")).Return(errors.New("database error")) diff --git a/internal/api/handler_purchases.go b/internal/api/handler_purchases.go index 3e6f88058..d8700039a 100644 --- a/internal/api/handler_purchases.go +++ b/internal/api/handler_purchases.go @@ -461,7 +461,11 @@ func (h *Handler) approvePurchaseViaSession(ctx context.Context, req *events.Lam // rules added in issue #286. Returns a 403 ClientError otherwise. // Mirror of authorizeSessionCancel. func (h *Handler) authorizeSessionApprove(ctx context.Context, session *Session, execution *config.PurchaseExecution) error { - if session.Role == "admin" { + // The stateless admin API key has full access and no user row to resolve + // permissions from. Administrators-group users fall through and pass via + // the approve-any HasPermissionAPI check below, since {admin, *} matches + // any requested permission. + if session.UserID == apiKeyAdminUserID { return nil } if h.auth == nil { @@ -628,7 +632,9 @@ func (h *Handler) cancelPurchaseViaSession(ctx context.Context, req *events.Lamb // the given execution under the cancel-any / cancel-own RBAC rules added in // issue #46. Returns a 403 ClientError otherwise. func (h *Handler) authorizeSessionCancel(ctx context.Context, session *Session, execution *config.PurchaseExecution) error { - if session.Role == "admin" { + // Stateless admin API key: full access, no user row. Administrators-group + // users pass via the cancel-any HasPermissionAPI check below. + if session.UserID == apiKeyAdminUserID { return nil } if h.auth == nil { @@ -978,7 +984,9 @@ func (h *Handler) persistRetryExecution(ctx context.Context, failedExec *config. // the session UserID — legacy NULL-creator rows are out of reach for // non-admins, same as the cancel path). func (h *Handler) authorizeSessionRetry(ctx context.Context, session *Session, execution *config.PurchaseExecution) error { - if session.Role == "admin" { + // Stateless admin API key: full access, no user row. Administrators-group + // users pass via the retry-any HasPermissionAPI check below. + if session.UserID == apiKeyAdminUserID { return nil } if h.auth == nil { diff --git a/internal/api/handler_purchases_test.go b/internal/api/handler_purchases_test.go index 09e61a918..7fa74b4e0 100644 --- a/internal/api/handler_purchases_test.go +++ b/internal/api/handler_purchases_test.go @@ -298,7 +298,8 @@ func TestHandler_approvePurchase_SessionApproveAnyChainsToExecute(t *testing.T) mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockAuth := new(MockAuthService) - mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: adminEmail, Role: "admin"}, nil) + mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: adminEmail}, nil) + mockAuth.grantAdmin() mockPurchase := new(MockPurchaseManager) // CRITICAL ASSERTION: session-authed approve goes through @@ -341,7 +342,8 @@ func TestHandler_approvePurchase_SessionExecuteFailureSurfacesAs409(t *testing.T mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockAuth := new(MockAuthService) - mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: adminEmail, Role: "admin"}, nil) + mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: adminEmail}, nil) + mockAuth.grantAdmin() mockPurchase := new(MockPurchaseManager) mockPurchase.On("ApproveAndExecute", ctx, execID, adminEmail).Return(errors.New("AWS RI purchase failed")) @@ -381,7 +383,8 @@ func TestHandler_approvePurchase_AzureOrphanRejects409(t *testing.T) { mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockAuth := new(MockAuthService) - mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: "admin@example.com", Role: "admin"}, nil) + mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: "admin@example.com"}, nil) + mockAuth.grantAdmin() mockPurchase := new(MockPurchaseManager) @@ -417,7 +420,8 @@ func TestHandler_approvePurchase_GCPOrphanRejects409(t *testing.T) { mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockAuth := new(MockAuthService) - mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: "admin@example.com", Role: "admin"}, nil) + mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: "admin@example.com"}, nil) + mockAuth.grantAdmin() mockPurchase := new(MockPurchaseManager) @@ -456,7 +460,8 @@ func TestHandler_approvePurchase_AWSOrphanFallsThrough(t *testing.T) { mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockAuth := new(MockAuthService) - mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: adminEmail, Role: "admin"}, nil) + mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: adminEmail}, nil) + mockAuth.grantAdmin() mockPurchase := new(MockPurchaseManager) // Guard does not fire; ApproveAndExecute is called normally. @@ -493,7 +498,8 @@ func TestHandler_approvePurchase_NonOrphanUnchanged(t *testing.T) { mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockAuth := new(MockAuthService) - mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: adminEmail, Role: "admin"}, nil) + mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: adminEmail}, nil) + mockAuth.grantAdmin() mockPurchase := new(MockPurchaseManager) mockPurchase.On("ApproveAndExecute", ctx, execID, adminEmail).Return(nil) @@ -716,7 +722,6 @@ func TestHandler_getPlannedPurchases(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } scheduledDate := time.Now().AddDate(0, 0, 7) @@ -751,6 +756,7 @@ func TestHandler_getPlannedPurchases(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() // The planned list must request paused executions alongside pending/notified // so a paused row stays VISIBLE. Assert the status set // explicitly rather than mock.Anything to lock the invariant. @@ -793,10 +799,10 @@ func TestHandler_getPlannedPurchases_ErrorGettingExecutions(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetPlannedExecutions", ctx, mock.Anything, mock.Anything). Return(nil, errors.New("database error")) @@ -824,7 +830,6 @@ func TestHandler_getPlannedPurchases_PausedStaysVisible(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } soon := time.Now().AddDate(0, 0, 3) @@ -857,6 +862,7 @@ func TestHandler_getPlannedPurchases_PausedStaysVisible(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetPlannedExecutions", ctx, []string{"pending", "notified", "paused"}, config.MaxListLimit). Return(executions, nil) @@ -903,7 +909,6 @@ func TestHandler_getPlannedPurchases_SoonestRowsNotTruncated(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } now := time.Now() @@ -929,6 +934,7 @@ func TestHandler_getPlannedPurchases_SoonestRowsNotTruncated(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() // Strict args lock the contract: planned statuses + MaxListLimit (so the // DB receives the same cap the handler intends, no off-by-one budget). mockStore.On("GetPlannedExecutions", ctx, @@ -969,11 +975,11 @@ func TestHandler_pausePlannedPurchase(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } paused := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "paused"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("TransitionExecutionStatus", ctx, "11111111-1111-1111-1111-111111111111", []string{"pending", "running"}, "paused").Return(paused, nil) handler := &Handler{config: mockStore, auth: mockAuth} @@ -997,10 +1003,10 @@ func TestHandler_pausePlannedPurchase_NotFound(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("TransitionExecutionStatus", ctx, "99999999-9999-9999-9999-999999999999", []string{"pending", "running"}, "paused").Return(nil, fmt.Errorf("execution not found: 99999999-9999-9999-9999-999999999999")) handler := &Handler{config: mockStore, auth: mockAuth} @@ -1023,11 +1029,11 @@ func TestHandler_resumePlannedPurchase(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } resumed := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "pending"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("TransitionExecutionStatus", ctx, "11111111-1111-1111-1111-111111111111", []string{"paused"}, "pending").Return(resumed, nil) handler := &Handler{config: mockStore, auth: mockAuth} @@ -1051,7 +1057,6 @@ func TestHandler_runPlannedPurchase(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } transitioned := &config.PurchaseExecution{ @@ -1060,6 +1065,7 @@ func TestHandler_runPlannedPurchase(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("TransitionExecutionStatus", ctx, "11111111-1111-1111-1111-111111111111", []string{"pending", "paused"}, "running").Return(transitioned, nil) handler := &Handler{config: mockStore, auth: mockAuth} @@ -1085,11 +1091,11 @@ func TestHandler_deletePlannedPurchase(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } cancelled := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "cancelled"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("TransitionExecutionStatus", ctx, "11111111-1111-1111-1111-111111111111", []string{"pending", "paused"}, "cancelled").Return(cancelled, nil) handler := &Handler{config: mockStore, auth: mockAuth} @@ -1117,7 +1123,6 @@ func TestHandler_deletePlannedPurchase_DisablesPlan(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } planID := "22222222-2222-2222-2222-222222222222" @@ -1135,6 +1140,7 @@ func TestHandler_deletePlannedPurchase_DisablesPlan(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "paused"}, "cancelled").Return(cancelled, nil) mockStore.On("GetPurchasePlan", ctx, planID).Return(plan, nil) // Assert that UpdatePurchasePlan is called with enabled=false. @@ -1168,7 +1174,6 @@ func TestHandler_deletePlannedPurchase_AlreadyDisabledPlan(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } planID := "33333333-3333-3333-3333-333333333333" @@ -1187,6 +1192,7 @@ func TestHandler_deletePlannedPurchase_AlreadyDisabledPlan(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "paused"}, "cancelled").Return(cancelled, nil) mockStore.On("GetPurchasePlan", ctx, planID).Return(plan, nil) @@ -1215,7 +1221,6 @@ func TestHandler_deletePlannedPurchase_ConflictRetryDisablesPlan(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } planID := "55555555-5555-5555-5555-555555555555" @@ -1238,6 +1243,7 @@ func TestHandler_deletePlannedPurchase_ConflictRetryDisablesPlan(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "paused"}, "cancelled").Return(nil, conflictErr) mockStore.On("GetExecutionByID", ctx, execID).Return(existingExec, nil) mockStore.On("GetPurchasePlan", ctx, planID).Return(plan, nil) @@ -1268,7 +1274,6 @@ func TestHandler_deletePlannedPurchase_ConflictRetryAlreadyDisabled(t *testing.T adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } planID := "77777777-7777-7777-7777-777777777777" @@ -1289,6 +1294,7 @@ func TestHandler_deletePlannedPurchase_ConflictRetryAlreadyDisabled(t *testing.T } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "paused"}, "cancelled").Return(nil, conflictErr) mockStore.On("GetExecutionByID", ctx, execID).Return(existingExec, nil) mockStore.On("GetPurchasePlan", ctx, planID).Return(plan, nil) @@ -1313,10 +1319,10 @@ func TestHandler_pausePlannedPurchase_NilExecution(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("TransitionExecutionStatus", ctx, "99999999-9999-9999-9999-999999999999", []string{"pending", "running"}, "paused").Return(nil, fmt.Errorf("execution not found: 99999999-9999-9999-9999-999999999999")) handler := &Handler{config: mockStore, auth: mockAuth} @@ -1348,10 +1354,10 @@ func TestHandler_pausePlannedPurchase_IneligibleStatus(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() // Store returns ErrExecutionNotInExpectedStatus when the row is 'completed' // and cannot be transitioned to 'paused'. mockStore.On("TransitionExecutionStatus", ctx, "11111111-1111-1111-1111-111111111111", []string{"pending", "running"}, "paused"). @@ -1382,10 +1388,10 @@ func TestHandler_resumePlannedPurchase_NilExecution(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("TransitionExecutionStatus", ctx, "99999999-9999-9999-9999-999999999999", []string{"paused"}, "pending").Return(nil, fmt.Errorf("execution not found: 99999999-9999-9999-9999-999999999999")) handler := &Handler{config: mockStore, auth: mockAuth} @@ -1408,10 +1414,10 @@ func TestHandler_runPlannedPurchase_NilExecution(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("TransitionExecutionStatus", ctx, "99999999-9999-9999-9999-999999999999", []string{"pending", "paused"}, "running").Return(nil, fmt.Errorf("execution not found: 99999999-9999-9999-9999-999999999999")) handler := &Handler{config: mockStore, auth: mockAuth} @@ -1434,10 +1440,10 @@ func TestHandler_deletePlannedPurchase_NilExecution(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("TransitionExecutionStatus", ctx, "99999999-9999-9999-9999-999999999999", []string{"pending", "paused"}, "cancelled").Return(nil, fmt.Errorf("execution not found: 99999999-9999-9999-9999-999999999999")) handler := &Handler{config: mockStore, auth: mockAuth} @@ -1460,12 +1466,12 @@ func TestHandler_getPlannedPurchases_ErrorGettingPlans(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } executions := []config.PurchaseExecution{{ExecutionID: "11111111-1111-1111-1111-111111111111", PlanID: "11111111-1111-1111-1111-111111111111"}} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetPlannedExecutions", ctx, mock.Anything, mock.Anything).Return(executions, nil) mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return(nil, errors.New("database error")) @@ -1492,7 +1498,6 @@ func TestHandler_getPurchaseDetails_Success(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } scheduledDate := time.Now().AddDate(0, 0, 7) @@ -1512,6 +1517,7 @@ func TestHandler_getPurchaseDetails_Success(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetExecutionByID", ctx, "11111111-1111-1111-1111-111111111111").Return(execution, nil) mockStore.On("GetPurchasePlan", ctx, "22222222-2222-2222-2222-222222222222").Return(plan, nil) @@ -1560,10 +1566,10 @@ func TestHandler_getPurchaseDetails_NotFound(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetExecutionByID", ctx, "99999999-9999-9999-9999-999999999999").Return(nil, errors.New("not found")) handler := &Handler{config: mockStore, auth: mockAuth} @@ -1587,10 +1593,10 @@ func TestHandler_getPurchaseDetails_NilExecution(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetExecutionByID", ctx, "99999999-9999-9999-9999-999999999999").Return(nil, nil) handler := &Handler{config: mockStore, auth: mockAuth} @@ -1614,7 +1620,6 @@ func TestHandler_getPurchaseDetails_WithTimestamps(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } scheduledDate := time.Now().AddDate(0, 0, 7) @@ -1632,6 +1637,7 @@ func TestHandler_getPurchaseDetails_WithTimestamps(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetExecutionByID", ctx, "11111111-1111-1111-1111-111111111111").Return(execution, nil) mockStore.On("GetPurchasePlan", ctx, "22222222-2222-2222-2222-222222222222").Return(nil, errors.New("not found")) @@ -1662,10 +1668,10 @@ func TestHandler_executePurchase_Success(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("SavePurchaseExecution", ctx, mock.AnythingOfType("*config.PurchaseExecution")).Return(nil) // executePurchase reads GlobalConfig to look up the per-provider // grace period. Return an empty-but-valid config so the grace @@ -1709,10 +1715,10 @@ func TestHandler_executePurchase_InvalidBody(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -1735,10 +1741,10 @@ func TestHandler_executePurchase_EmptyRecommendations(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -1761,10 +1767,10 @@ func TestHandler_executePurchase_NegativeUpfrontCost(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -1787,10 +1793,10 @@ func TestHandler_executePurchase_NegativeSavings(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -1813,10 +1819,10 @@ func TestHandler_executePurchase_TooManyRecommendations(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -1850,10 +1856,10 @@ func TestHandler_executePurchase_ExceedsMaxAmount(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -1877,10 +1883,10 @@ func TestHandler_executePurchase_SaveError(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("SavePurchaseExecution", ctx, mock.AnythingOfType("*config.PurchaseExecution")).Return(errors.New("database error")) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) mockStore.On("GetPendingExecutions", ctx).Return([]config.PurchaseExecution{}, nil) @@ -1912,7 +1918,7 @@ func TestHandler_pausePlannedPurchase_OutOfScope(t *testing.T) { planID := "88888888-8888-8888-8888-888888888888" mockAuth.On("ValidateSession", ctx, "viewer-token").Return(&Session{ - UserID: "viewer-1", Role: "user", + UserID: "viewer-1", }, nil) mockAuth.On("HasPermissionAPI", ctx, "viewer-1", "update", "purchases").Return(true, nil) mockAuth.On("GetAllowedAccountsAPI", ctx, "viewer-1").Return([]string{"Production"}, nil) @@ -1970,7 +1976,10 @@ func buildSessionCancelHandler(exec *config.PurchaseExecution, session *Session, mockAuth := new(MockAuthService) mockAuth.On("ValidateSession", mock.Anything, "sess-tok").Return(session, nil) - if session != nil && session.Role != "admin" { + // Authorization is permission-based for every caller now (issue #907): even + // an Administrators-group member resolves cancel-any/cancel-own through + // HasPermissionAPI, so register the permission mocks unconditionally. + if session != nil { mockAuth.On("HasPermissionAPI", mock.Anything, session.UserID, "cancel-any", "purchases").Return(hasAny, nil).Maybe() mockAuth.On("HasPermissionAPI", mock.Anything, session.UserID, "cancel-own", "purchases").Return(hasOwn, nil).Maybe() } @@ -2038,8 +2047,11 @@ func TestHandler_cancelPurchase_Session_Admin_AllowsAny(t *testing.T) { Status: "pending", CreatedByUserID: &creator, } - session := &Session{UserID: cancelCallerID, Role: "admin", Email: "admin@example.com"} - runSessionCancelAllowed(t, exec, session, false, false) + session := &Session{UserID: cancelCallerID, Email: "admin@example.com"} + // Admin == Administrators-group member, modelled as a cancel-any holder + // (issue #907 removed the role short-circuit); the row belongs to another + // user, so cancel-any is what authorises the action. + runSessionCancelAllowed(t, exec, session, true, false) } func TestHandler_cancelPurchase_Session_CancelAny_AllowsAny(t *testing.T) { @@ -2051,7 +2063,7 @@ func TestHandler_cancelPurchase_Session_CancelAny_AllowsAny(t *testing.T) { Status: "pending", CreatedByUserID: &creator, } - session := &Session{UserID: cancelCallerID, Role: "user", Email: "ops@example.com"} + session := &Session{UserID: cancelCallerID, Email: "ops@example.com"} runSessionCancelAllowed(t, exec, session, true, false) } @@ -2062,7 +2074,7 @@ func TestHandler_cancelPurchase_Session_CancelOwn_AllowsCreator(t *testing.T) { Status: "notified", CreatedByUserID: &creator, } - session := &Session{UserID: cancelCallerID, Role: "user", Email: "u1@example.com"} + session := &Session{UserID: cancelCallerID, Email: "u1@example.com"} runSessionCancelAllowed(t, exec, session, false, true) } @@ -2073,7 +2085,7 @@ func TestHandler_cancelPurchase_Session_CancelOwn_RejectsNonCreator(t *testing.T Status: "pending", CreatedByUserID: &creator, } - session := &Session{UserID: cancelCallerID, Role: "user", Email: "u1@example.com"} + session := &Session{UserID: cancelCallerID, Email: "u1@example.com"} handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, false, true) @@ -2092,7 +2104,7 @@ func TestHandler_cancelPurchase_Session_NoVerb_Rejects(t *testing.T) { Status: "pending", CreatedByUserID: &creator, } - session := &Session{UserID: cancelCallerID, Role: "user", Email: "u1@example.com"} + session := &Session{UserID: cancelCallerID, Email: "u1@example.com"} handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, false, false) @@ -2111,7 +2123,7 @@ func TestHandler_cancelPurchase_Session_RejectsTerminalStatus(t *testing.T) { Status: "completed", // already done — cannot transition CreatedByUserID: &creator, } - session := &Session{UserID: cancelCallerID, Role: "admin"} + session := &Session{UserID: cancelCallerID} handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, false, false) @@ -2140,7 +2152,7 @@ func TestHandler_cancelPurchase_Session_RejectsEachNonCancelableStatus(t *testin Status: status, CreatedByUserID: &creator, } - session := &Session{UserID: cancelCallerID, Role: "admin"} + session := &Session{UserID: cancelCallerID} handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, false, false) @@ -2169,8 +2181,10 @@ func TestHandler_cancelPurchase_Session_AllowsEachCancelableStatus(t *testing.T) Status: status, CreatedByUserID: &creator, } - session := &Session{UserID: cancelCallerID, Role: "admin", Email: "admin@example.com"} - runSessionCancelAllowed(t, exec, session, false, false) + session := &Session{UserID: cancelCallerID, Email: "admin@example.com"} + // Caller owns the row (creator == cancelCallerID); cancel-own + // authorises it (issue #907 group-only authz). + runSessionCancelAllowed(t, exec, session, false, true) }) } } @@ -2188,9 +2202,10 @@ func TestHandler_cancelPurchase_Session_RaceWithApprove(t *testing.T) { Status: "pending", // status at fetch time CreatedByUserID: &creator, } - session := &Session{UserID: cancelCallerID, Role: "admin", Email: "admin@example.com"} + session := &Session{UserID: cancelCallerID, Email: "admin@example.com"} - handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, false, false) + // Caller owns the row; cancel-own authorises it (issue #907). + handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, false, true) // Simulate concurrent approve winning between IsCancelable check and // the conditional UPDATE inside the tx. mockConfig.On("CancelExecutionAtomic", mock.Anything, mock.Anything, cancelExecID, mock.Anything). @@ -2219,7 +2234,7 @@ func TestHandler_cancelPurchase_Session_LegacyNullCreator_NonAdminRejected(t *te Status: "pending", CreatedByUserID: nil, } - session := &Session{UserID: cancelCallerID, Role: "user", Email: "u1@example.com"} + session := &Session{UserID: cancelCallerID, Email: "u1@example.com"} handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, false, true) @@ -2278,9 +2293,12 @@ func TestHandler_cancelPurchase_DeepLink_AdminBypassesContactEmailGate(t *testin {ID: "r-ambient", CloudAccountID: nil}, }, } - session := &Session{UserID: cancelCallerID, Role: "admin", Email: "admin@example.com"} + session := &Session{UserID: cancelCallerID, Email: "admin@example.com"} - handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, false, false) + // "Admin" is now an Administrators-group member, i.e. a holder of the + // cancel-any permission (issue #907). The contact-email-gate bypass under + // test is independent of how that authority is derived. + handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, true, false) // Capture cancelledBy to verify the audit-stamp is passed to the // atomic UPDATE. @@ -2328,7 +2346,7 @@ func TestHandler_cancelPurchase_DeepLink_CancelOwnBypassesContactEmailGate(t *te {ID: "r-ambient", CloudAccountID: nil}, }, } - session := &Session{UserID: cancelCallerID, Role: "user", Email: "u1@example.com"} + session := &Session{UserID: cancelCallerID, Email: "u1@example.com"} handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, false /*hasAny*/, true /*hasOwn*/) // CancelExecutionAtomic is called by the session-authed branch. @@ -2385,7 +2403,7 @@ func TestHandler_cancelPurchase_DeepLink_TransientAuthErrorPropagates(t *testing Status: "notified", CreatedByUserID: &creator, } - session := &Session{UserID: cancelCallerID, Role: "user", Email: "u1@example.com"} + session := &Session{UserID: cancelCallerID, Email: "u1@example.com"} mockConfig := new(MockConfigStore) mockConfig.On("GetExecutionByID", mock.Anything, exec.ExecutionID).Return(exec, nil) @@ -2430,7 +2448,7 @@ func TestHandler_cancelPurchase_DeepLink_NonPrivilegedSessionStillHitsContactGat {ID: "r-ambient", CloudAccountID: nil}, }, } - session := &Session{UserID: cancelCallerID, Role: "user", Email: "u1@example.com"} + session := &Session{UserID: cancelCallerID, Email: "u1@example.com"} handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, false /*hasAny*/, false /*hasOwn*/) // Token branch fetches the global config to populate the Cc list. @@ -2477,7 +2495,8 @@ func buildSessionRetryHandler(failed *config.PurchaseExecution, session *Session mockAuth := new(MockAuthService) mockAuth.On("ValidateSession", mock.Anything, "sess-tok").Return(session, nil) - if session != nil && session.Role != "admin" { + // Permission-based for every caller (issue #907): register unconditionally. + if session != nil { mockAuth.On("HasPermissionAPI", mock.Anything, session.UserID, "retry-any", "purchases").Return(hasAny, nil).Maybe() mockAuth.On("HasPermissionAPI", mock.Anything, session.UserID, "retry-own", "purchases").Return(hasOwn, nil).Maybe() } @@ -2544,8 +2563,10 @@ func TestHandler_retryPurchase_Admin_AllowsAny(t *testing.T) { CreatedByUserID: &creator, Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1}}, } - session := &Session{UserID: retryCallerID, Role: "admin", Email: "admin@example.com"} - newExec, updated := runSessionRetryAllowed(t, failed, session, false, false, sessionRetryReq()) + session := &Session{UserID: retryCallerID, Email: "admin@example.com"} + // Admin (Administrators-group member) modelled as a retry-any holder; the + // row belongs to another user (issue #907 group-only authz). + newExec, updated := runSessionRetryAllowed(t, failed, session, true, false, sessionRetryReq()) assert.Equal(t, "pending", newExec.Status) assert.Equal(t, 1, newExec.RetryAttemptN, "fresh first retry → n=1") require.NotNil(t, updated.RetryExecutionID, "original must carry pointer to successor") @@ -2562,7 +2583,7 @@ func TestHandler_retryPurchase_RetryAny_AllowsAny(t *testing.T) { CreatedByUserID: &creator, Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1}}, } - session := &Session{UserID: retryCallerID, Role: "user", Email: "ops@example.com"} + session := &Session{UserID: retryCallerID, Email: "ops@example.com"} runSessionRetryAllowed(t, failed, session, true, false, sessionRetryReq()) } @@ -2576,7 +2597,7 @@ func TestHandler_retryPurchase_RetryOwn_AllowsCreator(t *testing.T) { RetryAttemptN: 2, // already retried twice Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1}}, } - session := &Session{UserID: retryCallerID, Role: "user", Email: "u1@example.com"} + session := &Session{UserID: retryCallerID, Email: "u1@example.com"} newExec, updated := runSessionRetryAllowed(t, failed, session, false, true, sessionRetryReq()) assert.Equal(t, 3, newExec.RetryAttemptN, "n=2 predecessor → n=3 successor") require.NotNil(t, updated.RetryExecutionID) @@ -2590,7 +2611,7 @@ func TestHandler_retryPurchase_RetryOwn_RejectsNonCreator(t *testing.T) { Status: "failed", CreatedByUserID: &creator, } - session := &Session{UserID: retryCallerID, Role: "user", Email: "u1@example.com"} + session := &Session{UserID: retryCallerID, Email: "u1@example.com"} handler, mockConfig, mockAuth := buildSessionRetryHandler(failed, session, false, true) _, err := handler.retryPurchase(context.Background(), sessionRetryReq(), retryExecID) @@ -2608,7 +2629,7 @@ func TestHandler_retryPurchase_NoVerb_Rejects(t *testing.T) { Status: "failed", CreatedByUserID: &creator, } - session := &Session{UserID: retryCallerID, Role: "user", Email: "u1@example.com"} + session := &Session{UserID: retryCallerID, Email: "u1@example.com"} handler, mockConfig, mockAuth := buildSessionRetryHandler(failed, session, false, false) _, err := handler.retryPurchase(context.Background(), sessionRetryReq(), retryExecID) @@ -2626,7 +2647,7 @@ func TestHandler_retryPurchase_RejectsNonFailedStatus(t *testing.T) { Status: "completed", // already done — no retry from here CreatedByUserID: &creator, } - session := &Session{UserID: retryCallerID, Role: "admin"} + session := &Session{UserID: retryCallerID} handler, mockConfig, _ := buildSessionRetryHandler(failed, session, false, false) _, err := handler.retryPurchase(context.Background(), sessionRetryReq(), retryExecID) require.Error(t, err) @@ -2641,7 +2662,7 @@ func TestHandler_retryPurchase_LegacyNullCreator_NonAdminRejected(t *testing.T) Status: "failed", CreatedByUserID: nil, // pre-migration row } - session := &Session{UserID: retryCallerID, Role: "user", Email: "u1@example.com"} + session := &Session{UserID: retryCallerID, Email: "u1@example.com"} handler, mockConfig, mockAuth := buildSessionRetryHandler(failed, session, false, true) _, err := handler.retryPurchase(context.Background(), sessionRetryReq(), retryExecID) require.Error(t, err) @@ -2658,8 +2679,9 @@ func TestHandler_retryPurchase_PersistentFailure_BlocksWithOpsHint(t *testing.T) Error: "FROM_EMAIL not configured for this deployment", CreatedByUserID: &creator, } - session := &Session{UserID: retryCallerID, Role: "admin"} - handler, mockConfig, _ := buildSessionRetryHandler(failed, session, false, false) + session := &Session{UserID: retryCallerID} + // Caller owns the row; retry-own authorises it (issue #907). + handler, mockConfig, _ := buildSessionRetryHandler(failed, session, false, true) _, err := handler.retryPurchase(context.Background(), sessionRetryReq(), retryExecID) require.Error(t, err) assert.Contains(t, err.Error(), "operator-fixable") @@ -2685,8 +2707,9 @@ func TestHandler_retryPurchase_PersistentFailure_NoMatch_AllowsRetry(t *testing. CreatedByUserID: &creator, Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1}}, } - session := &Session{UserID: retryCallerID, Role: "admin"} - runSessionRetryAllowed(t, failed, session, false, false, sessionRetryReq()) + session := &Session{UserID: retryCallerID} + // Caller owns the row; retry-own authorises it (issue #907). + runSessionRetryAllowed(t, failed, session, false, true, sessionRetryReq()) } func TestHandler_retryPurchase_Threshold_BlocksAtFive_NoForce(t *testing.T) { @@ -2698,8 +2721,9 @@ func TestHandler_retryPurchase_Threshold_BlocksAtFive_NoForce(t *testing.T) { CreatedByUserID: &creator, Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1}}, } - session := &Session{UserID: retryCallerID, Role: "admin"} - handler, mockConfig, _ := buildSessionRetryHandler(failed, session, false, false) + session := &Session{UserID: retryCallerID} + // Caller owns the row; retry-own authorises it (issue #907). + handler, mockConfig, _ := buildSessionRetryHandler(failed, session, false, true) _, err := handler.retryPurchase(context.Background(), sessionRetryReq(), retryExecID) require.Error(t, err) assert.Contains(t, err.Error(), "force=true") @@ -2720,8 +2744,9 @@ func TestHandler_retryPurchase_Threshold_AllowsWithForce(t *testing.T) { CreatedByUserID: &creator, Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1}}, } - session := &Session{UserID: retryCallerID, Role: "admin"} - newExec, _ := runSessionRetryAllowed(t, failed, session, false, false, sessionRetryReqWithForce()) + session := &Session{UserID: retryCallerID} + // Caller owns the row; retry-own authorises it (issue #907). + newExec, _ := runSessionRetryAllowed(t, failed, session, false, true, sessionRetryReqWithForce()) assert.Equal(t, 6, newExec.RetryAttemptN, "force=true past threshold still increments the chain count") } @@ -2734,8 +2759,9 @@ func TestHandler_retryPurchase_JustUnderThreshold_AllowsNoForce(t *testing.T) { CreatedByUserID: &creator, Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1}}, } - session := &Session{UserID: retryCallerID, Role: "admin"} - newExec, _ := runSessionRetryAllowed(t, failed, session, false, false, sessionRetryReq()) + session := &Session{UserID: retryCallerID} + // Caller owns the row; retry-own authorises it (issue #907). + newExec, _ := runSessionRetryAllowed(t, failed, session, false, true, sessionRetryReq()) assert.Equal(t, 5, newExec.RetryAttemptN) } @@ -2752,8 +2778,9 @@ func TestHandler_retryPurchase_AlreadyRetried_Rejects(t *testing.T) { RetryExecutionID: &successor, Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1}}, } - session := &Session{UserID: retryCallerID, Role: "admin"} - handler, mockConfig, _ := buildSessionRetryHandler(failed, session, false, false) + session := &Session{UserID: retryCallerID} + // Caller owns the row; retry-own authorises it (issue #907). + handler, mockConfig, _ := buildSessionRetryHandler(failed, session, false, true) _, err := handler.retryPurchase(context.Background(), sessionRetryReq(), retryExecID) require.Error(t, err) assert.Contains(t, err.Error(), "already retried") @@ -2789,8 +2816,9 @@ func TestHandler_retryPurchase_PreservesPlanMetadata(t *testing.T) { CreatedByUserID: &creator, Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1}}, } - session := &Session{UserID: retryCallerID, Role: "admin"} - newExec, _ := runSessionRetryAllowed(t, failed, session, false, false, sessionRetryReq()) + session := &Session{UserID: retryCallerID} + // Caller owns the row; retry-own authorises it (issue #907). + newExec, _ := runSessionRetryAllowed(t, failed, session, false, true, sessionRetryReq()) assert.Equal(t, "plan-abc", newExec.PlanID, "successor must inherit predecessor PlanID") assert.Equal(t, 3, newExec.StepNumber, "successor must inherit predecessor StepNumber") } @@ -2813,7 +2841,7 @@ func TestHandler_retryPurchase_AlreadyRetried_RBACBeforeLeak(t *testing.T) { // Caller is a non-admin holding NEITHER retry-any nor retry-own — // must hit the 403 from authorizeSessionRetry, NOT the 409 with // successor exposure. - session := &Session{UserID: retryCallerID, Role: "user"} + session := &Session{UserID: retryCallerID} handler, _, _ := buildSessionRetryHandler(failed, session, false, false) _, err := handler.retryPurchase(context.Background(), sessionRetryReq(), retryExecID) require.Error(t, err) @@ -2845,8 +2873,9 @@ func TestPersistRetryExecution_ApprovalTokenNotUUID(t *testing.T) { CreatedByUserID: &creator, Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1}}, } - session := &Session{UserID: retryCallerID, Role: "admin", Email: "admin@example.com"} - newExec, _ := runSessionRetryAllowed(t, failed, session, false, false, sessionRetryReq()) + session := &Session{UserID: retryCallerID, Email: "admin@example.com"} + // Caller owns the row; retry-own authorises it (issue #907). + newExec, _ := runSessionRetryAllowed(t, failed, session, false, true, sessionRetryReq()) // 64 hex characters = 32 bytes = 256 bits. UUID format is 36 chars // (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx). This length check is the @@ -2873,10 +2902,11 @@ func TestPersistRetryExecution_ApprovalTokenExpiresAtSet(t *testing.T) { CreatedByUserID: &creator, Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1}}, } - session := &Session{UserID: retryCallerID, Role: "admin", Email: "admin@example.com"} + session := &Session{UserID: retryCallerID, Email: "admin@example.com"} before := time.Now() - newExec, _ := runSessionRetryAllowed(t, failed, session, false, false, sessionRetryReq()) + // Caller owns the row; retry-own authorises it (issue #907). + newExec, _ := runSessionRetryAllowed(t, failed, session, false, true, sessionRetryReq()) after := time.Now() require.NotNil(t, newExec.ApprovalTokenExpiresAt, diff --git a/internal/api/handler_registrations.go b/internal/api/handler_registrations.go index 38f99c7cf..a9e041216 100644 --- a/internal/api/handler_registrations.go +++ b/internal/api/handler_registrations.go @@ -453,7 +453,7 @@ func generateReferenceToken() (string, error) { // for a new-registration notification email. // // Rules: -// - Every CUDly user with role == "admin" is an authorised reviewer. +// - Every member of the Administrators group is an authorised reviewer. // - The first admin email is the To; remaining admins + the global // Settings → General notification email go on Cc. // - When no admin users are configured, falls through to the legacy @@ -492,9 +492,10 @@ func (h *Handler) resolveRegistrationRecipients(ctx context.Context) (to string, } // gatherAdminEmails returns the deduped, insertion-ordered list of emails -// for every user with role == "admin". Transport errors are logged and -// result in an empty return so registration notifications don't block on -// auth-store hiccups. +// for every authorised reviewer, i.e. every member of the Administrators group +// (the group-membership replacement for the former role == "admin" check; +// issue #907). Transport errors are logged and result in an empty return so +// registration notifications don't block on auth-store hiccups. func (h *Handler) gatherAdminEmails(ctx context.Context) []string { if h.auth == nil { return nil @@ -512,7 +513,7 @@ func (h *Handler) gatherAdminEmails(ctx context.Context) []string { seen := map[string]bool{} var out []string for _, u := range users { - if u == nil || u.Role != "admin" { + if u == nil || !isAdminGroupMember(u.Groups) { continue } addr := strings.TrimSpace(u.Email) @@ -529,6 +530,17 @@ func (h *Handler) gatherAdminEmails(ctx context.Context) []string { return out } +// isAdminGroupMember reports whether the given group IDs include the +// Administrators group, i.e. the user has full-access ({admin, *}) capability. +func isAdminGroupMember(groupIDs []string) bool { + for _, g := range groupIDs { + if g == auth.DefaultAdminGroupID { + return true + } + } + return false +} + // globalNotificationEmail loads the Settings → General notification email // (best-effort: returns "" on transport error or when unset). func (h *Handler) globalNotificationEmail(ctx context.Context) string { diff --git a/internal/api/handler_registrations_recipients_test.go b/internal/api/handler_registrations_recipients_test.go index 85e5ee280..35de95b54 100644 --- a/internal/api/handler_registrations_recipients_test.go +++ b/internal/api/handler_registrations_recipients_test.go @@ -15,11 +15,12 @@ func TestHandler_resolveRegistrationRecipients_AdminsBecomeApprovers(t *testing. globalNotify := "global@cudly.example" mockAuth := new(MockAuthService) + adminGroup := []string{"00000000-0000-5000-8000-000000000001"} mockAuth.On("ListUsersAPI", ctx).Return([]*auth.APIUser{ - {Email: "admin-a@example.com", Role: "admin"}, - {Email: "user@example.com", Role: "user"}, // non-admin → filtered out - {Email: "admin-b@example.com", Role: "admin"}, - {Email: "ADMIN-A@example.com", Role: "admin"}, // case dupe → dropped + {Email: "admin-a@example.com", Groups: adminGroup}, + {Email: "user@example.com", Groups: []string{"00000000-0000-5000-8000-000000000005"}}, // non-admin → filtered out + {Email: "admin-b@example.com", Groups: adminGroup}, + {Email: "ADMIN-A@example.com", Groups: adminGroup}, // case dupe → dropped }, nil) mockConfig := new(MockConfigStore) @@ -43,7 +44,7 @@ func TestHandler_resolveRegistrationRecipients_NoAdminsTriggersBroadcastFallback mockAuth := new(MockAuthService) mockAuth.On("ListUsersAPI", ctx).Return([]*auth.APIUser{ - {Email: "user@example.com", Role: "user"}, + {Email: "user@example.com"}, }, nil) mockConfig := new(MockConfigStore) @@ -70,7 +71,7 @@ func TestHandler_resolveRegistrationRecipients_OmitsGlobalWhenAlreadyAdmin(t *te mockAuth := new(MockAuthService) mockAuth.On("ListUsersAPI", ctx).Return([]*auth.APIUser{ - {Email: "admin-a@example.com", Role: "admin"}, + {Email: "admin-a@example.com", Groups: []string{"00000000-0000-5000-8000-000000000001"}}, }, nil) mockConfig := new(MockConfigStore) diff --git a/internal/api/handler_ri_exchange.go b/internal/api/handler_ri_exchange.go index cacb25abe..e7046cbc8 100644 --- a/internal/api/handler_ri_exchange.go +++ b/internal/api/handler_ri_exchange.go @@ -988,7 +988,9 @@ func (h *Handler) fetchAndAuthorizeRIExchange(ctx context.Context, session *Sess // Used by the three-mode dispatch in approveRIExchange to decide whether to route // to approveRIExchangeViaSession before fetching the record. func (h *Handler) sessionHasApproveRight(ctx context.Context, session *Session) error { - if session.Role == "admin" { + // Stateless admin API key: full access, no user row. Administrators-group + // users pass via the approve-any HasPermissionAPI check below. + if session.UserID == apiKeyAdminUserID { return nil } if h.auth == nil { @@ -1019,7 +1021,9 @@ func (h *Handler) sessionHasApproveRight(ctx context.Context, session *Session) // "approving a purchase action on a different resource type" per the issue spec, // which prefers reusing the existing verbs to keep the matrix small. func (h *Handler) authorizeSessionApproveRIExchange(ctx context.Context, session *Session, record *config.RIExchangeRecord) error { - if session.Role == "admin" { + // Stateless admin API key: full access, no user row. Administrators-group + // users pass via the approve-any HasPermissionAPI check below. + if session.UserID == apiKeyAdminUserID { return nil } if h.auth == nil { diff --git a/internal/api/handler_ri_exchange_test.go b/internal/api/handler_ri_exchange_test.go index 3aa0feae8..a7fe60244 100644 --- a/internal/api/handler_ri_exchange_test.go +++ b/internal/api/handler_ri_exchange_test.go @@ -364,8 +364,9 @@ func TestApproveRIExchange_SessionAdmin(t *testing.T) { id := "550e8400-e29b-41d4-a716-446655440010" creatorID := "creator-uuid" - adminSession := &Session{UserID: "admin-uuid", Email: "admin@example.com", Role: "admin"} + adminSession := &Session{UserID: "admin-uuid", Email: "admin@example.com"} mockAuth.On("ValidateSession", ctx, "admin-bearer").Return(adminSession, nil) + mockAuth.grantAdmin() // authorizeSessionApproveRIExchange: admin role short-circuits (no HasPermissionAPI call) @@ -419,7 +420,7 @@ func TestApproveRIExchange_SessionApproveOwn(t *testing.T) { mockAuth := new(MockAuthService) h := &Handler{config: mockStore, auth: mockAuth} - ownerSession := &Session{UserID: ownerID, Email: "owner@example.com", Role: "user"} + ownerSession := &Session{UserID: ownerID, Email: "owner@example.com"} mockAuth.On("ValidateSession", ctx, "owner-bearer").Return(ownerSession, nil) mockAuth.On("HasPermissionAPI", ctx, ownerID, auth.ActionApproveAny, auth.ResourcePurchases).Return(false, nil) mockAuth.On("HasPermissionAPI", ctx, ownerID, auth.ActionApproveOwn, auth.ResourcePurchases).Return(true, nil) @@ -457,7 +458,7 @@ func TestApproveRIExchange_SessionApproveOwn(t *testing.T) { mockAuth := new(MockAuthService) h := &Handler{config: mockStore, auth: mockAuth} - ownerSession := &Session{UserID: ownerID, Email: "owner@example.com", Role: "user"} + ownerSession := &Session{UserID: ownerID, Email: "owner@example.com"} mockAuth.On("ValidateSession", ctx, "owner-bearer").Return(ownerSession, nil) mockAuth.On("HasPermissionAPI", ctx, ownerID, auth.ActionApproveAny, auth.ResourcePurchases).Return(false, nil) mockAuth.On("HasPermissionAPI", ctx, ownerID, auth.ActionApproveOwn, auth.ResourcePurchases).Return(true, nil) @@ -815,7 +816,7 @@ func (m *mockAuthForExchange) Login(_ context.Context, _ LoginRequest) (*LoginRe } func (m *mockAuthForExchange) Logout(_ context.Context, _ string) error { return nil } func (m *mockAuthForExchange) ValidateSession(_ context.Context, _ string) (*Session, error) { - return &Session{UserID: "admin", Email: "admin@test.com", Role: "admin"}, nil + return &Session{UserID: "admin", Email: "admin@test.com"}, nil } func (m *mockAuthForExchange) ValidateCSRFToken(_ context.Context, _, _ string) error { return nil } func (m *mockAuthForExchange) SetupAdmin(_ context.Context, _ SetupAdminRequest) (*LoginResponse, error) { @@ -834,7 +835,7 @@ func (m *mockAuthForExchange) UpdateUserProfile(_ context.Context, _, _, _, _ st return nil } func (m *mockAuthForExchange) CreateUserAPI(_ context.Context, _ any) (any, error) { return nil, nil } -func (m *mockAuthForExchange) UpdateUserAPI(_ context.Context, _ string, _ any) (any, error) { +func (m *mockAuthForExchange) UpdateUserAPI(_ context.Context, _, _ string, _ any) (any, error) { return nil, nil } func (m *mockAuthForExchange) DeleteUser(_ context.Context, _ string) error { return nil } diff --git a/internal/api/handler_router_test.go b/internal/api/handler_router_test.go index ee39715c4..ffa655d58 100644 --- a/internal/api/handler_router_test.go +++ b/internal/api/handler_router_test.go @@ -15,8 +15,9 @@ func TestHandler_getGroup_Error(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Role: "admin"} + adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockAuth.On("GetGroupAPI", ctx, "11111111-1111-1111-1111-111111111111").Return(nil, assert.AnError) handler := &Handler{auth: mockAuth} @@ -34,8 +35,9 @@ func TestHandler_updateGroup_Error(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Role: "admin"} + adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockAuth.On("UpdateGroupAPI", ctx, "11111111-1111-1111-1111-111111111111", mock.Anything).Return(nil, assert.AnError) handler := &Handler{auth: mockAuth} @@ -54,8 +56,9 @@ func TestHandler_listGroups_Error(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Role: "admin"} + adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockAuth.On("ListGroupsAPI", ctx).Return(nil, assert.AnError) handler := &Handler{auth: mockAuth} @@ -73,8 +76,9 @@ func TestHandler_createGroup_Error(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Role: "admin"} + adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockAuth.On("CreateGroupAPI", ctx, mock.Anything).Return(nil, assert.AnError) handler := &Handler{auth: mockAuth} diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index 9c12174c7..ffdf6fdd0 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -367,7 +367,7 @@ func TestHandler_HandleRequest_PutConfig(t *testing.T) { mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com", Role: "admin"} + adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} mockStore.On("SaveGlobalConfig", mock.Anything, mock.AnythingOfType("*config.GlobalConfig")).Return(nil) mockStore.On("ListServiceConfigs", mock.Anything).Return([]config.ServiceConfig{}, nil) @@ -378,6 +378,7 @@ func TestHandler_HandleRequest_PutConfig(t *testing.T) { RecommendationsLookbackDays: config.DefaultRecommendationsLookbackDays, }, nil) mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) handler := &Handler{config: mockStore, auth: mockAuth, apiKey: "test-key"} @@ -434,8 +435,9 @@ func TestHandler_HandleRequest_PutServiceConfig(t *testing.T) { mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com", Role: "admin"} + adminSession := &Session{UserID: "admin-id", 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) mockStore.On("GetServiceConfig", mock.Anything, "aws", "rds").Return(nil, nil) @@ -502,8 +504,9 @@ func TestHandler_HandleRequest_RefreshRecommendations(t *testing.T) { mockAuth := new(MockAuthService) mockStore := new(MockConfigStore) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com", Role: "admin"} + adminSession := &Session{UserID: "admin-id", 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) mockScheduler.On("CollectRecommendations", mock.Anything).Return(&scheduler.CollectResult{Recommendations: 0, TotalSavings: 0}, nil) @@ -540,8 +543,9 @@ func TestHandler_HandleRequest_ListPlans(t *testing.T) { mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com", Role: "admin"} + adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + mockAuth.grantAdmin() plans := []config.PurchasePlan{{ID: "11111111-1111-1111-1111-111111111111"}} mockStore.On("ListPurchasePlans", mock.Anything, mock.Anything).Return(plans, nil) @@ -571,8 +575,9 @@ func TestHandler_HandleRequest_CreatePlan(t *testing.T) { mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com", Role: "admin"} + adminSession := &Session{UserID: "admin-id", 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) mockStore.On("CreatePurchasePlan", mock.Anything, mock.AnythingOfType("*config.PurchasePlan")).Return(nil) @@ -615,8 +620,9 @@ func TestHandler_HandleRequest_GetPlan(t *testing.T) { mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com", Role: "admin"} + adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + mockAuth.grantAdmin() plan := &config.PurchasePlan{ID: "12345678-1234-1234-1234-123456789abc"} mockStore.On("GetPurchasePlan", mock.Anything, "12345678-1234-1234-1234-123456789abc").Return(plan, nil) @@ -646,8 +652,9 @@ func TestHandler_HandleRequest_UpdatePlan(t *testing.T) { mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com", Role: "admin"} + adminSession := &Session{UserID: "admin-id", 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) existingPlan := &config.PurchasePlan{ @@ -687,8 +694,9 @@ func TestHandler_HandleRequest_DeletePlan(t *testing.T) { mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com", Role: "admin"} + adminSession := &Session{UserID: "admin-id", 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) mockStore.On("DeletePurchasePlan", mock.Anything, "12345678-1234-1234-1234-123456789abc").Return(nil) @@ -1010,8 +1018,9 @@ func TestHandler_HandleRequest_GetPlannedPurchases(t *testing.T) { mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com", Role: "admin"} + adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + mockAuth.grantAdmin() scheduledDate := time.Now().AddDate(0, 0, 7) executions := []config.PurchaseExecution{ @@ -1049,8 +1058,9 @@ func TestHandler_HandleRequest_PausePlannedPurchase(t *testing.T) { mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com", Role: "admin"} + adminSession := &Session{UserID: "admin-id", 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) paused := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "paused"} @@ -1083,8 +1093,9 @@ func TestHandler_HandleRequest_ResumePlannedPurchase(t *testing.T) { mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com", Role: "admin"} + adminSession := &Session{UserID: "admin-id", 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) resumed := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "pending"} @@ -1117,8 +1128,9 @@ func TestHandler_HandleRequest_RunPlannedPurchase(t *testing.T) { mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com", Role: "admin"} + adminSession := &Session{UserID: "admin-id", 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) transitioned := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "running"} @@ -1151,8 +1163,9 @@ func TestHandler_HandleRequest_DeletePlannedPurchase(t *testing.T) { mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com", Role: "admin"} + adminSession := &Session{UserID: "admin-id", 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) cancelled := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "cancelled"} @@ -1184,8 +1197,9 @@ func TestHandler_HandleRequest_CreatePlannedPurchases(t *testing.T) { mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com", Role: "admin"} + adminSession := &Session{UserID: "admin-id", 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) plan := &config.PurchasePlan{ @@ -1227,8 +1241,9 @@ func TestHandler_HandleRequest_GetPlan_Error(t *testing.T) { mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com", Role: "admin"} + adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetPurchasePlan", mock.Anything, "12345678-1234-1234-1234-123456789abc").Return(nil, assert.AnError) @@ -1259,8 +1274,9 @@ func TestHandler_HandleRequest_DeleteUser_SelfDeletion(t *testing.T) { // Use valid UUID format for the admin user ID adminUserID := "12345678-1234-1234-1234-123456789abc" - adminSession := &Session{UserID: adminUserID, Email: "admin@example.com", Role: "admin"} + 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) handler := &Handler{auth: mockAuth, apiKey: "test-key"} @@ -1296,8 +1312,9 @@ func TestHandler_HandleRequest_ListPlans_Error(t *testing.T) { mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com", Role: "admin"} + adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("ListPurchasePlans", mock.Anything, mock.Anything).Return(nil, assert.AnError) @@ -1326,8 +1343,9 @@ func TestHandler_HandleRequest_UpdateConfig_InvalidJSON(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com", Role: "admin"} + adminSession := &Session{UserID: "admin-id", 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) handler := &Handler{auth: mockAuth, apiKey: "test-key"} diff --git a/internal/api/handler_users.go b/internal/api/handler_users.go index 7ba24d335..36befda8a 100644 --- a/internal/api/handler_users.go +++ b/internal/api/handler_users.go @@ -48,12 +48,11 @@ func (h *Handler) createUser(ctx context.Context, req *events.LambdaFunctionURLR return nil, NewClientError(400, "invalid request body") } - // Validate role against allowlist - switch createReq.Role { - case auth.RoleAdmin, auth.RoleUser, auth.RoleReadOnly: - // valid - default: - return nil, NewClientError(400, "role must be one of: admin, user, readonly") + // Authorization is group-membership-only: a user must belong to at least + // one group (issue #907). The service layer re-validates as defence in + // depth and the DB enforces it via a CHECK constraint. + if len(createReq.Groups) == 0 { + return nil, NewClientError(400, "at least one group is required") } // Decode base64-encoded password @@ -80,11 +79,14 @@ func (h *Handler) createUser(ctx context.Context, req *events.LambdaFunctionURLR func mapAuthError(err error) error { switch { case errors.Is(err, auth.ErrInvalidEmail), - errors.Is(err, auth.ErrInvalidRole), + errors.Is(err, auth.ErrNoGroups), errors.Is(err, auth.ErrPasswordPolicy): return NewClientError(400, err.Error()) + case errors.Is(err, auth.ErrSelfEscalation): + return NewClientError(403, err.Error()) case errors.Is(err, auth.ErrEmailInUse), - errors.Is(err, auth.ErrAdminExists): + errors.Is(err, auth.ErrAdminExists), + errors.Is(err, auth.ErrLastAdmin): return NewClientError(409, err.Error()) } return err @@ -116,7 +118,8 @@ func (h *Handler) updateUser(ctx context.Context, req *events.LambdaFunctionURLR return nil, err } - if _, err := h.requirePermission(ctx, req, "update", "users"); err != nil { + session, err := h.requirePermission(ctx, req, "update", "users") + if err != nil { return nil, err } @@ -125,9 +128,12 @@ func (h *Handler) updateUser(ctx context.Context, req *events.LambdaFunctionURLR return nil, NewClientError(400, "invalid request body") } - user, err := h.auth.UpdateUserAPI(ctx, userID, updateReq) + // session.UserID is the trusted actor identity (from the validated + // session, never the request body); the service layer uses it to enforce + // the self-escalation guard (issue #907). + user, err := h.auth.UpdateUserAPI(ctx, session.UserID, userID, updateReq) if err != nil { - return nil, err + return nil, mapAuthError(err) } return user, nil @@ -151,7 +157,7 @@ func (h *Handler) deleteUser(ctx context.Context, req *events.LambdaFunctionURLR } if err := h.auth.DeleteUser(ctx, userID); err != nil { - return nil, err + return nil, mapAuthError(err) } return map[string]string{"status": "user deleted"}, nil diff --git a/internal/api/handler_users_test.go b/internal/api/handler_users_test.go index d926a8b70..b170735f6 100644 --- a/internal/api/handler_users_test.go +++ b/internal/api/handler_users_test.go @@ -19,7 +19,6 @@ func TestHandler_listUsers_Success(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } users := []interface{}{ @@ -28,6 +27,7 @@ func TestHandler_listUsers_Success(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockAuth.On("ListUsersAPI", ctx).Return(users, nil) handler := &Handler{auth: mockAuth} @@ -52,7 +52,6 @@ func TestHandler_listUsers_NoPermission(t *testing.T) { userSession := &Session{ UserID: "11111111-1111-1111-1111-111111111111", Email: "user@example.com", - Role: "user", } mockAuth.On("ValidateSession", ctx, "user-token").Return(userSession, nil) @@ -79,16 +78,16 @@ func TestHandler_createUser_Success(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } createdUser := &auth.APIUser{ - ID: "33333333-3333-3333-3333-333333333333", - Email: "newuser@example.com", - Role: "user", + ID: "33333333-3333-3333-3333-333333333333", + Email: "newuser@example.com", + Groups: []string{"00000000-0000-5000-8000-000000000005"}, } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockAuth.On("CreateUserAPI", ctx, mock.Anything).Return(createdUser, nil) handler := &Handler{auth: mockAuth} @@ -98,7 +97,7 @@ func TestHandler_createUser_Success(t *testing.T) { Headers: map[string]string{ "Authorization": "Bearer admin-token", }, - Body: `{"email": "newuser@example.com", "password": "` + password + `", "role": "user"}`, + Body: `{"email": "newuser@example.com", "password": "` + password + `", "groups": ["00000000-0000-5000-8000-000000000005"]}`, } result, err := handler.createUser(ctx, req) @@ -113,16 +112,16 @@ func TestHandler_getUser_Success(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } user := &User{ - ID: "11111111-1111-1111-1111-111111111111", - Email: "user@example.com", - Role: "user", + ID: "11111111-1111-1111-1111-111111111111", + Email: "user@example.com", + Groups: []string{"00000000-0000-5000-8000-000000000005"}, } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockAuth.On("GetUser", ctx, "11111111-1111-1111-1111-111111111111").Return(user, nil) handler := &Handler{auth: mockAuth} @@ -147,17 +146,17 @@ func TestHandler_updateUser_Success(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } updatedUser := map[string]interface{}{ - "id": "11111111-1111-1111-1111-111111111111", - "email": "user@example.com", - "role": "admin", + "id": "11111111-1111-1111-1111-111111111111", + "email": "user@example.com", + "groups": []string{"00000000-0000-5000-8000-000000000001"}, } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) - mockAuth.On("UpdateUserAPI", ctx, "11111111-1111-1111-1111-111111111111", mock.Anything).Return(updatedUser, nil) + mockAuth.grantAdmin() + mockAuth.On("UpdateUserAPI", ctx, adminSession.UserID, "11111111-1111-1111-1111-111111111111", mock.Anything).Return(updatedUser, nil) handler := &Handler{auth: mockAuth} @@ -165,7 +164,7 @@ func TestHandler_updateUser_Success(t *testing.T) { Headers: map[string]string{ "Authorization": "Bearer admin-token", }, - Body: `{"role": "admin"}`, + Body: `{"groups": ["00000000-0000-5000-8000-000000000001"]}`, } result, err := handler.updateUser(ctx, req, "11111111-1111-1111-1111-111111111111") @@ -180,10 +179,10 @@ func TestHandler_deleteUser_Success(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockAuth.On("DeleteUser", ctx, "22222222-2222-2222-2222-222222222222").Return(nil) handler := &Handler{auth: mockAuth} @@ -208,10 +207,10 @@ func TestHandler_deleteUser_SelfDeletion(t *testing.T) { adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", - Role: "admin", } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -232,8 +231,9 @@ func TestHandler_createUser_InvalidJSON(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Role: "admin"} + adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} @@ -252,8 +252,9 @@ func TestHandler_updateUser_InvalidJSON(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Role: "admin"} + adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() handler := &Handler{auth: mockAuth} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 0c43fe964..3d0dc851c 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" + "github.com/LeanerCloud/CUDly/internal/auth" "github.com/LeanerCloud/CUDly/pkg/logging" "github.com/aws/aws-lambda-go/events" ) @@ -225,13 +226,17 @@ func (h *Handler) requireAuth(ctx context.Context, req *events.LambdaFunctionURL return NewClientError(401, "authentication required") } -// requireAdmin checks if the current user has admin role. -// Accepts both admin API-key auth and Bearer token auth. +// 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 +// bypasses the per-user lookup) and a Bearer-token session whose group-derived +// 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{Role: "admin", UserID: "admin-api-key"}, nil + return &Session{UserID: apiKeyAdminUserID}, nil } if h.auth == nil { @@ -248,7 +253,14 @@ func (h *Handler) requireAdmin(ctx context.Context, req *events.LambdaFunctionUR return nil, NewClientError(401, "invalid session") } - if session.Role != "admin" { + // 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. + isAdmin, err := h.auth.HasPermissionAPI(ctx, session.UserID, auth.ActionAdmin, auth.ResourceAll) + if err != nil { + return nil, fmt.Errorf("admin permission check failed: %w", err) + } + if !isAdmin { return nil, NewClientError(403, "admin access required") } diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go index 4c1efec01..557204e13 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -182,7 +182,6 @@ func TestHandler_authenticate_BearerToken(t *testing.T) { session := &Session{ UserID: "11111111-1111-1111-1111-111111111111", Email: "user@example.com", - Role: "user", } mockAuth.On("ValidateSession", ctx, "valid-token").Return(session, nil) @@ -215,7 +214,6 @@ func TestHandler_authenticate_BearerTokenWithAPIKey(t *testing.T) { session := &Session{ UserID: "11111111-1111-1111-1111-111111111111", Email: "user@example.com", - Role: "user", } mockAuth.On("ValidateSession", ctx, "valid-token").Return(session, nil) diff --git a/internal/api/mocks_test.go b/internal/api/mocks_test.go index c759b08fe..18223b334 100644 --- a/internal/api/mocks_test.go +++ b/internal/api/mocks_test.go @@ -782,8 +782,8 @@ func (m *MockAuthService) CreateUserAPI(ctx context.Context, req interface{}) (i return args.Get(0), args.Error(1) } -func (m *MockAuthService) UpdateUserAPI(ctx context.Context, userID string, req interface{}) (interface{}, error) { - args := m.Called(ctx, userID, req) +func (m *MockAuthService) UpdateUserAPI(ctx context.Context, actorUserID, userID string, req interface{}) (interface{}, error) { + args := m.Called(ctx, actorUserID, userID, req) return args.Get(0), args.Error(1) } @@ -860,6 +860,24 @@ func (m *MockAuthService) HasPermissionAPI(ctx context.Context, userID, action, return args.Bool(0), args.Error(1) } +// grantAdmin makes every HasPermissionAPI check succeed, modelling an +// Administrators-group member. Authorization is group-membership-only after +// issue #907, so admin-gated handlers resolve "is admin" / specific permissions +// through HasPermissionAPI rather than a Session.Role short-circuit; tests that +// previously set Role:"admin" register this instead. Uses .Maybe() so handlers +// that don't reach a permission check don't fail the expectation, and matches +// any userID so a single call covers the test's admin session regardless of its +// UUID. +func (m *MockAuthService) grantAdmin() { + m.On("HasPermissionAPI", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(true, nil).Maybe() + // Administrators-group members carry the "*" wildcard, surfaced as + // unrestricted access (nil/empty). Handlers that scope by account call + // GetAllowedAccountsAPI after the permission check, so stub it too. + m.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything). + Return([]string(nil), nil).Maybe() +} + func (m *MockAuthService) GetAllowedAccountsAPI(ctx context.Context, userID string) ([]string, error) { args := m.Called(ctx, userID) if v := args.Get(0); v != nil { diff --git a/internal/api/router_660_permission_flips_test.go b/internal/api/router_660_permission_flips_test.go index a5033cb73..aad023e32 100644 --- a/internal/api/router_660_permission_flips_test.go +++ b/internal/api/router_660_permission_flips_test.go @@ -31,11 +31,11 @@ import ( // --- helpers --------------------------------------------------------------- func userSessionFixture(userID string) *Session { - return &Session{UserID: userID, Role: "user"} + return &Session{UserID: userID} } func adminSessionFixture() *Session { - return &Session{UserID: "admin-uid", Role: "admin"} + return &Session{UserID: "admin-uid"} } // authForUserWith returns a MockAuthService that: @@ -60,6 +60,7 @@ func authForAdmin(ctx context.Context, t *testing.T) *MockAuthService { t.Helper() m := new(MockAuthService) m.On("ValidateSession", ctx, "admin-token").Return(adminSessionFixture(), nil) + m.grantAdmin() t.Cleanup(func() { m.AssertExpectations(t) }) return m } diff --git a/internal/api/router_authuser_test.go b/internal/api/router_authuser_test.go index eb0858fac..7e5571a85 100644 --- a/internal/api/router_authuser_test.go +++ b/internal/api/router_authuser_test.go @@ -68,7 +68,7 @@ func TestRouterAuthUser_InvalidBearerToken_Rejects(t *testing.T) { func TestRouterAuthUser_ValidUserSession_Accepts(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - userSession := &Session{UserID: "11111111-1111-1111-1111-111111111111", Role: "user"} + 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) h := &Handler{auth: mockAuth} @@ -115,7 +115,7 @@ func TestRequireAuth_AdminAPIKey(t *testing.T) { func TestRequireAuth_UserSession(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - userSession := &Session{UserID: "uid", Role: "user"} + userSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "user-token").Return(userSession, nil) h := &Handler{auth: mockAuth} req := &events.LambdaFunctionURLRequest{ diff --git a/internal/api/router_handlers_test.go b/internal/api/router_handlers_test.go index 0383e30ab..068f21e62 100644 --- a/internal/api/router_handlers_test.go +++ b/internal/api/router_handlers_test.go @@ -29,8 +29,9 @@ func TestRouter_patchPlanHandler(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() plan := &config.PurchasePlan{ ID: "11111111-1111-1111-1111-111111111111", @@ -67,8 +68,9 @@ func TestRouter_executePurchaseHandler_NoAuth(t *testing.T) { func TestRouter_getPurchaseDetailsHandler_InvalidUUID(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() h := &Handler{auth: mockAuth} r := newTestRouter(h) @@ -120,8 +122,9 @@ func TestRouter_getRIExchangeConfigHandler(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) h := &Handler{auth: mockAuth, config: mockStore} @@ -139,8 +142,9 @@ func TestRouter_updateRIExchangeConfigHandler(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) mockStore.On("SaveGlobalConfig", ctx, mock.Anything).Return(nil) @@ -160,8 +164,9 @@ func TestRouter_getRIExchangeHistoryHandler(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetRIExchangeHistory", ctx, mock.Anything, 500).Return([]config.RIExchangeRecord{}, nil) h := &Handler{auth: mockAuth, config: mockStore} @@ -220,8 +225,9 @@ func TestRouter_rejectRIExchangeHandler_InvalidToken(t *testing.T) { func TestRouter_listAccountsHandler(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() store := new(MockConfigStore) h := &Handler{auth: mockAuth, config: store} @@ -238,8 +244,9 @@ func TestRouter_listAccountsHandler(t *testing.T) { func TestRouter_createAccountHandler(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() store := new(MockConfigStore) h := &Handler{auth: mockAuth, config: store} @@ -262,8 +269,9 @@ func TestRouter_discoverOrgAccountsHandler(t *testing.T) { // behaviour is exercised in handler_accounts_test.go. ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() h := &Handler{auth: mockAuth} r := newTestRouter(h) @@ -281,8 +289,9 @@ func TestRouter_discoverOrgAccountsHandler(t *testing.T) { func TestRouter_getAccountHandler(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() store := new(MockConfigStore) h := &Handler{auth: mockAuth, config: store} @@ -299,8 +308,9 @@ func TestRouter_getAccountHandler(t *testing.T) { func TestRouter_saveAccountCredentialsHandler_NoCredStore(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() store := new(MockConfigStore) h := &Handler{auth: mockAuth, config: store, credStore: nil} @@ -320,8 +330,9 @@ func TestRouter_saveAccountCredentialsHandler_NoCredStore(t *testing.T) { func TestRouter_testAccountCredentialsHandler(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() store := new(MockConfigStore) // GetCloudAccount returns an access_keys account (not ambient) → fall through to checkCredentialPresence @@ -339,8 +350,9 @@ func TestRouter_testAccountCredentialsHandler(t *testing.T) { func TestRouter_listAccountServiceOverridesHandler(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() store := new(MockConfigStore) h := &Handler{auth: mockAuth, config: store} @@ -357,8 +369,9 @@ func TestRouter_listAccountServiceOverridesHandler(t *testing.T) { func TestRouter_setPlanAccountsHandler(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() store := new(MockConfigStore) h := &Handler{auth: mockAuth, config: store} @@ -384,8 +397,9 @@ func TestHandler_getRIExchangeConfig_Success(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{ RIExchangeEnabled: true, RIExchangeMode: "manual", @@ -412,8 +426,9 @@ func TestHandler_getRIExchangeConfig_StoreError(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetGlobalConfig", ctx).Return(nil, errors.New("db error")) h := &Handler{auth: mockAuth, config: mockStore} @@ -428,8 +443,9 @@ func TestHandler_updateRIExchangeConfig_Success(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) mockStore.On("SaveGlobalConfig", ctx, mock.Anything).Return(nil) @@ -446,8 +462,9 @@ func TestHandler_updateRIExchangeConfig_Success(t *testing.T) { func TestHandler_updateRIExchangeConfig_InvalidBody(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() h := &Handler{auth: mockAuth} req := &events.LambdaFunctionURLRequest{ @@ -462,8 +479,9 @@ func TestHandler_updateRIExchangeConfig_InvalidBody(t *testing.T) { func TestHandler_updateRIExchangeConfig_ValidationError(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() h := &Handler{auth: mockAuth} req := &events.LambdaFunctionURLRequest{ @@ -555,8 +573,9 @@ func TestHandler_getRIExchangeHistory_Success(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() records := []config.RIExchangeRecord{ {ID: "aaaa", ApprovalToken: "should-be-redacted"}, @@ -579,8 +598,9 @@ func TestHandler_getRIExchangeHistory_StoreError(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() mockStore.On("GetRIExchangeHistory", ctx, mock.Anything, 500).Return(nil, errors.New("db error")) h := &Handler{auth: mockAuth, config: mockStore} @@ -672,8 +692,9 @@ func TestHandler_testAccountCredentials_NoAuth(t *testing.T) { func TestHandler_testAccountCredentials_NotFound(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() store := new(MockConfigStore) store.GetCloudAccountFn = func(_ context.Context, _ string) (*config.CloudAccount, error) { @@ -693,8 +714,9 @@ func TestHandler_testAccountCredentials_NotFound(t *testing.T) { func TestHandler_testAccountCredentials_AmbientCredResult(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() store := new(MockConfigStore) store.GetCloudAccountFn = func(_ context.Context, _ string) (*config.CloudAccount, error) { @@ -719,8 +741,9 @@ func TestHandler_testAccountCredentials_AmbientCredResult(t *testing.T) { func TestHandler_testAccountCredentials_CheckPresence(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() // access_keys account — falls through to checkCredentialPresence store := new(MockConfigStore) @@ -891,8 +914,9 @@ func TestHandler_getRIExchangeHistory_SinceTime(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid", Role: "admin"} + adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() // Use Any matcher for time.Time since it's computed at call time mockStore.On("GetRIExchangeHistory", ctx, mock.MatchedBy(func(t time.Time) bool { diff --git a/internal/api/types.go b/internal/api/types.go index 3cce623d2..867b4eecd 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -166,7 +166,7 @@ type AuthServiceInterface interface { UpdateUserProfile(ctx context.Context, userID string, email string, currentPassword string, newPassword string) error // User management - uses auth.API* types CreateUserAPI(ctx context.Context, req any) (any, error) - UpdateUserAPI(ctx context.Context, userID string, req any) (any, error) + UpdateUserAPI(ctx context.Context, actorUserID, userID string, req any) (any, error) DeleteUser(ctx context.Context, userID string) error ListUsersAPI(ctx context.Context) (any, error) ChangePasswordAPI(ctx context.Context, userID, currentPassword, newPassword string) error @@ -212,7 +212,6 @@ type LoginResponse struct { type UserInfo struct { ID string `json:"id"` Email string `json:"email"` - Role string `json:"role"` Groups []string `json:"groups,omitempty"` MFAEnabled bool `json:"mfa_enabled"` } @@ -234,31 +233,28 @@ type PasswordResetConfirm struct { type Session struct { UserID string `json:"user_id"` Email string `json:"email"` - Role string `json:"role"` } type User struct { ID string `json:"id"` Email string `json:"email"` - Role string `json:"role"` Groups []string `json:"groups,omitempty"` MFAEnabled bool `json:"mfa_enabled"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` } -// CreateUserRequest represents a request to create a new user +// CreateUserRequest represents a request to create a new user. Groups must be +// non-empty: authorization is group-membership-only (issue #907). type CreateUserRequest struct { Email string `json:"email"` Password string `json:"password"` - Role string `json:"role"` Groups []string `json:"groups,omitempty"` } // UpdateUserRequest represents a request to update a user type UpdateUserRequest struct { Email string `json:"email,omitempty"` - Role string `json:"role,omitempty"` Groups []string `json:"groups,omitempty"` } @@ -388,10 +384,10 @@ type PlansResponse struct { // CurrentUserResponse holds the current user response type CurrentUserResponse struct { - ID string `json:"id"` - Email string `json:"email"` - Role string `json:"role"` - MFAEnabled bool `json:"mfa_enabled"` + ID string `json:"id"` + Email string `json:"email"` + Groups []string `json:"groups,omitempty"` + MFAEnabled bool `json:"mfa_enabled"` } // AdminExistsResponse holds the admin exists check response diff --git a/internal/auth/errors.go b/internal/auth/errors.go index d96269ec6..4696cc2ec 100644 --- a/internal/auth/errors.go +++ b/internal/auth/errors.go @@ -15,10 +15,25 @@ import "errors" var ( ErrInvalidEmail = errors.New("invalid email format") ErrEmailInUse = errors.New("email already in use") - ErrInvalidRole = errors.New("invalid role") ErrAdminExists = errors.New("admin user already exists") ErrPasswordPolicy = errors.New("password does not meet policy") + // ErrNoGroups is returned when a create/update would leave a user with + // zero group memberships. Authorization derives entirely from groups, so + // a zero-group user can do nothing; the API rejects it as a 400 rather + // than silently creating an inert account (issue #907). + ErrNoGroups = errors.New("user must belong to at least one group") + + // ErrLastAdmin is returned when an update or delete would remove the last + // remaining member of the Administrators group, which would lock everyone + // out of admin-gated functionality. Mapped to 409 (issue #907). + ErrLastAdmin = errors.New("cannot remove the last administrator") + + // ErrSelfEscalation is returned when a user attempts to grant themselves + // a group they are not already a member of without holding the manage-users + // permission. Mapped to 403 (issue #907). + ErrSelfEscalation = errors.New("cannot escalate your own group membership") + // MFA login-gate sentinels — used by the login API handler to map // to machine-readable response codes (mfa_required / // invalid_mfa_code) so the frontend can branch on the error class diff --git a/internal/auth/interfaces.go b/internal/auth/interfaces.go index 756fd0942..eff3a8d57 100644 --- a/internal/auth/interfaces.go +++ b/internal/auth/interfaces.go @@ -30,6 +30,10 @@ type StoreInterface interface { UpdateGroup(ctx context.Context, group *Group) error DeleteGroup(ctx context.Context, groupID string) error ListGroups(ctx context.Context) ([]Group, error) + // CountGroupMembers returns the number of users whose group_ids array + // contains groupID. Used to enforce the last-administrator protection + // (issue #907) and any future per-group membership invariants. + CountGroupMembers(ctx context.Context, groupID string) (int, error) // Session operations CreateSession(ctx context.Context, session *Session) error diff --git a/internal/auth/service.go b/internal/auth/service.go index 45ad50471..bdd556372 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -247,7 +247,6 @@ func (s *Service) completeSuccessfulLogin(ctx context.Context, user *User) (*Log User: &UserInfo{ ID: user.ID, Email: user.Email, - Role: user.Role, Groups: user.GroupIDs, MFAEnabled: user.MFAEnabled, }, diff --git a/internal/auth/service_api.go b/internal/auth/service_api.go index b61a1ad30..24a94b01b 100644 --- a/internal/auth/service_api.go +++ b/internal/auth/service_api.go @@ -19,7 +19,6 @@ import ( type APIUser struct { ID string `json:"id"` Email string `json:"email"` - Role string `json:"role"` Groups []string `json:"groups"` MFAEnabled bool `json:"mfa_enabled"` CreatedAt string `json:"created_at,omitempty"` @@ -57,11 +56,11 @@ type APIPermissionConstraint struct { MaxAmount float64 `json:"max_amount,omitempty"` } -// APICreateUserRequest is the request type for creating users via API +// APICreateUserRequest is the request type for creating users via API. +// Groups must be non-empty: authorization is group-membership-only (issue #907). type APICreateUserRequest struct { Email string `json:"email"` Password string `json:"password"` - Role string `json:"role"` Groups []string `json:"groups,omitempty"` } @@ -81,10 +80,14 @@ type APICreateUserResponse struct { InviteEmailError string `json:"invite_email_error,omitempty"` } -// APIUpdateUserRequest is the request type for updating users via API +// APIUpdateUserRequest is the request type for updating users via API. +// +// Groups is decoded from JSON, so the handler cannot use a nil slice to mean +// "not sent". A non-empty Groups replaces the user's membership; an empty/nil +// Groups means "leave membership unchanged" (callers that intend to change +// groups always send at least one, since zero-group users are forbidden). type APIUpdateUserRequest struct { Email string `json:"email,omitempty"` - Role string `json:"role,omitempty"` Groups []string `json:"groups,omitempty"` } @@ -126,7 +129,6 @@ func userToAPIUser(u *User) *APIUser { return &APIUser{ ID: u.ID, Email: u.Email, - Role: u.Role, Groups: groups, MFAEnabled: u.MFAEnabled, CreatedAt: u.CreatedAt.Format(time.RFC3339), @@ -205,7 +207,6 @@ func (s *Service) CreateUserAPI(ctx context.Context, reqInterface any) (any, err authReq := CreateUserRequest{ Email: req.Email, Password: req.Password, - Role: req.Role, GroupIDs: req.Groups, } result, err := s.CreateUser(ctx, authReq) @@ -219,8 +220,10 @@ func (s *Service) CreateUserAPI(ctx context.Context, reqInterface any) (any, err }, nil } -// UpdateUserAPI updates a user via the API -func (s *Service) UpdateUserAPI(ctx context.Context, userID string, reqInterface any) (any, error) { +// UpdateUserAPI updates a user via the API. actorUserID is the authenticated +// caller performing the change (from the session, never the request body); it +// is used by the service layer to enforce the self-escalation guard (#907). +func (s *Service) UpdateUserAPI(ctx context.Context, actorUserID, userID string, reqInterface any) (any, error) { req, ok := reqInterface.(APIUpdateUserRequest) if !ok { return nil, fmt.Errorf("invalid request type") @@ -228,9 +231,6 @@ func (s *Service) UpdateUserAPI(ctx context.Context, userID string, reqInterface authReq := UpdateUserRequest{ GroupIDs: req.Groups, } - if req.Role != "" { - authReq.Role = &req.Role - } // Wire email through so admins can edit other users' email addresses. // Before #892 this field was silently dropped: the API accepted email // in the JSON, the handler returned 200, and the user's email column @@ -239,7 +239,7 @@ func (s *Service) UpdateUserAPI(ctx context.Context, userID string, reqInterface if req.Email != "" { authReq.Email = &req.Email } - user, err := s.UpdateUser(ctx, userID, authReq) + user, err := s.UpdateUser(ctx, actorUserID, userID, authReq) if err != nil { return nil, err } diff --git a/internal/auth/service_api_test.go b/internal/auth/service_api_test.go index b066ddcca..6d00ad7e5 100644 --- a/internal/auth/service_api_test.go +++ b/internal/auth/service_api_test.go @@ -23,7 +23,6 @@ func TestConversionHelpers(t *testing.T) { user := &User{ ID: "user-123", Email: "test@example.com", - Role: RoleUser, GroupIDs: []string{"group-1", "group-2"}, MFAEnabled: true, CreatedAt: now, @@ -33,7 +32,6 @@ func TestConversionHelpers(t *testing.T) { assert.NotNil(t, result) assert.Equal(t, "user-123", result.ID) assert.Equal(t, "test@example.com", result.Email) - assert.Equal(t, RoleUser, result.Role) assert.Equal(t, []string{"group-1", "group-2"}, result.Groups) assert.True(t, result.MFAEnabled) assert.NotEmpty(t, result.CreatedAt) @@ -144,7 +142,6 @@ func TestService_CreateUserAPI(t *testing.T) { req := APICreateUserRequest{ Email: "newuser@example.com", Password: "SecurePass@123", - Role: RoleUser, Groups: []string{"group-1"}, } @@ -156,7 +153,6 @@ func TestService_CreateUserAPI(t *testing.T) { require.True(t, ok, "CreateUserAPI should wrap the response in APICreateUserResponse") require.NotNil(t, resp.APIUser) assert.Equal(t, "newuser@example.com", resp.Email) - assert.Equal(t, RoleUser, resp.Role) assert.Equal(t, []string{"group-1"}, resp.Groups) // Non-invite path: no invite-email status fields. assert.Nil(t, resp.InviteEmailSent) @@ -186,26 +182,26 @@ func TestService_UpdateUserAPI(t *testing.T) { service := createTestService(mockStore, mockEmail) existingUser := &User{ - ID: "user-123", - Email: "test@example.com", - Role: RoleUser, + ID: "user-123", + Email: "test@example.com", + GroupIDs: []string{"group-1"}, } mockStore.On("GetUserByID", ctx, "user-123").Return(existingUser, nil).Once() mockStore.On("UpdateUser", ctx, mock.AnythingOfType("*auth.User")).Return(nil).Once() + // An admin actor ("") changes another user's group membership. req := APIUpdateUserRequest{ - Role: RoleAdmin, Groups: []string{"group-2"}, } - result, err := service.UpdateUserAPI(ctx, "user-123", req) + result, err := service.UpdateUserAPI(ctx, "", "user-123", req) require.NoError(t, err) assert.NotNil(t, result) apiUser, ok := result.(*APIUser) assert.True(t, ok) - assert.Equal(t, RoleAdmin, apiUser.Role) + assert.Equal(t, []string{"group-2"}, apiUser.Groups) mockStore.AssertExpectations(t) }) @@ -223,9 +219,9 @@ func TestService_UpdateUserAPI(t *testing.T) { service := createTestService(mockStore, mockEmail) existingUser := &User{ - ID: "user-123", - Email: "old@example.com", - Role: RoleAdmin, + ID: "user-123", + Email: "old@example.com", + GroupIDs: []string{DefaultAdminGroupID}, } mockStore.On("GetUserByID", ctx, "user-123").Return(existingUser, nil).Once() @@ -242,7 +238,7 @@ func TestService_UpdateUserAPI(t *testing.T) { Email: "new@example.com", } - result, err := service.UpdateUserAPI(ctx, "user-123", req) + result, err := service.UpdateUserAPI(ctx, "", "user-123", req) require.NoError(t, err) require.NotNil(t, result) @@ -260,9 +256,9 @@ func TestService_UpdateUserAPI(t *testing.T) { service := createTestService(mockStore, mockEmail) existingUser := &User{ - ID: "user-123", - Email: "old@example.com", - Role: RoleAdmin, + ID: "user-123", + Email: "old@example.com", + GroupIDs: []string{DefaultAdminGroupID}, } conflictingUser := &User{ ID: "user-456", @@ -276,7 +272,7 @@ func TestService_UpdateUserAPI(t *testing.T) { Email: "taken@example.com", } - result, err := service.UpdateUserAPI(ctx, "user-123", req) + result, err := service.UpdateUserAPI(ctx, "", "user-123", req) assert.Error(t, err) assert.Nil(t, result) assert.Contains(t, err.Error(), "email already in use") @@ -289,7 +285,7 @@ func TestService_UpdateUserAPI(t *testing.T) { mockEmail := new(MockEmailSender) service := createTestService(mockStore, mockEmail) - result, err := service.UpdateUserAPI(ctx, "user-123", "invalid") + result, err := service.UpdateUserAPI(ctx, "", "user-123", "invalid") assert.Error(t, err) assert.Nil(t, result) assert.Contains(t, err.Error(), "invalid request type") @@ -305,8 +301,8 @@ func TestService_ListUsersAPI(t *testing.T) { service := createTestService(mockStore, mockEmail) users := []User{ - {ID: "user-1", Email: "user1@example.com", Role: RoleUser, CreatedAt: time.Now(), UpdatedAt: time.Now()}, - {ID: "user-2", Email: "user2@example.com", Role: RoleAdmin, CreatedAt: time.Now(), UpdatedAt: time.Now()}, + {ID: "user-1", Email: "user1@example.com", CreatedAt: time.Now(), UpdatedAt: time.Now()}, + {ID: "user-2", Email: "user2@example.com", CreatedAt: time.Now(), UpdatedAt: time.Now()}, } mockStore.On("ListUsers", ctx).Return(users, nil).Once() @@ -591,11 +587,15 @@ func TestService_HasPermissionAPI(t *testing.T) { service := createTestService(mockStore, mockEmail) adminUser := &User{ - ID: "admin-123", - Role: RoleAdmin, + ID: "admin-123", + GroupIDs: []string{DefaultAdminGroupID}, } mockStore.On("GetUserByID", ctx, "admin-123").Return(adminUser, nil).Once() + mockStore.On("GetGroup", ctx, DefaultAdminGroupID).Return(&Group{ + ID: DefaultAdminGroupID, + Permissions: []Permission{{Action: ActionAdmin, Resource: ResourceAll}}, + }, nil).Once() has, err := service.HasPermissionAPI(ctx, "admin-123", ActionExecute, ResourcePlans) require.NoError(t, err) @@ -610,11 +610,15 @@ func TestService_HasPermissionAPI(t *testing.T) { service := createTestService(mockStore, mockEmail) readonlyUser := &User{ - ID: "readonly-123", - Role: RoleReadOnly, + ID: "readonly-123", + GroupIDs: []string{"readonly-group"}, } mockStore.On("GetUserByID", ctx, "readonly-123").Return(readonlyUser, nil).Once() + mockStore.On("GetGroup", ctx, "readonly-group").Return(&Group{ + ID: "readonly-group", + Permissions: []Permission{{Action: ActionView, Resource: ResourceRecommendations}}, + }, nil).Once() has, err := service.HasPermissionAPI(ctx, "readonly-123", ActionExecute, ResourcePlans) require.NoError(t, err) @@ -640,7 +644,6 @@ func TestUserToAPIUser_EmptyGroups(t *testing.T) { user := &User{ ID: "user-1", Email: "user@example.com", - Role: RoleUser, GroupIDs: nil, CreatedAt: now, UpdatedAt: now, @@ -662,7 +665,6 @@ func TestUserToAPIUser_EmptyGroups(t *testing.T) { user := &User{ ID: "user-2", Email: "user2@example.com", - Role: RoleUser, GroupIDs: []string{}, CreatedAt: now, UpdatedAt: now, @@ -679,7 +681,6 @@ func TestUserToAPIUser_EmptyGroups(t *testing.T) { user := &User{ ID: "user-3", Email: "admin@example.com", - Role: RoleAdmin, GroupIDs: []string{"admin-group-id"}, CreatedAt: now, UpdatedAt: now, diff --git a/internal/auth/service_apikeys.go b/internal/auth/service_apikeys.go index d7d688709..3a6290ad2 100644 --- a/internal/auth/service_apikeys.go +++ b/internal/auth/service_apikeys.go @@ -85,13 +85,11 @@ func (s *Service) CreateAPIKey(ctx context.Context, userID, name string, permiss return apiKey, userAPIKey, nil } -// validateAPIKeyPermissions ensures the key's permissions don't exceed the user's permissions +// validateAPIKeyPermissions ensures the key's permissions don't exceed the +// user's permissions. Administrators-group members hold {admin, *}, so the +// per-permission HasPermission check below already passes for every requested +// permission; no role-based short-circuit is needed. func (s *Service) validateAPIKeyPermissions(ctx context.Context, user *User, permissions []Permission) error { - // Admin users can create keys with any permissions - if user.Role == RoleAdmin { - return nil - } - // Get user's auth context to check their permissions authCtx, err := s.GetAuthContext(ctx, user.ID) if err != nil { @@ -169,8 +167,14 @@ func (s *Service) RevokeAPIKey(ctx context.Context, userID, keyID string) error return fmt.Errorf("user not found") } - if key.UserID != userID && user.Role != RoleAdmin { - return fmt.Errorf("unauthorized: cannot revoke another user's API key") + if key.UserID != userID { + isAdmin, err := s.UserHasAdminCapability(ctx, userID) + if err != nil { + return fmt.Errorf("failed to check admin capability: %w", err) + } + if !isAdmin { + return fmt.Errorf("unauthorized: cannot revoke another user's API key") + } } // Revoke the key @@ -210,8 +214,14 @@ func (s *Service) DeleteAPIKey(ctx context.Context, userID, keyID string) error return fmt.Errorf("user not found") } - if key.UserID != userID && user.Role != RoleAdmin { - return fmt.Errorf("unauthorized: cannot delete another user's API key") + if key.UserID != userID { + isAdmin, err := s.UserHasAdminCapability(ctx, userID) + if err != nil { + return fmt.Errorf("failed to check admin capability: %w", err) + } + if !isAdmin { + return fmt.Errorf("unauthorized: cannot delete another user's API key") + } } // Delete the key @@ -293,18 +303,14 @@ func (s *Service) UpdateLastUsed(ctx context.Context, keyID string) error { } // ComputeEffectivePermissions computes the intersection of API key permissions and user permissions -// This ensures an API key cannot grant more permissions than the user has +// This ensures an API key cannot grant more permissions than the user has. +// +// Administrators-group members carry {admin, *}: with no key-specific +// permissions their full {admin, *} context is returned, and a scoped admin +// key's permissions all pass the HasPermission intersection below, so the +// group-derived path preserves the previous role == admin behaviour without a +// special case. func (s *Service) ComputeEffectivePermissions(ctx context.Context, apiKey *UserAPIKey, user *User) ([]Permission, error) { - // Admin users always have full permissions - if user.Role == RoleAdmin { - // If API key has specific permissions, use those (scoped admin key) - if len(apiKey.Permissions) > 0 { - return apiKey.Permissions, nil - } - // Otherwise return full admin permissions - return DefaultAdminPermissions(), nil - } - // Get user's auth context authCtx, err := s.GetAuthContext(ctx, user.ID) if err != nil { diff --git a/internal/auth/service_apikeys_api_test.go b/internal/auth/service_apikeys_api_test.go index 74541265b..f7af75eb9 100644 --- a/internal/auth/service_apikeys_api_test.go +++ b/internal/auth/service_apikeys_api_test.go @@ -20,10 +20,10 @@ func TestService_CreateAPIKeyAPI(t *testing.T) { service := &Service{store: mockStore} user := &User{ - ID: "user-123", - Email: "test@example.com", - Active: true, - Role: RoleAdmin, + ID: "user-123", + Email: "test@example.com", + Active: true, + GroupIDs: []string{DefaultAdminGroupID}, } permissions := []Permission{ @@ -37,6 +37,10 @@ func TestService_CreateAPIKeyAPI(t *testing.T) { } mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) + mockStore.On("GetGroup", ctx, DefaultAdminGroupID).Return(&Group{ + ID: DefaultAdminGroupID, + Permissions: []Permission{{Action: ActionAdmin, Resource: ResourceAll}}, + }, nil) mockStore.On("CreateAPIKey", ctx, mock.AnythingOfType("*auth.UserAPIKey")).Return(nil) result, err := service.CreateAPIKeyAPI(ctx, "user-123", req) @@ -56,10 +60,10 @@ func TestService_CreateAPIKeyAPI(t *testing.T) { service := &Service{store: mockStore} user := &User{ - ID: "user-123", - Email: "test@example.com", - Active: true, - Role: RoleAdmin, + ID: "user-123", + Email: "test@example.com", + Active: true, + GroupIDs: []string{DefaultAdminGroupID}, } expiresAt := time.Now().Add(30 * 24 * time.Hour) @@ -70,6 +74,10 @@ func TestService_CreateAPIKeyAPI(t *testing.T) { } mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) + mockStore.On("GetGroup", ctx, DefaultAdminGroupID).Return(&Group{ + ID: DefaultAdminGroupID, + Permissions: []Permission{{Action: ActionAdmin, Resource: ResourceAll}}, + }, nil) mockStore.On("CreateAPIKey", ctx, mock.AnythingOfType("*auth.UserAPIKey")).Return(nil) result, err := service.CreateAPIKeyAPI(ctx, "user-123", req) @@ -196,8 +204,7 @@ func TestService_DeleteAPIKeyAPI(t *testing.T) { UserID: "user-123", } user := &User{ - ID: "user-123", - Role: RoleUser, + ID: "user-123", } mockStore.On("GetAPIKeyByID", ctx, "key-1").Return(existingKey, nil) @@ -236,8 +243,7 @@ func TestService_RevokeAPIKeyAPI(t *testing.T) { IsActive: true, } user := &User{ - ID: "user-123", - Role: RoleUser, + ID: "user-123", } mockStore.On("GetAPIKeyByID", ctx, "key-1").Return(existingKey, nil) diff --git a/internal/auth/service_apikeys_test.go b/internal/auth/service_apikeys_test.go index 2bd2129cb..b43639584 100644 --- a/internal/auth/service_apikeys_test.go +++ b/internal/auth/service_apikeys_test.go @@ -20,10 +20,10 @@ func TestService_CreateAPIKey(t *testing.T) { service := &Service{store: mockStore} user := &User{ - ID: "user-123", - Email: "test@example.com", - Active: true, - Role: RoleAdmin, + ID: "user-123", + Email: "test@example.com", + Active: true, + GroupIDs: []string{DefaultAdminGroupID}, } permissions := []Permission{ @@ -31,6 +31,10 @@ func TestService_CreateAPIKey(t *testing.T) { } mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) + mockStore.On("GetGroup", ctx, DefaultAdminGroupID).Return(&Group{ + ID: DefaultAdminGroupID, + Permissions: []Permission{{Action: ActionAdmin, Resource: ResourceAll}}, + }, nil) mockStore.On("CreateAPIKey", ctx, mock.AnythingOfType("*auth.UserAPIKey")).Return(nil) apiKey, keyInfo, err := service.CreateAPIKey(ctx, "user-123", "Test Key", permissions, nil) @@ -107,10 +111,10 @@ func TestService_CreateAPIKey(t *testing.T) { service := &Service{store: mockStore} user := &User{ - ID: "user-123", - Email: "test@example.com", - Active: true, - Role: RoleAdmin, + ID: "user-123", + Email: "test@example.com", + Active: true, + GroupIDs: []string{DefaultAdminGroupID}, } expiresAt := time.Now().Add(30 * 24 * time.Hour) @@ -119,6 +123,10 @@ func TestService_CreateAPIKey(t *testing.T) { } mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) + mockStore.On("GetGroup", ctx, DefaultAdminGroupID).Return(&Group{ + ID: DefaultAdminGroupID, + Permissions: []Permission{{Action: ActionAdmin, Resource: ResourceAll}}, + }, nil) mockStore.On("CreateAPIKey", ctx, mock.AnythingOfType("*auth.UserAPIKey")).Return(nil) apiKey, keyInfo, err := service.CreateAPIKey(ctx, "user-123", "Test Key", permissions, &expiresAt) @@ -272,7 +280,7 @@ func TestService_RevokeAPIKey(t *testing.T) { UserID: "user-123", IsActive: true, } - user := &User{ID: "user-123", Role: RoleUser} + user := &User{ID: "user-123"} mockStore.On("GetAPIKeyByID", ctx, "key-1").Return(existingKey, nil) mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) @@ -307,7 +315,7 @@ func TestService_RevokeAPIKey(t *testing.T) { UserID: "user-123", IsActive: true, } - user := &User{ID: "user-123", Role: RoleUser} + user := &User{ID: "user-123"} mockStore.On("GetAPIKeyByID", ctx, "key-1").Return(existingKey, nil) mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) @@ -328,7 +336,7 @@ func TestService_RevokeAPIKey(t *testing.T) { UserID: "user-456", IsActive: true, } - user := &User{ID: "user-123", Role: RoleUser} + user := &User{ID: "user-123"} mockStore.On("GetAPIKeyByID", ctx, "key-1").Return(existingKey, nil) mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) @@ -349,10 +357,15 @@ func TestService_RevokeAPIKey(t *testing.T) { UserID: "user-456", IsActive: true, } - user := &User{ID: "user-123", Role: RoleAdmin} + // Admin == member of the Administrators group ({admin, *}). + user := &User{ID: "user-123", GroupIDs: []string{DefaultAdminGroupID}} mockStore.On("GetAPIKeyByID", ctx, "key-1").Return(existingKey, nil) mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) + mockStore.On("GetGroup", ctx, DefaultAdminGroupID).Return(&Group{ + ID: DefaultAdminGroupID, + Permissions: []Permission{{Action: ActionAdmin, Resource: ResourceAll}}, + }, nil) mockStore.On("UpdateAPIKey", ctx, mock.MatchedBy(func(key *UserAPIKey) bool { return key.ID == "key-1" && !key.IsActive })).Return(nil) @@ -372,7 +385,7 @@ func TestService_DeleteAPIKey(t *testing.T) { service := &Service{store: mockStore} existingKey := &UserAPIKey{ID: "key-1", UserID: "user-123"} - user := &User{ID: "user-123", Role: RoleUser} + user := &User{ID: "user-123"} mockStore.On("GetAPIKeyByID", ctx, "key-1").Return(existingKey, nil) mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) @@ -401,7 +414,7 @@ func TestService_DeleteAPIKey(t *testing.T) { service := &Service{store: mockStore} existingKey := &UserAPIKey{ID: "key-1", UserID: "user-456"} - user := &User{ID: "user-123", Role: RoleUser} + user := &User{ID: "user-123"} mockStore.On("GetAPIKeyByID", ctx, "key-1").Return(existingKey, nil) mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) @@ -418,10 +431,15 @@ func TestService_DeleteAPIKey(t *testing.T) { service := &Service{store: mockStore} existingKey := &UserAPIKey{ID: "key-1", UserID: "user-456"} - user := &User{ID: "user-123", Role: RoleAdmin} + // Admin == member of the Administrators group ({admin, *}). + user := &User{ID: "user-123", GroupIDs: []string{DefaultAdminGroupID}} mockStore.On("GetAPIKeyByID", ctx, "key-1").Return(existingKey, nil) mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) + mockStore.On("GetGroup", ctx, DefaultAdminGroupID).Return(&Group{ + ID: DefaultAdminGroupID, + Permissions: []Permission{{Action: ActionAdmin, Resource: ResourceAll}}, + }, nil) mockStore.On("DeleteAPIKey", ctx, "key-1").Return(nil) err := service.DeleteAPIKey(ctx, "user-123", "key-1") @@ -450,7 +468,7 @@ func TestService_DeleteAPIKey(t *testing.T) { service := &Service{store: mockStore} existingKey := &UserAPIKey{ID: "key-1", UserID: "user-123"} - user := &User{ID: "user-123", Role: RoleUser} + user := &User{ID: "user-123"} mockStore.On("GetAPIKeyByID", ctx, "key-1").Return(existingKey, nil) mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) @@ -666,112 +684,73 @@ func TestService_UpdateLastUsed(t *testing.T) { func TestService_ComputeEffectivePermissions(t *testing.T) { ctx := context.Background() - t.Run("return API key permissions when defined", func(t *testing.T) { - mockStore := new(MockStore) - service := &Service{store: mockStore} - - apiKeyPermissions := []Permission{ + // adminGrp / userGrp are reused across sub-cases. Permissions derive + // purely from group membership now (issue #907). + adminGrp := func() *Group { + return &Group{ID: DefaultAdminGroupID, Permissions: []Permission{{Action: ActionAdmin, Resource: ResourceAll}}} + } + userGrpID := "00000000-0000-5000-8000-000000000005" + userGrp := func() *Group { + return &Group{ID: userGrpID, Permissions: []Permission{ {Action: ActionView, Resource: ResourceRecommendations}, - } - - apiKey := &UserAPIKey{ - ID: "key-1", - UserID: "user-123", - Permissions: apiKeyPermissions, - } - - user := &User{ - ID: "user-123", - Role: RoleAdmin, - } - - permissions, err := service.ComputeEffectivePermissions(ctx, apiKey, user) - - require.NoError(t, err) - assert.Equal(t, apiKeyPermissions, permissions) - }) + {Action: ActionCreate, Resource: ResourcePlans}, + }} + } - t.Run("return user permissions when API key has no permissions", func(t *testing.T) { + t.Run("admin with scoped API key returns key permissions (intersection passes)", func(t *testing.T) { mockStore := new(MockStore) service := &Service{store: mockStore} + t.Cleanup(func() { mockStore.AssertExpectations(t) }) - apiKey := &UserAPIKey{ - ID: "key-1", - UserID: "user-123", - Permissions: []Permission{}, - } - - user := &User{ - ID: "user-123", - Role: RoleUser, - } + scoped := []Permission{{Action: ActionView, Resource: ResourceRecommendations}} + apiKey := &UserAPIKey{ID: "key-1", UserID: "user-123", Permissions: scoped} + user := &User{ID: "user-123", GroupIDs: []string{DefaultAdminGroupID}} mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) - mockStore.On("GetUserGroups", ctx, "user-123").Return([]string{}, nil) - mockStore.On("GetUserPermissions", ctx, "user-123").Return([]Permission{ - {Action: ActionView, Resource: ResourceRecommendations}, - {Action: ActionExecute, Resource: ResourcePlans}, - }, nil) + mockStore.On("GetGroup", ctx, DefaultAdminGroupID).Return(adminGrp(), nil) permissions, err := service.ComputeEffectivePermissions(ctx, apiKey, user) - require.NoError(t, err) - assert.Greater(t, len(permissions), 0) // Returns role-based permissions when API key has none + // Admin holds {admin, *} so the scoped key permission passes the + // intersection and is returned. + assert.Equal(t, scoped, permissions) }) - t.Run("return empty when both have no permissions", func(t *testing.T) { + t.Run("admin with unscoped key returns full group permissions", func(t *testing.T) { mockStore := new(MockStore) service := &Service{store: mockStore} + t.Cleanup(func() { mockStore.AssertExpectations(t) }) - apiKey := &UserAPIKey{ - ID: "key-1", - UserID: "user-123", - Permissions: []Permission{}, - } - - user := &User{ - ID: "user-123", - Role: RoleReadOnly, - } + apiKey := &UserAPIKey{ID: "key-1", UserID: "user-123", Permissions: []Permission{}} + user := &User{ID: "user-123", GroupIDs: []string{DefaultAdminGroupID}} mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) - mockStore.On("GetUserGroups", ctx, "user-123").Return([]string{}, nil) - mockStore.On("GetUserPermissions", ctx, "user-123").Return([]Permission{}, nil) + mockStore.On("GetGroup", ctx, DefaultAdminGroupID).Return(adminGrp(), nil) permissions, err := service.ComputeEffectivePermissions(ctx, apiKey, user) - require.NoError(t, err) - assert.Greater(t, len(permissions), 0) // ReadOnly role has default permissions + assert.Equal(t, []Permission{{Action: ActionAdmin, Resource: ResourceAll}}, permissions) }) - t.Run("admin with scoped API key returns key permissions", func(t *testing.T) { + t.Run("zero-group user with unscoped key returns no permissions (fail closed)", func(t *testing.T) { mockStore := new(MockStore) service := &Service{store: mockStore} + t.Cleanup(func() { mockStore.AssertExpectations(t) }) - scopedPermissions := []Permission{ - {Action: ActionView, Resource: ResourceRecommendations}, - } - - apiKey := &UserAPIKey{ - ID: "key-1", - UserID: "user-123", - Permissions: scopedPermissions, - } + apiKey := &UserAPIKey{ID: "key-1", UserID: "user-123", Permissions: []Permission{}} + user := &User{ID: "user-123", GroupIDs: nil} - user := &User{ - ID: "user-123", - Role: RoleAdmin, - } + mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) permissions, err := service.ComputeEffectivePermissions(ctx, apiKey, user) - require.NoError(t, err) - assert.Equal(t, scopedPermissions, permissions) + assert.Empty(t, permissions) }) - t.Run("return intersection of API key and user permissions", func(t *testing.T) { + t.Run("return intersection of API key and group permissions", func(t *testing.T) { mockStore := new(MockStore) service := &Service{store: mockStore} + t.Cleanup(func() { mockStore.AssertExpectations(t) }) apiKey := &UserAPIKey{ ID: "key-1", @@ -779,33 +758,25 @@ func TestService_ComputeEffectivePermissions(t *testing.T) { Permissions: []Permission{ {Action: ActionView, Resource: ResourceRecommendations}, {Action: ActionCreate, Resource: ResourcePlans}, - {Action: ActionAdmin, Resource: ResourceUsers}, // User doesn't have this + {Action: ActionAdmin, Resource: ResourceUsers}, // group lacks this }, } - - user := &User{ - ID: "user-123", - Role: RoleUser, - } + user := &User{ID: "user-123", GroupIDs: []string{userGrpID}} mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) - mockStore.On("GetUserGroups", ctx, "user-123").Return([]string{}, nil) - mockStore.On("GetUserPermissions", ctx, "user-123").Return([]Permission{ - {Action: ActionView, Resource: ResourceRecommendations}, - {Action: ActionCreate, Resource: ResourcePlans}, - }, nil) + mockStore.On("GetGroup", ctx, userGrpID).Return(userGrp(), nil) permissions, err := service.ComputeEffectivePermissions(ctx, apiKey, user) - require.NoError(t, err) assert.Len(t, permissions, 2) assert.Contains(t, permissions, Permission{Action: ActionView, Resource: ResourceRecommendations}) assert.Contains(t, permissions, Permission{Action: ActionCreate, Resource: ResourcePlans}) }) - t.Run("return empty when API key permissions not in user permissions", func(t *testing.T) { + t.Run("return empty when API key permissions not in group permissions", func(t *testing.T) { mockStore := new(MockStore) service := &Service{store: mockStore} + t.Cleanup(func() { mockStore.AssertExpectations(t) }) apiKey := &UserAPIKey{ ID: "key-1", @@ -815,22 +786,13 @@ func TestService_ComputeEffectivePermissions(t *testing.T) { {Action: ActionUpdate, Resource: ResourceConfig}, }, } - - user := &User{ - ID: "user-123", - Role: RoleUser, - } + user := &User{ID: "user-123", GroupIDs: []string{userGrpID}} mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) - mockStore.On("GetUserGroups", ctx, "user-123").Return([]string{}, nil) - mockStore.On("GetUserPermissions", ctx, "user-123").Return([]Permission{ - {Action: ActionView, Resource: ResourceRecommendations}, - }, nil) + mockStore.On("GetGroup", ctx, userGrpID).Return(userGrp(), nil) permissions, err := service.ComputeEffectivePermissions(ctx, apiKey, user) - require.NoError(t, err) - // Should return empty since user doesn't have any of the API key's permissions assert.Empty(t, permissions) }) } @@ -841,84 +803,77 @@ func TestService_validateAPIKeyPermissions(t *testing.T) { t.Run("admin user can create keys with any permissions", func(t *testing.T) { mockStore := new(MockStore) service := &Service{store: mockStore} + t.Cleanup(func() { mockStore.AssertExpectations(t) }) - user := &User{ - ID: "user-123", - Role: RoleAdmin, - } - + user := &User{ID: "user-123", GroupIDs: []string{DefaultAdminGroupID}} permissions := []Permission{ {Action: ActionAdmin, Resource: ResourceUsers}, {Action: ActionUpdate, Resource: ResourceConfig}, } + // validateAPIKeyPermissions resolves the user's group permissions; the + // admin group's {admin, *} satisfies any requested permission. + mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) + mockStore.On("GetGroup", ctx, DefaultAdminGroupID).Return(&Group{ + ID: DefaultAdminGroupID, + Permissions: []Permission{{Action: ActionAdmin, Resource: ResourceAll}}, + }, nil) + err := service.validateAPIKeyPermissions(ctx, user, permissions) require.NoError(t, err) }) - t.Run("non-admin user can create keys with their permissions", func(t *testing.T) { + t.Run("non-admin user can create keys with their group permissions", func(t *testing.T) { mockStore := new(MockStore) service := &Service{store: mockStore} + t.Cleanup(func() { mockStore.AssertExpectations(t) }) - user := &User{ - ID: "user-123", - Role: RoleUser, - GroupIDs: []string{}, - } + grpID := "viewer-group" + user := &User{ID: "user-123", GroupIDs: []string{grpID}} + permissions := []Permission{{Action: ActionView, Resource: ResourceRecommendations}} - permissions := []Permission{ - {Action: ActionView, Resource: ResourceRecommendations}, - } - - // BuildAuthContext will call GetUserByID mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) + mockStore.On("GetGroup", ctx, grpID).Return(&Group{ + ID: grpID, + Permissions: []Permission{{Action: ActionView, Resource: ResourceRecommendations}}, + }, nil) err := service.validateAPIKeyPermissions(ctx, user, permissions) require.NoError(t, err) - mockStore.AssertExpectations(t) }) - t.Run("fail when non-admin user requests permissions they don't have", func(t *testing.T) { + t.Run("fail when user requests permissions their groups don't grant", func(t *testing.T) { mockStore := new(MockStore) service := &Service{store: mockStore} + t.Cleanup(func() { mockStore.AssertExpectations(t) }) - user := &User{ - ID: "user-123", - Role: RoleUser, - GroupIDs: []string{}, - } - - permissions := []Permission{ - {Action: ActionAdmin, Resource: ResourceUsers}, - } + grpID := "viewer-group" + user := &User{ID: "user-123", GroupIDs: []string{grpID}} + permissions := []Permission{{Action: ActionAdmin, Resource: ResourceUsers}} - // BuildAuthContext will call GetUserByID mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) + mockStore.On("GetGroup", ctx, grpID).Return(&Group{ + ID: grpID, + Permissions: []Permission{{Action: ActionView, Resource: ResourceRecommendations}}, + }, nil) err := service.validateAPIKeyPermissions(ctx, user, permissions) assert.Error(t, err) assert.Contains(t, err.Error(), "user does not have permission") - mockStore.AssertExpectations(t) }) t.Run("fail when GetAuthContext fails", func(t *testing.T) { mockStore := new(MockStore) service := &Service{store: mockStore} + t.Cleanup(func() { mockStore.AssertExpectations(t) }) - user := &User{ - ID: "user-123", - Role: RoleUser, - } - - permissions := []Permission{ - {Action: ActionView, Resource: ResourceRecommendations}, - } + user := &User{ID: "user-123", GroupIDs: []string{"g"}} + permissions := []Permission{{Action: ActionView, Resource: ResourceRecommendations}} mockStore.On("GetUserByID", ctx, "user-123").Return(nil, assert.AnError) err := service.validateAPIKeyPermissions(ctx, user, permissions) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to get user permissions") - mockStore.AssertExpectations(t) }) } diff --git a/internal/auth/service_group.go b/internal/auth/service_group.go index 94e90daea..b930192a1 100644 --- a/internal/auth/service_group.go +++ b/internal/auth/service_group.go @@ -46,7 +46,10 @@ func (s *Service) ListGroups(ctx context.Context) ([]Group, error) { return s.store.ListGroups(ctx) } -// GetUserPermissions returns all permissions for a user (from role + groups) +// GetUserPermissions returns all permissions for a user. Authorization is +// derived purely from the union of the user's groups' permissions: there is +// no role-based fallback. A user with no groups therefore has no permissions +// and is denied everything (fail closed). func (s *Service) GetUserPermissions(ctx context.Context, userID string) ([]Permission, error) { user, err := s.store.GetUserByID(ctx, userID) if err != nil { @@ -61,17 +64,7 @@ func (s *Service) GetUserPermissions(ctx context.Context, userID string) ([]Perm var permissions []Permission - // Add role-based permissions - switch user.Role { - case RoleAdmin: - permissions = append(permissions, DefaultAdminPermissions()...) - case RoleUser: - permissions = append(permissions, DefaultUserPermissions()...) - case RoleReadOnly: - permissions = append(permissions, DefaultReadOnlyPermissions()...) - } - - // Add group permissions + // Permissions come exclusively from group memberships. for _, groupID := range user.GroupIDs { group, err := s.store.GetGroup(ctx, groupID) if err != nil { @@ -87,8 +80,10 @@ func (s *Service) GetUserPermissions(ctx context.Context, userID string) ([]Perm return permissions, nil } -// BuildAuthContext builds a complete authorization context for a user -// This includes permissions and allowed accounts from the user's role and groups +// BuildAuthContext builds a complete authorization context for a user. +// Permissions and allowed accounts are derived purely from the union of the +// user's group memberships; a user with no groups gets an empty context and +// is denied everything (fail closed). func (s *Service) BuildAuthContext(ctx context.Context, userID string) (*AuthContext, error) { user, err := s.store.GetUserByID(ctx, userID) if err != nil { @@ -108,23 +103,11 @@ func (s *Service) BuildAuthContext(ctx context.Context, userID string) (*AuthCon Permissions: make([]Permission, 0), } - addRolePermissions(authCtx, user.Role) s.collectGroupsAndAccounts(ctx, authCtx, user.GroupIDs) return authCtx, nil } -func addRolePermissions(authCtx *AuthContext, role string) { - switch role { - case RoleAdmin: - authCtx.Permissions = append(authCtx.Permissions, DefaultAdminPermissions()...) - case RoleUser: - authCtx.Permissions = append(authCtx.Permissions, DefaultUserPermissions()...) - case RoleReadOnly: - authCtx.Permissions = append(authCtx.Permissions, DefaultReadOnlyPermissions()...) - } -} - func (s *Service) collectGroupsAndAccounts(ctx context.Context, authCtx *AuthContext, groupIDs []string) { accountSet := make(map[string]bool) @@ -186,6 +169,25 @@ func checkAdminPermission(perm Permission) bool { return perm.Action == ActionAdmin && perm.Resource == ResourceAll } +// UserHasAdminCapability reports whether the user's effective (group-derived) +// permissions include the full-access {admin, *} capability, i.e. the user is +// a member of the Administrators group (or any group granted equivalent +// permission). This is the group-membership replacement for the old +// role == "admin" short-circuit. Fail closed: any lookup error returns +// (false, err) and callers must deny. +func (s *Service) UserHasAdminCapability(ctx context.Context, userID string) (bool, error) { + perms, err := s.GetUserPermissions(ctx, userID) + if err != nil { + return false, err + } + for _, perm := range perms { + if checkAdminPermission(perm) { + return true, nil + } + } + return false, nil +} + func checkPermissionMatch(perm Permission, action, resource string) bool { if perm.Action != action { return false diff --git a/internal/auth/service_group_only_authz_test.go b/internal/auth/service_group_only_authz_test.go new file mode 100644 index 000000000..a97fc36ff --- /dev/null +++ b/internal/auth/service_group_only_authz_test.go @@ -0,0 +1,271 @@ +package auth + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// adminGroup returns the seeded Administrators group ({admin, *}, allowed=['*']). +func adminGroup() *Group { + return &Group{ + ID: DefaultAdminGroupID, + Name: "Administrators", + Permissions: []Permission{{Action: ActionAdmin, Resource: ResourceAll}}, + AllowedAccounts: []string{"*"}, + } +} + +// viewerGroup returns a read-only group with no admin capability. +func viewerGroup() *Group { + return &Group{ + ID: "00000000-0000-5000-8000-000000000004", + Name: "Viewers", + Permissions: []Permission{{Action: ActionView, Resource: ResourceRecommendations}}, + } +} + +// TestGroupOnlyAuthz_AdminEquivalence proves an Administrators-group member +// retains every capability the old role == "admin" path allowed: HasPermission +// returns true for any action/resource, and UserHasAdminCapability is true. +func TestGroupOnlyAuthz_AdminEquivalence(t *testing.T) { + ctx := context.Background() + mockStore := new(MockStore) + mockEmail := new(MockEmailSender) + svc := createTestService(mockStore, mockEmail) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + + admin := &User{ID: "admin-1", GroupIDs: []string{DefaultAdminGroupID}, Active: true} + mockStore.On("GetUserByID", ctx, "admin-1").Return(admin, nil) + mockStore.On("GetGroup", ctx, DefaultAdminGroupID).Return(adminGroup(), nil) + + for _, tc := range []struct{ action, resource string }{ + {ActionExecute, ResourcePurchases}, + {ActionApproveAny, ResourcePurchases}, + {ActionDelete, ResourceUsers}, + {ActionUpdate, ResourceConfig}, + } { + has, err := svc.HasPermission(ctx, "admin-1", tc.action, tc.resource, nil) + require.NoError(t, err) + assert.Truef(t, has, "admin must hold %s on %s", tc.action, tc.resource) + } + + isAdmin, err := svc.UserHasAdminCapability(ctx, "admin-1") + require.NoError(t, err) + assert.True(t, isAdmin) +} + +// TestGroupOnlyAuthz_NonAdminDenied proves a non-admin (viewer-only) is denied +// the privileged actions the admin path allowed, and is not flagged as admin. +func TestGroupOnlyAuthz_NonAdminDenied(t *testing.T) { + ctx := context.Background() + mockStore := new(MockStore) + mockEmail := new(MockEmailSender) + svc := createTestService(mockStore, mockEmail) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + + viewer := &User{ID: "viewer-1", GroupIDs: []string{viewerGroup().ID}, Active: true} + mockStore.On("GetUserByID", ctx, "viewer-1").Return(viewer, nil) + mockStore.On("GetGroup", ctx, viewerGroup().ID).Return(viewerGroup(), nil) + + has, err := svc.HasPermission(ctx, "viewer-1", ActionApproveAny, ResourcePurchases, nil) + require.NoError(t, err) + assert.False(t, has, "viewer must NOT hold approve-any on purchases") + + isAdmin, err := svc.UserHasAdminCapability(ctx, "viewer-1") + require.NoError(t, err) + assert.False(t, isAdmin) +} + +// TestGroupOnlyAuthz_ZeroGroupFailClosed proves a user with no groups is denied +// everything (fail closed) — no role fallback grants them anything. +func TestGroupOnlyAuthz_ZeroGroupFailClosed(t *testing.T) { + ctx := context.Background() + mockStore := new(MockStore) + mockEmail := new(MockEmailSender) + svc := createTestService(mockStore, mockEmail) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + + orphan := &User{ID: "orphan-1", GroupIDs: nil, Active: true} + mockStore.On("GetUserByID", ctx, "orphan-1").Return(orphan, nil) + + has, err := svc.HasPermission(ctx, "orphan-1", ActionView, ResourceRecommendations, nil) + require.NoError(t, err) + assert.False(t, has, "a zero-group user must be denied everything") + + isAdmin, err := svc.UserHasAdminCapability(ctx, "orphan-1") + require.NoError(t, err) + assert.False(t, isAdmin) +} + +// TestGroupOnlyAuthz_LookupErrorDenies proves a store error fails closed: +// HasPermission propagates the error (callers deny) rather than allowing. +func TestGroupOnlyAuthz_LookupErrorDenies(t *testing.T) { + ctx := context.Background() + mockStore := new(MockStore) + mockEmail := new(MockEmailSender) + svc := createTestService(mockStore, mockEmail) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + + mockStore.On("GetUserByID", ctx, "err-1").Return(nil, errors.New("db down")) + + has, err := svc.HasPermission(ctx, "err-1", ActionView, ResourceRecommendations, nil) + require.Error(t, err) + assert.False(t, has) +} + +// TestCreateUser_RejectsZeroGroups proves user creation requires >= 1 group. +func TestCreateUser_RejectsZeroGroups(t *testing.T) { + ctx := context.Background() + mockStore := new(MockStore) + mockEmail := new(MockEmailSender) + svc := createTestService(mockStore, mockEmail) + + // The email-uniqueness pre-check runs before the group check. + mockStore.On("GetUserByEmail", ctx, "new@example.com").Return(nil, nil) + + _, err := svc.CreateUser(ctx, CreateUserRequest{ + Email: "new@example.com", + Password: "Sup3rSecretP@ssw0rd!", + GroupIDs: nil, + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoGroups) + // CreateUser must NOT have been called on the store. + mockStore.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything) +} + +// TestUpdateUser_RejectsZeroGroups proves an update cannot empty group_ids. +func TestUpdateUser_RejectsZeroGroups(t *testing.T) { + ctx := context.Background() + mockStore := new(MockStore) + mockEmail := new(MockEmailSender) + svc := createTestService(mockStore, mockEmail) + + existing := &User{ID: "u1", GroupIDs: []string{viewerGroup().ID}, Active: true} + mockStore.On("GetUserByID", ctx, "u1").Return(existing, nil) + + empty := []string{} + _, err := svc.UpdateUser(ctx, "actor", "u1", UpdateUserRequest{GroupIDs: empty}) + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoGroups) + mockStore.AssertNotCalled(t, "UpdateUser", mock.Anything, mock.Anything) +} + +// TestUpdateUser_LastAdminProtection proves the last Administrators-group +// member cannot be demoted out of the group. +func TestUpdateUser_LastAdminProtection(t *testing.T) { + ctx := context.Background() + mockStore := new(MockStore) + mockEmail := new(MockEmailSender) + svc := createTestService(mockStore, mockEmail) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + + admin := &User{ID: "a1", GroupIDs: []string{DefaultAdminGroupID}, Active: true} + mockStore.On("GetUserByID", ctx, "a1").Return(admin, nil) + mockStore.On("CountGroupMembers", ctx, DefaultAdminGroupID).Return(1, nil) + + // Demote the only admin to a viewer group: must be refused. + _, err := svc.UpdateUser(ctx, "", "a1", UpdateUserRequest{GroupIDs: []string{viewerGroup().ID}}) + require.Error(t, err) + assert.ErrorIs(t, err, ErrLastAdmin) + mockStore.AssertNotCalled(t, "UpdateUser", mock.Anything, mock.Anything) +} + +// TestUpdateUser_DemoteAdminWhenOthersExist allows demotion when another admin +// remains, proving the guard is a last-member guard, not a blanket block. +func TestUpdateUser_DemoteAdminWhenOthersExist(t *testing.T) { + ctx := context.Background() + mockStore := new(MockStore) + mockEmail := new(MockEmailSender) + svc := createTestService(mockStore, mockEmail) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + + admin := &User{ID: "a1", GroupIDs: []string{DefaultAdminGroupID}, Active: true} + mockStore.On("GetUserByID", ctx, "a1").Return(admin, nil) + mockStore.On("CountGroupMembers", ctx, DefaultAdminGroupID).Return(2, nil) + mockStore.On("UpdateUser", ctx, mock.AnythingOfType("*auth.User")).Return(nil) + + updated, err := svc.UpdateUser(ctx, "", "a1", UpdateUserRequest{GroupIDs: []string{viewerGroup().ID}}) + require.NoError(t, err) + assert.Equal(t, []string{viewerGroup().ID}, updated.GroupIDs) +} + +// TestDeleteUser_LastAdminProtection proves the last admin cannot be deleted. +func TestDeleteUser_LastAdminProtection(t *testing.T) { + ctx := context.Background() + mockStore := new(MockStore) + mockEmail := new(MockEmailSender) + svc := createTestService(mockStore, mockEmail) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + + admin := &User{ID: "a1", GroupIDs: []string{DefaultAdminGroupID}, Active: true} + mockStore.On("GetUserByID", ctx, "a1").Return(admin, nil) + mockStore.On("CountGroupMembers", ctx, DefaultAdminGroupID).Return(1, nil) + + err := svc.DeleteUser(ctx, "a1") + require.Error(t, err) + assert.ErrorIs(t, err, ErrLastAdmin) + mockStore.AssertNotCalled(t, "DeleteUser", mock.Anything, mock.Anything) +} + +// TestUpdateUser_SelfEscalationDenied proves a non-privileged user cannot add a +// new group to their own membership. +func TestUpdateUser_SelfEscalationDenied(t *testing.T) { + ctx := context.Background() + mockStore := new(MockStore) + mockEmail := new(MockEmailSender) + svc := createTestService(mockStore, mockEmail) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + + // Actor == target, currently only a viewer, attempts to add the + // Administrators group to themselves. UpdateUser fetches the target first + // (this object is mutated in place by applyUpdateUserRequest), then the + // self-escalation guard re-resolves the actor's *persisted* permissions + // via a fresh GetUserByID; return a distinct, unmutated viewer copy for + // that second read so it mirrors a real DB round-trip rather than aliasing + // the just-mutated object. + target := &User{ID: "self-1", GroupIDs: []string{viewerGroup().ID}, Active: true} + actor := &User{ID: "self-1", GroupIDs: []string{viewerGroup().ID}, Active: true} + mockStore.On("GetUserByID", ctx, "self-1").Return(target, nil).Once() + mockStore.On("GetUserByID", ctx, "self-1").Return(actor, nil).Once() + mockStore.On("GetGroup", ctx, viewerGroup().ID).Return(viewerGroup(), nil) + // The change ADDS Administrators (does not remove it), so the last-admin + // branch is skipped; the self-escalation branch then evaluates + // HasPermission(update, users), which a viewer lacks. + + _, err := svc.UpdateUser(ctx, "self-1", "self-1", + UpdateUserRequest{GroupIDs: []string{viewerGroup().ID, DefaultAdminGroupID}}) + require.Error(t, err) + assert.ErrorIs(t, err, ErrSelfEscalation) + mockStore.AssertNotCalled(t, "UpdateUser", mock.Anything, mock.Anything) +} + +// TestUpdateUser_AdminEditingSelfAllowed proves a privileged actor (manage-users) +// can change their own groups (no self-escalation block). +func TestUpdateUser_AdminEditingSelfAllowed(t *testing.T) { + ctx := context.Background() + mockStore := new(MockStore) + mockEmail := new(MockEmailSender) + svc := createTestService(mockStore, mockEmail) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + + admin := &User{ID: "adm", GroupIDs: []string{DefaultAdminGroupID}, Active: true} + // GetUserByID is hit by UpdateUser (target) and by HasPermission (actor). + mockStore.On("GetUserByID", ctx, "adm").Return(admin, nil) + mockStore.On("GetGroup", ctx, DefaultAdminGroupID).Return(adminGroup(), nil) + mockStore.On("GetGroup", ctx, viewerGroup().ID).Return(viewerGroup(), nil) + mockStore.On("UpdateUser", ctx, mock.AnythingOfType("*auth.User")).Return(nil) + + // Admin adds a viewer group to themselves while KEEPING Administrators, so + // the last-admin branch is not triggered and the self-escalation guard + // passes because the actor holds {admin, *} (i.e. manage-users). + updated, err := svc.UpdateUser(ctx, "adm", "adm", + UpdateUserRequest{GroupIDs: []string{DefaultAdminGroupID, viewerGroup().ID}}) + require.NoError(t, err) + assert.ElementsMatch(t, []string{DefaultAdminGroupID, viewerGroup().ID}, updated.GroupIDs) +} diff --git a/internal/auth/service_group_test.go b/internal/auth/service_group_test.go index 10d34308d..b5d9a3eed 100644 --- a/internal/auth/service_group_test.go +++ b/internal/auth/service_group_test.go @@ -18,11 +18,17 @@ func TestService_HasPermission(t *testing.T) { service := createTestService(mockStore, mockEmail) adminUser := &User{ - ID: "admin-123", - Role: RoleAdmin, + ID: "admin-123", + GroupIDs: []string{DefaultAdminGroupID}, + } + adminGrp := &Group{ + ID: DefaultAdminGroupID, + Name: "Administrators", + Permissions: []Permission{{Action: ActionAdmin, Resource: ResourceAll}}, } mockStore.On("GetUserByID", ctx, "admin-123").Return(adminUser, nil).Once() + mockStore.On("GetGroup", ctx, DefaultAdminGroupID).Return(adminGrp, nil).Once() has, err := service.HasPermission(ctx, "admin-123", ActionExecute, "aws/ec2", nil) require.NoError(t, err) @@ -38,7 +44,6 @@ func TestService_HasPermission(t *testing.T) { regularUser := &User{ ID: "user-123", - Role: RoleUser, GroupIDs: []string{"group-1"}, } @@ -77,11 +82,17 @@ func TestService_HasPermission(t *testing.T) { service := createTestService(mockStore, mockEmail) readonlyUser := &User{ - ID: "readonly-123", - Role: RoleReadOnly, + ID: "readonly-123", + GroupIDs: []string{"readonly-group"}, + } + readonlyGrp := &Group{ + ID: "readonly-group", + Name: "Read-Only Users", + Permissions: DefaultReadOnlyPermissions(), } mockStore.On("GetUserByID", ctx, "readonly-123").Return(readonlyUser, nil).Once() + mockStore.On("GetGroup", ctx, "readonly-group").Return(readonlyGrp, nil).Once() has, err := service.HasPermission(ctx, "readonly-123", ActionExecute, "aws/ec2", nil) require.NoError(t, err) @@ -226,11 +237,17 @@ func TestService_GetUserPermissions(t *testing.T) { service := createTestService(mockStore, mockEmail) adminUser := &User{ - ID: "admin-123", - Role: RoleAdmin, + ID: "admin-123", + GroupIDs: []string{DefaultAdminGroupID}, + } + adminGrp := &Group{ + ID: DefaultAdminGroupID, + Name: "Administrators", + Permissions: DefaultAdminPermissions(), } mockStore.On("GetUserByID", ctx, "admin-123").Return(adminUser, nil).Once() + mockStore.On("GetGroup", ctx, DefaultAdminGroupID).Return(adminGrp, nil).Once() permissions, err := service.GetUserPermissions(ctx, "admin-123") require.NoError(t, err) @@ -247,11 +264,17 @@ func TestService_GetUserPermissions(t *testing.T) { service := createTestService(mockStore, mockEmail) regularUser := &User{ - ID: "user-123", - Role: RoleUser, + ID: "user-123", + GroupIDs: []string{"standard-group"}, + } + standardGrp := &Group{ + ID: "standard-group", + Name: "Standard Users", + Permissions: DefaultUserPermissions(), } mockStore.On("GetUserByID", ctx, "user-123").Return(regularUser, nil).Once() + mockStore.On("GetGroup", ctx, "standard-group").Return(standardGrp, nil).Once() permissions, err := service.GetUserPermissions(ctx, "user-123") require.NoError(t, err) @@ -270,11 +293,17 @@ func TestService_GetUserPermissions(t *testing.T) { service := createTestService(mockStore, mockEmail) readonlyUser := &User{ - ID: "readonly-123", - Role: RoleReadOnly, + ID: "readonly-123", + GroupIDs: []string{"readonly-group"}, + } + readonlyGrp := &Group{ + ID: "readonly-group", + Name: "Read-Only Users", + Permissions: DefaultReadOnlyPermissions(), } mockStore.On("GetUserByID", ctx, "readonly-123").Return(readonlyUser, nil).Once() + mockStore.On("GetGroup", ctx, "readonly-group").Return(readonlyGrp, nil).Once() permissions, err := service.GetUserPermissions(ctx, "readonly-123") require.NoError(t, err) @@ -290,8 +319,13 @@ func TestService_GetUserPermissions(t *testing.T) { userWithGroups := &User{ ID: "user-123", - Role: RoleUser, - GroupIDs: []string{"group-1", "group-2"}, + GroupIDs: []string{"standard-group", "group-1", "group-2"}, + } + + standardGrp := &Group{ + ID: "standard-group", + Name: "Standard Users", + Permissions: DefaultUserPermissions(), } group1 := &Group{ @@ -311,12 +345,13 @@ func TestService_GetUserPermissions(t *testing.T) { } mockStore.On("GetUserByID", ctx, "user-123").Return(userWithGroups, nil).Once() + mockStore.On("GetGroup", ctx, "standard-group").Return(standardGrp, nil).Once() mockStore.On("GetGroup", ctx, "group-1").Return(group1, nil).Once() mockStore.On("GetGroup", ctx, "group-2").Return(group2, nil).Once() permissions, err := service.GetUserPermissions(ctx, "user-123") require.NoError(t, err) - // 11 user (incl. delete:plans (PR-A #660) + update:purchases (PR-A #660) + // 11 standard-group (incl. delete:plans (PR-A #660) + update:purchases (PR-A #660) // + cancel-own (#46) + retry-own (#47) + approve-own (#286):purchases) // + 1 group1 + 1 group2 = 13 assert.Len(t, permissions, 13) @@ -346,16 +381,22 @@ func TestService_GetUserPermissions(t *testing.T) { userWithMissingGroup := &User{ ID: "user-123", - Role: RoleUser, - GroupIDs: []string{"missing-group"}, + GroupIDs: []string{"standard-group", "missing-group"}, + } + standardGrp := &Group{ + ID: "standard-group", + Name: "Standard Users", + Permissions: DefaultUserPermissions(), } mockStore.On("GetUserByID", ctx, "user-123").Return(userWithMissingGroup, nil).Once() + mockStore.On("GetGroup", ctx, "standard-group").Return(standardGrp, nil).Once() mockStore.On("GetGroup", ctx, "missing-group").Return(nil, nil).Once() permissions, err := service.GetUserPermissions(ctx, "user-123") require.NoError(t, err) - // Should have only user permissions, missing group is skipped. + // Should have only the resolvable group's permissions; the missing + // group is skipped. // 11 = 6 read/plan-author + delete:plans (PR-A #660) // + update:purchases (PR-A #660) // + cancel-own:purchases (issue #46) @@ -375,12 +416,18 @@ func TestService_BuildAuthContext(t *testing.T) { service := createTestService(mockStore, mockEmail) adminUser := &User{ - ID: "admin-123", - Email: "admin@example.com", - Role: RoleAdmin, + ID: "admin-123", + Email: "admin@example.com", + GroupIDs: []string{DefaultAdminGroupID}, + } + adminGrp := &Group{ + ID: DefaultAdminGroupID, + Name: "Administrators", + Permissions: DefaultAdminPermissions(), } mockStore.On("GetUserByID", ctx, "admin-123").Return(adminUser, nil).Once() + mockStore.On("GetGroup", ctx, DefaultAdminGroupID).Return(adminGrp, nil).Once() authCtx, err := service.BuildAuthContext(ctx, "admin-123") require.NoError(t, err) @@ -388,7 +435,7 @@ func TestService_BuildAuthContext(t *testing.T) { assert.Equal(t, adminUser, authCtx.User) assert.Len(t, authCtx.Permissions, 1) assert.Equal(t, ActionAdmin, authCtx.Permissions[0].Action) - assert.Empty(t, authCtx.AllowedAccounts) // No group restrictions + assert.Empty(t, authCtx.AllowedAccounts) // No group account restrictions mockStore.AssertExpectations(t) }) @@ -401,7 +448,6 @@ func TestService_BuildAuthContext(t *testing.T) { user := &User{ ID: "user-123", Email: "user@example.com", - Role: RoleUser, GroupIDs: []string{"group-1", "group-2"}, } @@ -437,10 +483,8 @@ func TestService_BuildAuthContext(t *testing.T) { assert.Contains(t, authCtx.AllowedAccounts, "111111111111") assert.Contains(t, authCtx.AllowedAccounts, "222222222222") assert.Contains(t, authCtx.AllowedAccounts, "333333333333") - // 11 user perms (incl. delete:plans (PR-A #660) + update:purchases (PR-A #660) - // + cancel-own (#46) + retry-own (#47) + approve-own (#286):purchases) - // + 1 group1 + 1 group2 = 13 - assert.Len(t, authCtx.Permissions, 13) + // Permissions derive purely from group membership: 1 group1 + 1 group2 = 2. + assert.Len(t, authCtx.Permissions, 2) mockStore.AssertExpectations(t) }) @@ -453,7 +497,6 @@ func TestService_BuildAuthContext(t *testing.T) { user := &User{ ID: "user-123", Email: "user@example.com", - Role: RoleUser, } mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil).Once() @@ -462,11 +505,9 @@ func TestService_BuildAuthContext(t *testing.T) { require.NoError(t, err) assert.NotNil(t, authCtx) assert.Empty(t, authCtx.AllowedAccounts) - // 6 read/plan-author + delete:plans (PR-A #660) + update:purchases (PR-A #660) - // + cancel-own:purchases (issue #46) - // + retry-own:purchases (issue #47) + approve-own:purchases - // (issue #286) = 11. Only role-based permissions. - assert.Len(t, authCtx.Permissions, 11) + // A user with no groups holds no permissions (fail closed): authz is + // group-membership-only (issue #907). + assert.Empty(t, authCtx.Permissions) mockStore.AssertExpectations(t) }) @@ -493,7 +534,6 @@ func TestService_BuildAuthContext(t *testing.T) { user := &User{ ID: "user-123", - Role: RoleUser, GroupIDs: []string{"valid-group", "missing-group"}, } @@ -520,8 +560,11 @@ func TestService_BuildAuthContext(t *testing.T) { func TestAuthContext_HasPermission(t *testing.T) { t.Run("admin has all permissions", func(t *testing.T) { + // Admin capability now derives from holding the {admin, *} permission + // (via Administrators-group membership), not a role. authCtx := &AuthContext{ - User: &User{Role: RoleAdmin}, + User: &User{GroupIDs: []string{DefaultAdminGroupID}}, + Permissions: []Permission{{Action: ActionAdmin, Resource: ResourceAll}}, } assert.True(t, authCtx.HasPermission(ActionExecute, ResourcePlans)) assert.True(t, authCtx.HasPermission(ActionView, ResourceRecommendations)) @@ -530,7 +573,7 @@ func TestAuthContext_HasPermission(t *testing.T) { t.Run("user with specific permission", func(t *testing.T) { authCtx := &AuthContext{ - User: &User{Role: RoleUser}, + User: &User{}, Permissions: []Permission{ {Action: ActionView, Resource: ResourceRecommendations}, {Action: ActionExecute, Resource: ResourcePlans}, @@ -543,7 +586,7 @@ func TestAuthContext_HasPermission(t *testing.T) { t.Run("wildcard resource permission", func(t *testing.T) { authCtx := &AuthContext{ - User: &User{Role: RoleUser}, + User: &User{}, Permissions: []Permission{ {Action: ActionView, Resource: ResourceAll}, }, @@ -556,7 +599,7 @@ func TestAuthContext_HasPermission(t *testing.T) { t.Run("admin permission grants all", func(t *testing.T) { authCtx := &AuthContext{ - User: &User{Role: RoleUser}, + User: &User{}, Permissions: []Permission{ {Action: ActionAdmin, Resource: ResourceAll}, }, @@ -570,7 +613,7 @@ func TestAuthContext_HasPermission(t *testing.T) { func TestAuthContext_CanAccessAccount(t *testing.T) { t.Run("admin can access any account", func(t *testing.T) { authCtx := &AuthContext{ - User: &User{Role: RoleAdmin}, + User: &User{}, AllowedAccounts: []string{}, } assert.True(t, authCtx.CanAccessAccount("111111111111", "")) @@ -579,7 +622,7 @@ func TestAuthContext_CanAccessAccount(t *testing.T) { t.Run("empty allowed accounts means all access", func(t *testing.T) { authCtx := &AuthContext{ - User: &User{Role: RoleUser}, + User: &User{}, AllowedAccounts: []string{}, } assert.True(t, authCtx.CanAccessAccount("111111111111", "")) @@ -588,7 +631,7 @@ func TestAuthContext_CanAccessAccount(t *testing.T) { t.Run("wildcard in allowed accounts", func(t *testing.T) { authCtx := &AuthContext{ - User: &User{Role: RoleUser}, + User: &User{}, AllowedAccounts: []string{"*"}, } assert.True(t, authCtx.CanAccessAccount("111111111111", "")) @@ -597,7 +640,7 @@ func TestAuthContext_CanAccessAccount(t *testing.T) { t.Run("specific accounts only", func(t *testing.T) { authCtx := &AuthContext{ - User: &User{Role: RoleUser}, + User: &User{}, AllowedAccounts: []string{"111111111111", "222222222222"}, } assert.True(t, authCtx.CanAccessAccount("111111111111", "")) @@ -608,7 +651,7 @@ func TestAuthContext_CanAccessAccount(t *testing.T) { t.Run("readonly user with account restrictions", func(t *testing.T) { authCtx := &AuthContext{ - User: &User{Role: RoleReadOnly}, + User: &User{}, AllowedAccounts: []string{"111111111111"}, } assert.True(t, authCtx.CanAccessAccount("111111111111", "")) @@ -617,7 +660,7 @@ func TestAuthContext_CanAccessAccount(t *testing.T) { t.Run("match by account name", func(t *testing.T) { authCtx := &AuthContext{ - User: &User{Role: RoleUser}, + User: &User{}, AllowedAccounts: []string{"Production", "Staging"}, } // UUID doesn't match, name does @@ -631,7 +674,7 @@ func TestAuthContext_CanAccessAccount(t *testing.T) { t.Run("mixed UUID and name entries", func(t *testing.T) { authCtx := &AuthContext{ - User: &User{Role: RoleUser}, + User: &User{}, AllowedAccounts: []string{"uuid-prod", "Staging"}, } assert.True(t, authCtx.CanAccessAccount("uuid-prod", "Production")) @@ -641,7 +684,7 @@ func TestAuthContext_CanAccessAccount(t *testing.T) { t.Run("wildcard combined with specific names", func(t *testing.T) { authCtx := &AuthContext{ - User: &User{Role: RoleUser}, + User: &User{}, AllowedAccounts: []string{"*", "Production"}, } // Wildcard wins — everything matches @@ -697,7 +740,6 @@ func TestService_HasPermission_Constraints(t *testing.T) { user := &User{ ID: "user-123", - Role: RoleUser, GroupIDs: []string{"group-1"}, } @@ -735,7 +777,6 @@ func TestService_HasPermission_Constraints(t *testing.T) { user := &User{ ID: "user-123", - Role: RoleUser, GroupIDs: []string{"group-1"}, } @@ -773,7 +814,6 @@ func TestService_HasPermission_Constraints(t *testing.T) { user := &User{ ID: "user-123", - Role: RoleUser, GroupIDs: []string{"group-1"}, } @@ -810,7 +850,6 @@ func TestService_HasPermission_Constraints(t *testing.T) { user := &User{ ID: "user-123", - Role: RoleUser, GroupIDs: []string{"group-1"}, } @@ -847,7 +886,6 @@ func TestService_HasPermission_Constraints(t *testing.T) { user := &User{ ID: "user-123", - Role: RoleUser, GroupIDs: []string{"group-1"}, } @@ -884,7 +922,6 @@ func TestService_HasPermission_Constraints(t *testing.T) { user := &User{ ID: "user-123", - Role: RoleUser, GroupIDs: []string{"group-1"}, } @@ -921,7 +958,6 @@ func TestService_HasPermission_Constraints(t *testing.T) { user := &User{ ID: "user-123", - Role: RoleUser, GroupIDs: []string{"group-1"}, } @@ -959,7 +995,6 @@ func TestService_HasPermission_Constraints(t *testing.T) { user := &User{ ID: "user-123", - Role: RoleUser, GroupIDs: []string{"group-1"}, } @@ -997,7 +1032,6 @@ func TestService_HasPermission_Constraints(t *testing.T) { user := &User{ ID: "user-123", - Role: RoleUser, GroupIDs: []string{"group-1"}, } @@ -1032,8 +1066,7 @@ func TestService_HasPermission_Constraints(t *testing.T) { service := createTestService(mockStore, mockEmail) user := &User{ - ID: "user-123", - Role: RoleReadOnly, + ID: "user-123", } mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil).Once() @@ -1053,7 +1086,6 @@ func TestService_HasPermission_Constraints(t *testing.T) { user := &User{ ID: "user-123", - Role: RoleUser, GroupIDs: []string{"group-1"}, } diff --git a/internal/auth/service_helpers.go b/internal/auth/service_helpers.go index 26de372e7..5900279e8 100644 --- a/internal/auth/service_helpers.go +++ b/internal/auth/service_helpers.go @@ -39,7 +39,6 @@ func (s *Service) createSession(ctx context.Context, user *User, userAgent, ipAd Token: hashedToken, // Store the hash, not the raw token UserID: user.ID, Email: user.Email, - Role: user.Role, ExpiresAt: time.Now().Add(s.sessionDuration), CreatedAt: time.Now(), UserAgent: userAgent, @@ -56,7 +55,6 @@ func (s *Service) createSession(ctx context.Context, user *User, userAgent, ipAd Token: rawToken, // Client gets the raw token UserID: user.ID, Email: user.Email, - Role: user.Role, ExpiresAt: storedSession.ExpiresAt, CreatedAt: storedSession.CreatedAt, UserAgent: userAgent, diff --git a/internal/auth/service_lockout_test.go b/internal/auth/service_lockout_test.go index 91e1f44af..71fb3092f 100644 --- a/internal/auth/service_lockout_test.go +++ b/internal/auth/service_lockout_test.go @@ -245,7 +245,6 @@ func TestLogin_AccountLockout_MFAFailure(t *testing.T) { Active: true, MFAEnabled: true, MFASecret: "JBSWY3DPEHPK3PXP", - Role: RoleUser, FailedLoginAttempts: MaxFailedLoginAttempts - 1, } diff --git a/internal/auth/service_password_callback_test.go b/internal/auth/service_password_callback_test.go index 83d44595e..b3d649370 100644 --- a/internal/auth/service_password_callback_test.go +++ b/internal/auth/service_password_callback_test.go @@ -120,7 +120,6 @@ func TestService_OnPasswordChange_ConfirmPasswordReset(t *testing.T) { ID: "user-456", Email: "admin@example.com", PasswordHash: "", // First password set - Role: RoleAdmin, Active: false, PasswordResetToken: hashSessionToken("valid-token"), PasswordResetExpiry: &expiry, diff --git a/internal/auth/service_test.go b/internal/auth/service_test.go index bc6e4fc6e..107dae526 100644 --- a/internal/auth/service_test.go +++ b/internal/auth/service_test.go @@ -138,7 +138,6 @@ func TestService_ValidateSession(t *testing.T) { Token: hashedToken, UserID: "user-123", Email: "test@example.com", - Role: RoleUser, ExpiresAt: time.Now().Add(time.Hour), } @@ -357,7 +356,6 @@ func TestLogin_WithMFA(t *testing.T) { Active: true, MFAEnabled: true, MFASecret: mfaSecret, - Role: RoleUser, } mockStore.On("GetUserByEmail", ctx, "mfa@example.com").Return(user, nil) @@ -393,7 +391,6 @@ func TestLogin_WithMFA_InvalidCode(t *testing.T) { Active: true, MFAEnabled: true, MFASecret: "JBSWY3DPEHPK3PXP", - Role: RoleUser, } mockStore.On("GetUserByEmail", ctx, "mfa@example.com").Return(user, nil) @@ -429,7 +426,6 @@ func TestLogin_WithMFA_MissingCode(t *testing.T) { Active: true, MFAEnabled: true, MFASecret: "JBSWY3DPEHPK3PXP", - Role: RoleUser, } mockStore.On("GetUserByEmail", ctx, "mfa@example.com").Return(user, nil) @@ -463,7 +459,6 @@ func TestLogin_WithMFA_NoSecret(t *testing.T) { Active: true, MFAEnabled: true, MFASecret: "", // No secret configured - Role: RoleUser, } mockStore.On("GetUserByEmail", ctx, "mfa@example.com").Return(user, nil) @@ -512,6 +507,10 @@ func TestService_ErrorPaths(t *testing.T) { mockEmail := new(MockEmailSender) service := createTestService(mockStore, mockEmail) + // Non-admin user: the last-admin guard is skipped (no Administrators + // group membership), so deletion proceeds past the GetUserByID lookup. + mockStore.On("GetUserByID", ctx, "user-123"). + Return(&User{ID: "user-123", GroupIDs: []string{"group-1"}}, nil).Once() mockStore.On("DeleteUserSessions", ctx, "user-123").Return(fmt.Errorf("session cleanup error")).Once() mockStore.On("DeleteUser", ctx, "user-123").Return(nil).Once() @@ -651,7 +650,6 @@ func TestService_ErrorPaths(t *testing.T) { user := &User{ ID: "user-123", - Role: RoleUser, GroupIDs: []string{"group-1"}, } @@ -660,12 +658,11 @@ func TestService_ErrorPaths(t *testing.T) { permissions, err := service.GetUserPermissions(ctx, "user-123") require.NoError(t, err) - // Should still return user permissions even if group fetch fails. - // 11 = 6 read/plan-author + delete:plans (PR-A #660) - // + update:purchases (PR-A #660) - // + cancel-own:purchases (issue #46) - // + retry-own:purchases (issue #47) + approve-own:purchases (issue #286). - assert.Len(t, permissions, 11) + // A failing group fetch is logged and skipped rather than aborting the + // whole resolution (so a partially broken group set still yields the + // other groups' permissions). Here the sole group errored and there is + // no role fallback, so the effective permission set is empty. + assert.Empty(t, permissions) mockStore.AssertExpectations(t) }) diff --git a/internal/auth/service_user.go b/internal/auth/service_user.go index 660f4ab4e..bd7ccec23 100644 --- a/internal/auth/service_user.go +++ b/internal/auth/service_user.go @@ -48,7 +48,6 @@ func (s *Service) SetupAdmin(ctx context.Context, req SetupAdminRequest) (*Login Email: req.Email, PasswordHash: passwordHash, Salt: "", // Not used anymore, but kept for backward compatibility - Role: RoleAdmin, GroupIDs: []string{DefaultAdminGroupID}, CreatedAt: now, UpdatedAt: now, @@ -84,9 +83,9 @@ func (s *Service) SetupAdmin(ctx context.Context, req SetupAdminRequest) (*Login Token: session.Token, ExpiresAt: session.ExpiresAt, User: &UserInfo{ - ID: user.ID, - Email: user.Email, - Role: user.Role, + ID: user.ID, + Email: user.Email, + Groups: user.GroupIDs, }, CSRFToken: session.CSRFToken, }, nil @@ -130,10 +129,10 @@ func (s *Service) validateCreateUserRequest(ctx context.Context, req CreateUserR if existing != nil { return ErrEmailInUse } - if req.Role != RoleAdmin && req.Role != RoleUser && req.Role != RoleReadOnly { - // %w lets the API handler detect the category via errors.Is(ErrInvalidRole) - // while preserving the specific role name in the user-facing message. - return fmt.Errorf("%w: %s", ErrInvalidRole, req.Role) + // Authorization derives entirely from group membership, so a user with no + // groups can do nothing. Reject zero-group creation as a 400 (issue #907). + if len(req.GroupIDs) == 0 { + return ErrNoGroups } if req.Password == "" { return nil @@ -205,7 +204,6 @@ func (s *Service) CreateUser(ctx context.Context, req CreateUserRequest) (*Creat Email: req.Email, PasswordHash: passwordHash, Salt: "", // Not used anymore, but kept for backward compatibility - Role: req.Role, GroupIDs: req.GroupIDs, CreatedAt: now, UpdatedAt: now, @@ -233,7 +231,7 @@ func (s *Service) CreateUser(ctx context.Context, req CreateUserRequest) (*Creat if invite { s.sendInviteEmail(ctx, user, inviteToken, result) } else { - logging.Infof("User created: id=%s, role=%s", user.ID, user.Role) + logging.Infof("User created: id=%s, groups=%d", user.ID, len(user.GroupIDs)) } return result, nil @@ -270,11 +268,17 @@ func (s *Service) sendInviteEmail(ctx context.Context, user *User, inviteToken s result.InviteEmailError = err.Error() logging.Errorf("Failed to send user invite email: %v", err) } - logging.Infof("User invited: id=%s, role=%s, email_sent=%t", user.ID, user.Role, sent) + logging.Infof("User invited: id=%s, groups=%d, email_sent=%t", user.ID, len(user.GroupIDs), sent) } -// UpdateUser updates user details (admin only) -func (s *Service) UpdateUser(ctx context.Context, userID string, req UpdateUserRequest) (*User, error) { +// UpdateUser updates user details (requires manage-users permission). +// +// actorUserID is the authenticated user performing the change (from the +// session, never client-supplied). It is used to enforce the self-escalation +// guard: a user may not add a group they are not already a member of unless +// they hold the manage-users permission. Pass "" for trusted internal callers +// (e.g. the stateless admin API key) that have already been authorised. +func (s *Service) UpdateUser(ctx context.Context, actorUserID, userID string, req UpdateUserRequest) (*User, error) { user, err := s.store.GetUserByID(ctx, userID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -286,10 +290,20 @@ func (s *Service) UpdateUser(ctx context.Context, userID string, req UpdateUserR return nil, fmt.Errorf("user not found") } + // Snapshot the prior membership before mutating so the guards below can + // reason about what is being added/removed. + priorGroups := append([]string(nil), user.GroupIDs...) + if err := applyUpdateUserRequest(user, req); err != nil { return nil, err } + if req.GroupIDs != nil { + if err := s.guardGroupChange(ctx, actorUserID, userID, priorGroups, req.GroupIDs); err != nil { + return nil, err + } + } + // Email is mutated through updateUserEmail rather than applyUpdateUserRequest // because it requires a DB lookup (uniqueness check) and format validation // (same rules as the self-edit profile path; see updateUserEmail and #868 @@ -307,14 +321,65 @@ func (s *Service) UpdateUser(ctx context.Context, userID string, req UpdateUserR return user, nil } -// applyUpdateUserRequest applies the non-nil fields of req to user, validating as needed. -func applyUpdateUserRequest(user *User, req UpdateUserRequest) error { - if req.Role != nil { - if *req.Role != RoleAdmin && *req.Role != RoleUser && *req.Role != RoleReadOnly { - return fmt.Errorf("invalid role: %s", *req.Role) +// guardGroupChange enforces the issue #907 invariants for a group-membership +// change: at least one group remains, the last Administrators-group member is +// not removed, and a non-privileged actor cannot escalate their own access. +func (s *Service) guardGroupChange(ctx context.Context, actorUserID, targetUserID string, prior, next []string) error { + if len(next) == 0 { + return ErrNoGroups + } + + // Last-admin protection: if this change removes the Administrators group + // from a user who currently has it, ensure at least one other member + // keeps it. + if containsGroup(prior, DefaultAdminGroupID) && !containsGroup(next, DefaultAdminGroupID) { + count, err := s.store.CountGroupMembers(ctx, DefaultAdminGroupID) + if err != nil { + return fmt.Errorf("failed to count administrators: %w", err) + } + if count <= 1 { + return ErrLastAdmin + } + } + + // Self-escalation guard: when the actor is editing their own membership, + // any group being ADDED that they did not already have requires the actor + // to hold the manage-users permission. A non-privileged user therefore + // cannot grant themselves a more powerful group. Internal callers + // (actorUserID == "") are already trusted and skip this check. + if actorUserID != "" && actorUserID == targetUserID && addsNewGroup(prior, next) { + canManage, err := s.HasPermission(ctx, actorUserID, ActionUpdate, ResourceUsers, nil) + if err != nil { + return fmt.Errorf("failed to verify manage-users permission: %w", err) + } + if !canManage { + return ErrSelfEscalation } - user.Role = *req.Role } + return nil +} + +func containsGroup(groups []string, target string) bool { + for _, g := range groups { + if g == target { + return true + } + } + return false +} + +// addsNewGroup reports whether next contains any group not present in prior. +func addsNewGroup(prior, next []string) bool { + for _, g := range next { + if !containsGroup(prior, g) { + return true + } + } + return false +} + +// applyUpdateUserRequest applies the non-nil fields of req to user, validating as needed. +func applyUpdateUserRequest(user *User, req UpdateUserRequest) error { if req.GroupIDs != nil { user.GroupIDs = req.GroupIDs } @@ -324,8 +389,31 @@ func applyUpdateUserRequest(user *User, req UpdateUserRequest) error { return nil } -// DeleteUser removes a user (admin only) +// DeleteUser removes a user (requires manage-users permission). Refuses to +// delete the last remaining Administrators-group member so the deployment can +// never be locked out of admin functionality (issue #907). func (s *Service) DeleteUser(ctx context.Context, userID string) error { + user, err := s.store.GetUserByID(ctx, userID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("user not found") + } + return err + } + if user == nil { + return fmt.Errorf("user not found") + } + + if containsGroup(user.GroupIDs, DefaultAdminGroupID) { + count, err := s.store.CountGroupMembers(ctx, DefaultAdminGroupID) + if err != nil { + return fmt.Errorf("failed to count administrators: %w", err) + } + if count <= 1 { + return ErrLastAdmin + } + } + // Delete all user sessions if err := s.store.DeleteUserSessions(ctx, userID); err != nil { logging.Warnf("Failed to delete user sessions: %v", err) diff --git a/internal/auth/service_user_test.go b/internal/auth/service_user_test.go index b53528635..2f3721e52 100644 --- a/internal/auth/service_user_test.go +++ b/internal/auth/service_user_test.go @@ -40,7 +40,7 @@ func TestService_SetupAdmin(t *testing.T) { assert.NotNil(t, resp) assert.NotEmpty(t, resp.Token) assert.Equal(t, "admin@example.com", resp.User.Email) - assert.Equal(t, RoleAdmin, resp.User.Role) + assert.Equal(t, []string{DefaultAdminGroupID}, resp.User.Groups) // Verify the admin user was auto-assigned to the Administrators group. require.NotNil(t, capturedUser) @@ -123,7 +123,7 @@ func TestService_CreateUser(t *testing.T) { req := CreateUserRequest{ Email: "newuser@example.com", Password: "SecurePass@123", - Role: RoleUser, + GroupIDs: []string{DefaultAdminGroupID}, } result, err := service.CreateUser(ctx, req) @@ -131,7 +131,7 @@ func TestService_CreateUser(t *testing.T) { require.NotNil(t, result) require.NotNil(t, result.User) assert.Equal(t, "newuser@example.com", result.User.Email) - assert.Equal(t, RoleUser, result.User.Role) + assert.Equal(t, []string{DefaultAdminGroupID}, result.User.GroupIDs) assert.True(t, result.User.Active) // Non-invite path: no invite-email status. assert.Nil(t, result.InviteEmailSent) @@ -154,7 +154,7 @@ func TestService_CreateUser(t *testing.T) { req := CreateUserRequest{ Email: "existing@example.com", Password: "SecurePass@123", - Role: RoleUser, + GroupIDs: []string{DefaultAdminGroupID}, } result, err := service.CreateUser(ctx, req) @@ -165,23 +165,25 @@ func TestService_CreateUser(t *testing.T) { mockStore.AssertExpectations(t) }) - t.Run("invalid role", func(t *testing.T) { + t.Run("zero groups rejected", func(t *testing.T) { mockStore := new(MockStore) mockEmail := new(MockEmailSender) service := createTestService(mockStore, mockEmail) mockStore.On("GetUserByEmail", ctx, "newuser@example.com").Return(nil, nil).Once() + // Authorization is group-membership-only (issue #907): a user with no + // groups can do nothing, so creation must be rejected. req := CreateUserRequest{ Email: "newuser@example.com", Password: "SecurePass@123", - Role: "invalid-role", + GroupIDs: nil, } result, err := service.CreateUser(ctx, req) assert.Error(t, err) assert.Nil(t, result) - assert.Contains(t, err.Error(), "invalid role") + assert.ErrorIs(t, err, ErrNoGroups) mockStore.AssertExpectations(t) }) @@ -196,7 +198,7 @@ func TestService_CreateUser(t *testing.T) { req := CreateUserRequest{ Email: "newuser@example.com", Password: "SecurePass@123", - Role: RoleUser, + GroupIDs: []string{DefaultAdminGroupID}, } result, err := service.CreateUser(ctx, req) @@ -217,7 +219,7 @@ func TestService_CreateUser(t *testing.T) { req := CreateUserRequest{ Email: "newuser@example.com", Password: "SecurePass@123", - Role: RoleUser, + GroupIDs: []string{DefaultAdminGroupID}, } result, err := service.CreateUser(ctx, req) @@ -242,8 +244,8 @@ func TestService_CreateUser(t *testing.T) { Return(nil).Once() req := CreateUserRequest{ - Email: "invitee@example.com", - Role: RoleUser, + Email: "invitee@example.com", + GroupIDs: []string{DefaultAdminGroupID}, // Password intentionally empty — admin is inviting the user. } @@ -285,8 +287,8 @@ func TestService_CreateUser(t *testing.T) { Return(assert.AnError).Once() req := CreateUserRequest{ - Email: "invitee@example.com", - Role: RoleUser, + Email: "invitee@example.com", + GroupIDs: []string{DefaultAdminGroupID}, } result, err := service.CreateUser(ctx, req) @@ -315,6 +317,9 @@ func TestService_DeleteUser(t *testing.T) { mockEmail := new(MockEmailSender) service := createTestService(mockStore, mockEmail) + // Non-admin user: last-admin guard is skipped, so deletion proceeds. + mockStore.On("GetUserByID", ctx, "user-123"). + Return(&User{ID: "user-123", GroupIDs: []string{"group-1"}}, nil).Once() mockStore.On("DeleteUserSessions", ctx, "user-123").Return(nil).Once() mockStore.On("DeleteUser", ctx, "user-123").Return(nil).Once() @@ -357,9 +362,9 @@ func TestService_GetUser(t *testing.T) { service := createTestService(mockStore, mockEmail) testUser := &User{ - ID: "user-123", - Email: "test@example.com", - Role: RoleUser, + ID: "user-123", + Email: "test@example.com", + GroupIDs: []string{DefaultAdminGroupID}, } mockStore.On("GetUserByID", ctx, "user-123").Return(testUser, nil).Once() @@ -422,32 +427,6 @@ func TestService_CheckAdminExists(t *testing.T) { func TestService_UpdateUser(t *testing.T) { ctx := context.Background() - t.Run("update role successfully", func(t *testing.T) { - mockStore := new(MockStore) - mockEmail := new(MockEmailSender) - service := createTestService(mockStore, mockEmail) - - existingUser := &User{ - ID: "user-123", - Email: "test@example.com", - Role: RoleUser, - } - - mockStore.On("GetUserByID", ctx, "user-123").Return(existingUser, nil).Once() - mockStore.On("UpdateUser", ctx, mock.AnythingOfType("*auth.User")).Return(nil).Once() - - newRole := RoleAdmin - req := UpdateUserRequest{ - Role: &newRole, - } - - user, err := service.UpdateUser(ctx, "user-123", req) - require.NoError(t, err) - assert.Equal(t, RoleAdmin, user.Role) - - mockStore.AssertExpectations(t) - }) - t.Run("update groupIDs successfully", func(t *testing.T) { mockStore := new(MockStore) mockEmail := new(MockEmailSender) @@ -456,7 +435,6 @@ func TestService_UpdateUser(t *testing.T) { existingUser := &User{ ID: "user-123", Email: "test@example.com", - Role: RoleUser, GroupIDs: []string{"group-1"}, } @@ -467,7 +445,10 @@ func TestService_UpdateUser(t *testing.T) { GroupIDs: []string{"group-2", "group-3"}, } - user, err := service.UpdateUser(ctx, "user-123", req) + // Internal/admin caller (actorUserID == "") skips the self-escalation + // guard; the group change is neither emptying nor removing the last + // admin, so it succeeds. + user, err := service.UpdateUser(ctx, "", "user-123", req) require.NoError(t, err) assert.Equal(t, []string{"group-2", "group-3"}, user.GroupIDs) @@ -480,10 +461,10 @@ func TestService_UpdateUser(t *testing.T) { service := createTestService(mockStore, mockEmail) existingUser := &User{ - ID: "user-123", - Email: "test@example.com", - Role: RoleUser, - Active: true, + ID: "user-123", + Email: "test@example.com", + GroupIDs: []string{"group-1"}, + Active: true, } mockStore.On("GetUserByID", ctx, "user-123").Return(existingUser, nil).Once() @@ -494,35 +475,36 @@ func TestService_UpdateUser(t *testing.T) { Active: &inactive, } - user, err := service.UpdateUser(ctx, "user-123", req) + user, err := service.UpdateUser(ctx, "", "user-123", req) require.NoError(t, err) assert.False(t, user.Active) mockStore.AssertExpectations(t) }) - t.Run("update with invalid role", func(t *testing.T) { + t.Run("empty groups rejected", func(t *testing.T) { mockStore := new(MockStore) mockEmail := new(MockEmailSender) service := createTestService(mockStore, mockEmail) existingUser := &User{ - ID: "user-123", - Email: "test@example.com", - Role: RoleUser, + ID: "user-123", + Email: "test@example.com", + GroupIDs: []string{"group-1"}, } mockStore.On("GetUserByID", ctx, "user-123").Return(existingUser, nil).Once() - invalidRole := "superadmin" + // A non-nil but empty GroupIDs would leave a zero-group user, which + // authorization-as-group-membership (issue #907) forbids. req := UpdateUserRequest{ - Role: &invalidRole, + GroupIDs: []string{}, } - user, err := service.UpdateUser(ctx, "user-123", req) + user, err := service.UpdateUser(ctx, "", "user-123", req) assert.Error(t, err) assert.Nil(t, user) - assert.Contains(t, err.Error(), "invalid role") + assert.ErrorIs(t, err, ErrNoGroups) mockStore.AssertExpectations(t) }) @@ -534,12 +516,11 @@ func TestService_UpdateUser(t *testing.T) { mockStore.On("GetUserByID", ctx, "nonexistent").Return(nil, nil).Once() - newRole := RoleAdmin req := UpdateUserRequest{ - Role: &newRole, + GroupIDs: []string{"group-1"}, } - user, err := service.UpdateUser(ctx, "nonexistent", req) + user, err := service.UpdateUser(ctx, "", "nonexistent", req) assert.Error(t, err) assert.Nil(t, user) assert.Contains(t, err.Error(), "user not found") @@ -555,7 +536,6 @@ func TestService_UpdateUser(t *testing.T) { existingUser := &User{ ID: "user-123", Email: "test@example.com", - Role: RoleUser, Active: true, GroupIDs: []string{"group-1"}, } @@ -563,17 +543,14 @@ func TestService_UpdateUser(t *testing.T) { mockStore.On("GetUserByID", ctx, "user-123").Return(existingUser, nil).Once() mockStore.On("UpdateUser", ctx, mock.AnythingOfType("*auth.User")).Return(nil).Once() - newRole := RoleReadOnly active := false req := UpdateUserRequest{ - Role: &newRole, Active: &active, GroupIDs: []string{"group-2"}, } - user, err := service.UpdateUser(ctx, "user-123", req) + user, err := service.UpdateUser(ctx, "", "user-123", req) require.NoError(t, err) - assert.Equal(t, RoleReadOnly, user.Role) assert.False(t, user.Active) assert.Equal(t, []string{"group-2"}, user.GroupIDs) @@ -592,7 +569,7 @@ func TestService_CreateUser_EdgeCases(t *testing.T) { req := CreateUserRequest{ Email: "not-an-email", Password: "SecurePass@123", - Role: RoleUser, + GroupIDs: []string{DefaultAdminGroupID}, } result, err := service.CreateUser(ctx, req) @@ -611,7 +588,7 @@ func TestService_CreateUser_EdgeCases(t *testing.T) { req := CreateUserRequest{ Email: "newuser@example.com", Password: "weak", - Role: RoleUser, + GroupIDs: []string{DefaultAdminGroupID}, } result, err := service.CreateUser(ctx, req) @@ -632,7 +609,6 @@ func TestService_CreateUser_EdgeCases(t *testing.T) { req := CreateUserRequest{ Email: "newuser@example.com", Password: "SecurePass@123", - Role: RoleUser, GroupIDs: []string{"group-1", "group-2"}, } @@ -656,14 +632,14 @@ func TestService_CreateUser_EdgeCases(t *testing.T) { req := CreateUserRequest{ Email: "admin@example.com", Password: "SecurePass@123", - Role: RoleAdmin, + GroupIDs: []string{DefaultAdminGroupID}, } result, err := service.CreateUser(ctx, req) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.User) - assert.Equal(t, RoleAdmin, result.User.Role) + assert.Equal(t, []string{DefaultAdminGroupID}, result.User.GroupIDs) mockStore.AssertExpectations(t) }) @@ -679,14 +655,14 @@ func TestService_CreateUser_EdgeCases(t *testing.T) { req := CreateUserRequest{ Email: "readonly@example.com", Password: "SecurePass@123", - Role: RoleReadOnly, + GroupIDs: []string{"00000000-0000-5000-8000-000000000006"}, } result, err := service.CreateUser(ctx, req) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.User) - assert.Equal(t, RoleReadOnly, result.User.Role) + assert.Equal(t, []string{"00000000-0000-5000-8000-000000000006"}, result.User.GroupIDs) mockStore.AssertExpectations(t) }) @@ -786,7 +762,6 @@ func TestService_UpdateUserProfile(t *testing.T) { ID: "user-123", Email: "old@example.com", PasswordHash: string(hash), - Role: RoleUser, Active: true, CreatedAt: time.Now(), } @@ -812,7 +787,6 @@ func TestService_UpdateUserProfile(t *testing.T) { ID: "user-123", Email: "old@example.com", PasswordHash: string(hash), - Role: RoleUser, Active: true, } @@ -835,7 +809,6 @@ func TestService_UpdateUserProfile(t *testing.T) { ID: "user-123", Email: "old@example.com", PasswordHash: string(hash), - Role: RoleUser, Active: true, } @@ -858,7 +831,6 @@ func TestService_UpdateUserProfile(t *testing.T) { ID: "user-123", Email: "old@example.com", PasswordHash: string(hash), - Role: RoleUser, Active: true, } @@ -894,7 +866,6 @@ func TestService_UpdateUserProfile(t *testing.T) { ID: "user-123", Email: "old@example.com", PasswordHash: string(hash), - Role: RoleUser, Active: true, } diff --git a/internal/auth/store_postgres.go b/internal/auth/store_postgres.go index 848b79c43..8b66f3708 100644 --- a/internal/auth/store_postgres.go +++ b/internal/auth/store_postgres.go @@ -42,7 +42,7 @@ var _ StoreInterface = (*PostgresStore)(nil) // GetUserByID retrieves a user by ID func (s *PostgresStore) GetUserByID(ctx context.Context, userID string) (*User, error) { query := ` - SELECT id, email, password_hash, salt, role, group_ids, active, + SELECT id, email, password_hash, salt, group_ids, active, mfa_enabled, mfa_secret, mfa_pending_secret, mfa_pending_secret_expires_at, mfa_recovery_codes, password_reset_token, password_reset_expiry, failed_login_attempts, locked_until, password_history, @@ -57,7 +57,7 @@ func (s *PostgresStore) GetUserByID(ctx context.Context, userID string) (*User, // GetUserByEmail retrieves a user by email func (s *PostgresStore) GetUserByEmail(ctx context.Context, email string) (*User, error) { query := ` - SELECT id, email, password_hash, salt, role, group_ids, active, + SELECT id, email, password_hash, salt, group_ids, active, mfa_enabled, mfa_secret, mfa_pending_secret, mfa_pending_secret_expires_at, mfa_recovery_codes, password_reset_token, password_reset_expiry, failed_login_attempts, locked_until, password_history, @@ -87,12 +87,12 @@ func (s *PostgresStore) CreateUser(ctx context.Context, user *User) error { query := ` INSERT INTO users ( - id, email, password_hash, salt, role, group_ids, active, + id, email, password_hash, salt, group_ids, active, mfa_enabled, mfa_secret, mfa_pending_secret, mfa_pending_secret_expires_at, mfa_recovery_codes, password_reset_token, password_reset_expiry, failed_login_attempts, locked_until, password_history, created_at, updated_at, last_login_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) ` // Substitute an empty slice for nil so the Postgres TEXT[] column @@ -109,7 +109,6 @@ func (s *PostgresStore) CreateUser(ctx context.Context, user *User) error { user.Email, user.PasswordHash, user.Salt, - user.Role, user.GroupIDs, user.Active, user.MFAEnabled, @@ -170,21 +169,20 @@ func (s *PostgresStore) UpdateUser(ctx context.Context, user *User) error { email = $2, password_hash = $3, salt = $4, - role = $5, - group_ids = $6, - active = $7, - mfa_enabled = $8, - mfa_secret = $9, - mfa_pending_secret = $10, - mfa_pending_secret_expires_at = $11, - mfa_recovery_codes = $12, - password_reset_token = $13, - password_reset_expiry = $14, - failed_login_attempts = $15, - locked_until = $16, - password_history = $17, - updated_at = $18, - last_login_at = $19 + group_ids = $5, + active = $6, + mfa_enabled = $7, + mfa_secret = $8, + mfa_pending_secret = $9, + mfa_pending_secret_expires_at = $10, + mfa_recovery_codes = $11, + password_reset_token = $12, + password_reset_expiry = $13, + failed_login_attempts = $14, + locked_until = $15, + password_history = $16, + updated_at = $17, + last_login_at = $18 WHERE id = $1 ` @@ -199,7 +197,6 @@ func (s *PostgresStore) UpdateUser(ctx context.Context, user *User) error { user.Email, user.PasswordHash, user.Salt, - user.Role, user.GroupIDs, user.Active, user.MFAEnabled, @@ -248,7 +245,7 @@ func (s *PostgresStore) ListUsers(ctx context.Context) ([]User, error) { // LIMIT provides a safety cap against unbounded memory allocation on large installations. // Pagination support should be added if this limit proves insufficient. query := ` - SELECT id, email, password_hash, salt, role, group_ids, active, + SELECT id, email, password_hash, salt, group_ids, active, mfa_enabled, mfa_secret, mfa_pending_secret, mfa_pending_secret_expires_at, mfa_recovery_codes, password_reset_token, password_reset_expiry, failed_login_attempts, locked_until, password_history, @@ -287,7 +284,7 @@ func (s *PostgresStore) ListUsers(ctx context.Context) ([]User, error) { // as "used" instead of "expired" (QA bug 11.2). func (s *PostgresStore) GetUserByResetToken(ctx context.Context, token string) (*User, error) { query := ` - SELECT id, email, password_hash, salt, role, group_ids, active, + SELECT id, email, password_hash, salt, group_ids, active, mfa_enabled, mfa_secret, mfa_pending_secret, mfa_pending_secret_expires_at, mfa_recovery_codes, password_reset_token, password_reset_expiry, failed_login_attempts, locked_until, password_history, @@ -299,12 +296,19 @@ func (s *PostgresStore) GetUserByResetToken(ctx context.Context, token string) ( return s.scanUser(s.db.QueryRow(ctx, query, token)) } -// AdminExists checks if any admin user exists +// adminGroupContainsClause is the SQL predicate matching users who are members +// of the Administrators group. Authorization is group-membership-only after +// issue #907, so "is an admin" == "group_ids contains the Administrators group +// UUID". $1 must be bound to DefaultAdminGroupID. +const adminGroupContainsClause = `group_ids @> ARRAY[$1::uuid]` + +// AdminExists checks if any active Administrators-group member exists. This is +// the group-membership replacement for the former role = 'admin' check. func (s *PostgresStore) AdminExists(ctx context.Context) (bool, error) { - query := `SELECT EXISTS(SELECT 1 FROM users WHERE role = 'admin' AND active = true)` + query := `SELECT EXISTS(SELECT 1 FROM users WHERE ` + adminGroupContainsClause + ` AND active = true)` var exists bool - err := s.db.QueryRow(ctx, query).Scan(&exists) + err := s.db.QueryRow(ctx, query, DefaultAdminGroupID).Scan(&exists) if err != nil { return false, fmt.Errorf("failed to check admin existence: %w", err) } @@ -312,6 +316,18 @@ func (s *PostgresStore) AdminExists(ctx context.Context) (bool, error) { return exists, nil } +// CountGroupMembers returns the number of users whose group_ids contains +// groupID. Used to enforce last-administrator protection (issue #907). +func (s *PostgresStore) CountGroupMembers(ctx context.Context, groupID string) (int, error) { + query := `SELECT COUNT(*) FROM users WHERE group_ids @> ARRAY[$1::uuid]` + + var count int + if err := s.db.QueryRow(ctx, query, groupID).Scan(&count); err != nil { + return 0, fmt.Errorf("failed to count group members: %w", err) + } + return count, nil +} + // CreateAdminIfNone atomically inserts user as the first admin in the // system. Returns (true, nil) when the insert succeeded; (false, nil) // when an admin already existed (TOCTOU race — both callers passed @@ -333,43 +349,51 @@ func (s *PostgresStore) CreateAdminIfNone(ctx context.Context, user *User) (bool user.UpdatedAt = now // The NOT EXISTS guard matches AdminExists's semantics exactly - // (role='admin' AND active=true) so the fast-path check and the - // atomic insert agree: a deployment with only inactive admins is + // (Administrators-group member AND active=true) so the fast-path check + // and the atomic insert agree: a deployment with only inactive admins is // treated as "no admin" by both, and the insert proceeds. If they // disagreed, AdminExists could report false while this insert // failed silently on the WHERE clause, leaving the operator with // a recoverable-looking error and no admin. // - // The role column is also hard-coded to 'admin' regardless of - // user.Role — the method's contract is "create admin if none", - // and accepting a non-admin role here would silently break that - // contract (the WHERE clause would still let the insert through - // because a non-admin row doesn't trip the "admin exists" check). + // The bootstrap admin's group_ids is forced to include the Administrators + // group regardless of the caller-supplied slice — the method's contract is + // "create admin if none", and an admin row that did NOT carry the + // Administrators group would not satisfy the membership predicate, silently + // breaking that contract. query := ` INSERT INTO users ( - id, email, password_hash, salt, role, group_ids, active, + id, email, password_hash, salt, group_ids, active, mfa_enabled, mfa_secret, mfa_pending_secret, mfa_pending_secret_expires_at, mfa_recovery_codes, password_reset_token, password_reset_expiry, failed_login_attempts, locked_until, password_history, created_at, updated_at, last_login_at ) - SELECT $1, $2, $3, $4, 'admin', $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19 - WHERE NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND active = true) + SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19 + WHERE NOT EXISTS (SELECT 1 FROM users WHERE group_ids @> ARRAY[$20::uuid] AND active = true) ` - // See CreateUser for the nil-slice substitution rationale. + // See CreateUser for the nil-slice substitution rationale. Force the + // Administrators group onto the bootstrap admin so it satisfies the + // membership predicate above (deduped to avoid a doubled entry if the + // caller already supplied it). recoveryCodes := user.MFARecoveryCodes if recoveryCodes == nil { recoveryCodes = []string{} } + groupIDs := user.GroupIDs + if !containsGroup(groupIDs, DefaultAdminGroupID) { + groupIDs = append(append([]string(nil), groupIDs...), DefaultAdminGroupID) + } tag, err := s.db.Exec(ctx, query, user.ID, user.Email, user.PasswordHash, user.Salt, - user.GroupIDs, user.Active, user.MFAEnabled, user.MFASecret, + groupIDs, user.Active, user.MFAEnabled, user.MFASecret, user.MFAPendingSecret, user.MFAPendingSecretExpiresAt, recoveryCodes, user.PasswordResetToken, user.PasswordResetExpiry, user.FailedLoginAttempts, user.LockedUntil, user.PasswordHistory, user.CreatedAt, user.UpdatedAt, user.LastLoginAt, + DefaultAdminGroupID, ) if err != nil { // Email uniqueness can still fire if the bootstrap caller reuses @@ -549,16 +573,15 @@ func (s *PostgresStore) ListGroups(ctx context.Context) ([]Group, error) { func (s *PostgresStore) CreateSession(ctx context.Context, session *Session) error { query := ` INSERT INTO sessions ( - token, user_id, email, role, expires_at, created_at, + token, user_id, email, expires_at, created_at, user_agent, ip_address, csrf_token - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ` _, err := s.db.Exec(ctx, query, session.Token, session.UserID, session.Email, - session.Role, session.ExpiresAt, session.CreatedAt, session.UserAgent, @@ -576,7 +599,7 @@ func (s *PostgresStore) CreateSession(ctx context.Context, session *Session) err // GetSession retrieves a session by token func (s *PostgresStore) GetSession(ctx context.Context, token string) (*Session, error) { query := ` - SELECT token, user_id, email, role, expires_at, created_at, + SELECT token, user_id, email, expires_at, created_at, user_agent, ip_address, csrf_token FROM sessions WHERE token = $1 AND expires_at > NOW() @@ -587,7 +610,6 @@ func (s *PostgresStore) GetSession(ctx context.Context, token string) (*Session, &session.Token, &session.UserID, &session.Email, - &session.Role, &session.ExpiresAt, &session.CreatedAt, &session.UserAgent, @@ -835,7 +857,6 @@ func (s *PostgresStore) scanUser(scanner Scanner) (*User, error) { &user.Email, &user.PasswordHash, &user.Salt, - &user.Role, &groupIDs, &user.Active, &user.MFAEnabled, diff --git a/internal/auth/store_postgres_test.go b/internal/auth/store_postgres_test.go index 0bc73f311..f6b0ae937 100644 --- a/internal/auth/store_postgres_test.go +++ b/internal/auth/store_postgres_test.go @@ -57,8 +57,9 @@ func (m *MockRow) Scan(dest ...interface{}) error { } // Helper function to create a mock row that returns a user -// The scan order matches scanUser() in store_postgres.go: -// id, email, password_hash, salt, role, group_ids, active, +// The scan order matches scanUser() in store_postgres.go (issue #907 removed +// the role column, so there are now 19 destinations, not 20): +// id, email, password_hash, salt, group_ids, active, // mfa_enabled, mfa_secret (NullString), mfa_pending_secret (NullString), // mfa_pending_secret_expires_at (NullTime), mfa_recovery_codes ([]string), // reset_token (NullString), reset_expiry (NullTime), @@ -68,62 +69,61 @@ func createMockRowWithUser(user *User) *MockRow { return &MockRow{ scanFunc: func(dest ...interface{}) error { // Populate destination pointers with user data - if len(dest) >= 20 { + if len(dest) >= 19 { *dest[0].(*string) = user.ID *dest[1].(*string) = user.Email *dest[2].(*string) = user.PasswordHash *dest[3].(*string) = user.Salt - *dest[4].(*string) = user.Role - *dest[5].(*[]string) = user.GroupIDs - *dest[6].(*bool) = user.Active - *dest[7].(*bool) = user.MFAEnabled - // dest[8] is sql.NullString for MFASecret + *dest[4].(*[]string) = user.GroupIDs + *dest[5].(*bool) = user.Active + *dest[6].(*bool) = user.MFAEnabled + // dest[7] is sql.NullString for MFASecret if user.MFASecret != "" { - *dest[8].(*sql.NullString) = sql.NullString{String: user.MFASecret, Valid: true} + *dest[7].(*sql.NullString) = sql.NullString{String: user.MFASecret, Valid: true} } else { - *dest[8].(*sql.NullString) = sql.NullString{Valid: false} + *dest[7].(*sql.NullString) = sql.NullString{Valid: false} } - // dest[9] is sql.NullString for MFAPendingSecret + // dest[8] is sql.NullString for MFAPendingSecret if user.MFAPendingSecret != "" { - *dest[9].(*sql.NullString) = sql.NullString{String: user.MFAPendingSecret, Valid: true} + *dest[8].(*sql.NullString) = sql.NullString{String: user.MFAPendingSecret, Valid: true} } else { - *dest[9].(*sql.NullString) = sql.NullString{Valid: false} + *dest[8].(*sql.NullString) = sql.NullString{Valid: false} } - // dest[10] is sql.NullTime for MFAPendingSecretExpiresAt + // dest[9] is sql.NullTime for MFAPendingSecretExpiresAt if user.MFAPendingSecretExpiresAt != nil { - *dest[10].(*sql.NullTime) = sql.NullTime{Time: *user.MFAPendingSecretExpiresAt, Valid: true} + *dest[9].(*sql.NullTime) = sql.NullTime{Time: *user.MFAPendingSecretExpiresAt, Valid: true} } else { - *dest[10].(*sql.NullTime) = sql.NullTime{Valid: false} + *dest[9].(*sql.NullTime) = sql.NullTime{Valid: false} } - // dest[11] is []string for MFARecoveryCodes - *dest[11].(*[]string) = user.MFARecoveryCodes - // dest[12] is sql.NullString for PasswordResetToken + // dest[10] is []string for MFARecoveryCodes + *dest[10].(*[]string) = user.MFARecoveryCodes + // dest[11] is sql.NullString for PasswordResetToken if user.PasswordResetToken != "" { - *dest[12].(*sql.NullString) = sql.NullString{String: user.PasswordResetToken, Valid: true} + *dest[11].(*sql.NullString) = sql.NullString{String: user.PasswordResetToken, Valid: true} } else { - *dest[12].(*sql.NullString) = sql.NullString{Valid: false} + *dest[11].(*sql.NullString) = sql.NullString{Valid: false} } - // dest[13] is sql.NullTime for PasswordResetExpiry + // dest[12] is sql.NullTime for PasswordResetExpiry if user.PasswordResetExpiry != nil { - *dest[13].(*sql.NullTime) = sql.NullTime{Time: *user.PasswordResetExpiry, Valid: true} + *dest[12].(*sql.NullTime) = sql.NullTime{Time: *user.PasswordResetExpiry, Valid: true} } else { - *dest[13].(*sql.NullTime) = sql.NullTime{Valid: false} + *dest[12].(*sql.NullTime) = sql.NullTime{Valid: false} } - *dest[14].(*int) = user.FailedLoginAttempts - // dest[15] is sql.NullTime for LockedUntil + *dest[13].(*int) = user.FailedLoginAttempts + // dest[14] is sql.NullTime for LockedUntil if user.LockedUntil != nil { - *dest[15].(*sql.NullTime) = sql.NullTime{Time: *user.LockedUntil, Valid: true} + *dest[14].(*sql.NullTime) = sql.NullTime{Time: *user.LockedUntil, Valid: true} } else { - *dest[15].(*sql.NullTime) = sql.NullTime{Valid: false} + *dest[14].(*sql.NullTime) = sql.NullTime{Valid: false} } - *dest[16].(*[]string) = user.PasswordHistory - *dest[17].(*time.Time) = user.CreatedAt - *dest[18].(*time.Time) = user.UpdatedAt - // dest[19] is sql.NullTime for LastLoginAt + *dest[15].(*[]string) = user.PasswordHistory + *dest[16].(*time.Time) = user.CreatedAt + *dest[17].(*time.Time) = user.UpdatedAt + // dest[18] is sql.NullTime for LastLoginAt if user.LastLoginAt != nil { - *dest[19].(*sql.NullTime) = sql.NullTime{Time: *user.LastLoginAt, Valid: true} + *dest[18].(*sql.NullTime) = sql.NullTime{Time: *user.LastLoginAt, Valid: true} } else { - *dest[19].(*sql.NullTime) = sql.NullTime{Valid: false} + *dest[18].(*sql.NullTime) = sql.NullTime{Valid: false} } } return nil @@ -150,7 +150,6 @@ func TestPostgresStore_GetUserByID(t *testing.T) { expectedUser := &User{ ID: "user-123", Email: "test@example.com", - Role: RoleUser, Active: true, GroupIDs: []string{}, } @@ -191,7 +190,6 @@ func TestPostgresStore_GetUserByEmail(t *testing.T) { expectedUser := &User{ ID: "user-123", Email: "test@example.com", - Role: RoleUser, Active: true, GroupIDs: []string{}, } @@ -231,7 +229,6 @@ func TestPostgresStore_CreateUser(t *testing.T) { user := &User{ Email: "new@example.com", PasswordHash: "hash123", - Role: RoleUser, Active: true, GroupIDs: []string{}, } @@ -252,7 +249,6 @@ func TestPostgresStore_CreateUser(t *testing.T) { user := &User{ Email: "new@example.com", - Role: RoleUser, GroupIDs: []string{}, } @@ -275,7 +271,6 @@ func TestPostgresStore_UpdateUser(t *testing.T) { user := &User{ ID: "user-123", Email: "updated@example.com", - Role: RoleAdmin, GroupIDs: []string{}, } @@ -372,7 +367,6 @@ func TestPostgresStore_GetUserByResetToken(t *testing.T) { expectedUser := &User{ ID: "user-123", Email: "test@example.com", - Role: RoleUser, Active: true, GroupIDs: []string{}, PasswordResetToken: "reset-token-123", @@ -418,8 +412,8 @@ func TestPostgresStore_AdminExists(t *testing.T) { return nil }, } - // AdminExists calls QueryRow without extra args, so args slice is empty - mockDB.On("QueryRow", ctx, mock.AnythingOfType("string"), []interface{}(nil)).Return(mockRow) + // AdminExists binds the Administrators-group UUID as $1 (issue #907). + mockDB.On("QueryRow", ctx, mock.AnythingOfType("string"), []interface{}{DefaultAdminGroupID}).Return(mockRow) exists, err := store.AdminExists(ctx) require.NoError(t, err) @@ -438,7 +432,7 @@ func TestPostgresStore_AdminExists(t *testing.T) { return nil }, } - mockDB.On("QueryRow", ctx, mock.AnythingOfType("string"), []interface{}(nil)).Return(mockRow) + mockDB.On("QueryRow", ctx, mock.AnythingOfType("string"), []interface{}{DefaultAdminGroupID}).Return(mockRow) exists, err := store.AdminExists(ctx) require.NoError(t, err) @@ -452,7 +446,7 @@ func TestPostgresStore_AdminExists(t *testing.T) { store := &PostgresStore{db: mockDB} mockRow := createMockRowWithError(fmt.Errorf("database error")) - mockDB.On("QueryRow", ctx, mock.AnythingOfType("string"), []interface{}(nil)).Return(mockRow) + mockDB.On("QueryRow", ctx, mock.AnythingOfType("string"), []interface{}{DefaultAdminGroupID}).Return(mockRow) exists, err := store.AdminExists(ctx) assert.Error(t, err) @@ -500,7 +494,6 @@ func TestPostgresStore_CreateSession(t *testing.T) { Token: "session-token-123", UserID: "user-123", Email: "test@example.com", - Role: RoleUser, ExpiresAt: time.Now().Add(24 * time.Hour), CreatedAt: time.Now(), UserAgent: "Mozilla/5.0", @@ -545,7 +538,6 @@ func TestPostgresStore_GetSession(t *testing.T) { Token: "session-token-123", UserID: "user-123", Email: "test@example.com", - Role: RoleUser, ExpiresAt: time.Now().Add(24 * time.Hour), CreatedAt: time.Now(), UserAgent: "Mozilla/5.0", @@ -553,17 +545,19 @@ func TestPostgresStore_GetSession(t *testing.T) { CSRFToken: "csrf-token-123", } + // Session scan order matches store_postgres.go after issue #907 removed + // the role column: token, user_id, email, expires_at, created_at, + // user_agent, ip_address, csrf_token (8 destinations). mockRow := &MockRow{ scanFunc: func(dest ...interface{}) error { *dest[0].(*string) = expectedSession.Token *dest[1].(*string) = expectedSession.UserID *dest[2].(*string) = expectedSession.Email - *dest[3].(*string) = expectedSession.Role - *dest[4].(*time.Time) = expectedSession.ExpiresAt - *dest[5].(*time.Time) = expectedSession.CreatedAt - *dest[6].(*string) = expectedSession.UserAgent - *dest[7].(*string) = expectedSession.IPAddress - *dest[8].(*string) = expectedSession.CSRFToken + *dest[3].(*time.Time) = expectedSession.ExpiresAt + *dest[4].(*time.Time) = expectedSession.CreatedAt + *dest[5].(*string) = expectedSession.UserAgent + *dest[6].(*string) = expectedSession.IPAddress + *dest[7].(*string) = expectedSession.CSRFToken return nil }, } diff --git a/internal/auth/test_helpers.go b/internal/auth/test_helpers.go index 77955dfcb..ed0ed68cf 100644 --- a/internal/auth/test_helpers.go +++ b/internal/auth/test_helpers.go @@ -103,6 +103,11 @@ func (m *MockStore) ListGroups(ctx context.Context) ([]Group, error) { return args.Get(0).([]Group), args.Error(1) } +func (m *MockStore) CountGroupMembers(ctx context.Context, groupID string) (int, error) { + args := m.Called(ctx, groupID) + return args.Int(0), args.Error(1) +} + func (m *MockStore) CreateSession(ctx context.Context, session *Session) error { args := m.Called(ctx, session) return args.Error(0) @@ -237,7 +242,7 @@ func createTestUser(t *testing.T, password string) *User { Email: "test@example.com", PasswordHash: hash, Salt: "", // Not used anymore, bcrypt handles salting - Role: RoleUser, + GroupIDs: []string{DefaultAdminGroupID}, Active: true, CreatedAt: time.Now(), } diff --git a/internal/auth/types.go b/internal/auth/types.go index 49ddb2cc5..5f8fc76dd 100644 --- a/internal/auth/types.go +++ b/internal/auth/types.go @@ -11,7 +11,6 @@ type User struct { Email string `json:"email" dynamodbav:"Email"` PasswordHash string `json:"-" dynamodbav:"PasswordHash"` Salt string `json:"-" dynamodbav:"Salt"` - Role string `json:"role" dynamodbav:"Role"` // admin, user, readonly GroupIDs []string `json:"group_ids,omitempty" dynamodbav:"GroupIDs"` CreatedAt time.Time `json:"created_at" dynamodbav:"CreatedAt"` UpdatedAt time.Time `json:"updated_at" dynamodbav:"UpdatedAt"` @@ -99,21 +98,20 @@ type UserAPIKey struct { } // AuthContext represents the complete authorization context for a user -// It combines user role, group memberships, and computed permissions +// It combines group memberships and the permissions computed from them. type AuthContext struct { User *User Groups []*Group AllowedAccounts []string // Computed from all groups (union) - Permissions []Permission // Computed from role + groups + Permissions []Permission // Computed from group memberships } -// HasPermission checks if the auth context has a specific permission +// HasPermission checks if the auth context has a specific permission. +// Authorization is derived purely from group-granted permissions: a user +// who is a member of the Administrators group holds {ActionAdmin, ResourceAll} +// and therefore passes any check; a user with no groups holds no permissions +// and is denied everything (fail closed). func (ctx *AuthContext) HasPermission(action, resource string) bool { - // Admin has all permissions - if ctx.User.Role == RoleAdmin { - return true - } - for _, perm := range ctx.Permissions { // Admin permission grants all access if perm.Action == ActionAdmin && perm.Resource == ResourceAll { @@ -170,12 +168,12 @@ func MatchesAccount(allowed []string, accountID, accountName string) bool { } // CanAccessAccount checks if the user can access a specific account by its -// ID or display name. Admins always have full access. Non-admins are checked -// against AllowedAccounts via MatchesAccount. +// ID or display name. Access is derived from the union of the user's groups' +// AllowedAccounts via MatchesAccount. Administrators-group members carry the +// "*" wildcard (seeded with allowed_accounts=['*']) and so match any account; +// a user with no groups has an empty AllowedAccounts and, combined with the +// permission check at the call site, is denied (fail closed). func (ctx *AuthContext) CanAccessAccount(accountID, accountName string) bool { - if ctx.User.Role == RoleAdmin { - return true - } return MatchesAccount(ctx.AllowedAccounts, accountID, accountName) } @@ -184,7 +182,6 @@ type Session struct { Token string `json:"token" dynamodbav:"PK"` UserID string `json:"user_id" dynamodbav:"UserID"` Email string `json:"email" dynamodbav:"Email"` - Role string `json:"role" dynamodbav:"Role"` ExpiresAt time.Time `json:"expires_at" dynamodbav:"ExpiresAt"` CreatedAt time.Time `json:"created_at" dynamodbav:"CreatedAt"` UserAgent string `json:"user_agent,omitempty" dynamodbav:"UserAgent"` @@ -211,7 +208,6 @@ type LoginResponse struct { type UserInfo struct { ID string `json:"id"` Email string `json:"email"` - Role string `json:"role"` Groups []string `json:"groups,omitempty"` MFAEnabled bool `json:"mfa_enabled"` } @@ -227,11 +223,11 @@ type PasswordResetConfirm struct { NewPassword string `json:"new_password"` } -// CreateUserRequest for admin creating users +// CreateUserRequest for admin creating users. GroupIDs must contain at least +// one group: authorization derives entirely from group membership (issue #907). type CreateUserRequest struct { Email string `json:"email"` Password string `json:"password"` - Role string `json:"role"` GroupIDs []string `json:"group_ids,omitempty"` } @@ -241,10 +237,12 @@ type CreateUserRequest struct { // from "explicitly setting email to a new value". This matters because the // service layer applies email changes via updateUserEmail, which performs // format validation and uniqueness checks that must NOT run on no-op -// updates that only touch role/groups/active. +// updates that only touch groups/active. +// +// GroupIDs is nil when the caller is not changing group membership; a non-nil +// (including empty) slice replaces the membership and must be non-empty. type UpdateUserRequest struct { Email *string `json:"email,omitempty"` - Role *string `json:"role,omitempty"` GroupIDs []string `json:"group_ids,omitempty"` Active *bool `json:"active,omitempty"` } diff --git a/internal/database/postgres/migrations/000057_drop_user_role_to_groups.down.sql b/internal/database/postgres/migrations/000057_drop_user_role_to_groups.down.sql new file mode 100644 index 000000000..e1bb2f719 --- /dev/null +++ b/internal/database/postgres/migrations/000057_drop_user_role_to_groups.down.sql @@ -0,0 +1,35 @@ +-- Reverse of 000057: restore the `role` column and its constraints, and remove +-- the group-membership invariant. Role values are reconstructed best-effort +-- from group membership so the rolled-back system keeps a coherent role per +-- user. The two seeded groups (Standard Users / Read-Only Users) are left in +-- place: dropping them could orphan group_ids that operators have since +-- assigned, and they are harmless if unused (mirrors the no-op rollback +-- rationale of migration 000056). + +-- Drop the >= 1-group invariant first so the column can be relaxed. +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_min_one_group; +ALTER TABLE users ALTER COLUMN group_ids DROP NOT NULL; + +-- Re-add the sessions.role column (nullable; populated on next login). +ALTER TABLE sessions ADD COLUMN IF NOT EXISTS role VARCHAR(32); + +-- Re-add users.role. Add as nullable first so existing rows don't violate +-- NOT NULL before backfill, then backfill, then enforce NOT NULL + CHECK. +ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(32); + +-- Reconstruct role from group membership: +-- * Administrators group -> admin +-- * Read-Only Users group (and not admin) -> readonly +-- * everything else -> user +UPDATE users +SET role = CASE + WHEN group_ids @> ARRAY['00000000-0000-5000-8000-000000000001']::UUID[] THEN 'admin' + WHEN group_ids @> ARRAY['00000000-0000-5000-8000-000000000006']::UUID[] THEN 'readonly' + ELSE 'user' +END +WHERE role IS NULL; + +ALTER TABLE users ALTER COLUMN role SET DEFAULT 'user'; +ALTER TABLE users ALTER COLUMN role SET NOT NULL; +ALTER TABLE users ADD CONSTRAINT valid_role CHECK (role IN ('admin', 'user', 'readonly')); +CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); diff --git a/internal/database/postgres/migrations/000057_drop_user_role_to_groups.up.sql b/internal/database/postgres/migrations/000057_drop_user_role_to_groups.up.sql new file mode 100644 index 000000000..d21bec22c --- /dev/null +++ b/internal/database/postgres/migrations/000057_drop_user_role_to_groups.up.sql @@ -0,0 +1,106 @@ +-- Issue #907: collapse the dual authorization model (role + groups) down to +-- group-membership-only. Every authorization decision now derives from the +-- union of a user's groups' permissions; the `role` column is removed. +-- +-- Ordering is load-bearing: +-- 1. Seed groups that exactly mirror the legacy non-admin role permission +-- sets, so users mapped off `user` / `readonly` keep identical access. +-- 2. Backfill group_ids from role for any user whose group_ids is empty. +-- 3. Guarantee no user is left with zero groups (fail-safe net). +-- 4. Only THEN add the >= 1-group CHECK (it would fail on a zero-group row) +-- and drop the role column LAST. + +-- 1. Seed "Standard Users" and "Read-Only Users" groups mirroring +-- DefaultUserPermissions() and DefaultReadOnlyPermissions() respectively. +-- allowed_accounts = ['*'] (all-access marker, same as the 000024 seeds). +INSERT INTO groups (id, name, description, permissions, allowed_accounts) +VALUES + ('00000000-0000-5000-8000-000000000005', + 'Standard Users', + 'Standard users: view + manage plans, manage own purchase executions. Mirrors the legacy "user" role.', + '[ + {"action":"view","resource":"recommendations"}, + {"action":"view","resource":"plans"}, + {"action":"view","resource":"purchases"}, + {"action":"view","resource":"history"}, + {"action":"create","resource":"plans"}, + {"action":"update","resource":"plans"}, + {"action":"delete","resource":"plans"}, + {"action":"update","resource":"purchases"}, + {"action":"cancel-own","resource":"purchases"}, + {"action":"retry-own","resource":"purchases"}, + {"action":"approve-own","resource":"purchases"} + ]'::jsonb, + ARRAY['*']), + ('00000000-0000-5000-8000-000000000006', + 'Read-Only Users', + 'Read-only access to recommendations, plans, and history. Mirrors the legacy "readonly" role.', + '[ + {"action":"view","resource":"recommendations"}, + {"action":"view","resource":"plans"}, + {"action":"view","resource":"history"} + ]'::jsonb, + ARRAY['*']) +ON CONFLICT DO NOTHING; -- handles both id and name UNIQUE conflicts + +-- 2. Backfill group_ids from role for users whose group_ids is empty/null. +-- DISTINCT unnest dedupes; the WHERE clause only touches empty rows so +-- operator-assigned group_ids are preserved. The EXISTS guard prevents a +-- dangling reference if the corresponding group seed was skipped. +UPDATE users +SET group_ids = ARRAY( + SELECT DISTINCT unnest( + COALESCE(group_ids, '{}') || ARRAY['00000000-0000-5000-8000-000000000001']::UUID[] + ) + ), + updated_at = NOW() +WHERE role = 'admin' + AND (group_ids IS NULL OR cardinality(group_ids) = 0) + AND EXISTS (SELECT 1 FROM groups WHERE id = '00000000-0000-5000-8000-000000000001'); + +UPDATE users +SET group_ids = ARRAY( + SELECT DISTINCT unnest( + COALESCE(group_ids, '{}') || ARRAY['00000000-0000-5000-8000-000000000005']::UUID[] + ) + ), + updated_at = NOW() +WHERE role = 'user' + AND (group_ids IS NULL OR cardinality(group_ids) = 0) + AND EXISTS (SELECT 1 FROM groups WHERE id = '00000000-0000-5000-8000-000000000005'); + +UPDATE users +SET group_ids = ARRAY( + SELECT DISTINCT unnest( + COALESCE(group_ids, '{}') || ARRAY['00000000-0000-5000-8000-000000000006']::UUID[] + ) + ), + updated_at = NOW() +WHERE role = 'readonly' + AND (group_ids IS NULL OR cardinality(group_ids) = 0) + AND EXISTS (SELECT 1 FROM groups WHERE id = '00000000-0000-5000-8000-000000000006'); + +-- 3. Fail-safe: any user STILL left with zero groups (unknown role value, or a +-- seed that failed to insert) gets the least-privileged Read-Only Users +-- group so the >= 1-group CHECK below cannot fail and no account is left +-- inert. This never grants more than read-only access. +UPDATE users +SET group_ids = ARRAY['00000000-0000-5000-8000-000000000006']::UUID[], + updated_at = NOW() +WHERE (group_ids IS NULL OR cardinality(group_ids) = 0) + AND EXISTS (SELECT 1 FROM groups WHERE id = '00000000-0000-5000-8000-000000000006'); + +-- 4. Enforce the invariant at the schema level, then drop role. +-- group_ids becomes NOT NULL DEFAULT '{}' with a >= 1 element CHECK so the +-- database can never represent a zero-group user (issue #907). +ALTER TABLE users ALTER COLUMN group_ids SET DEFAULT '{}'; +UPDATE users SET group_ids = '{}' WHERE group_ids IS NULL; +ALTER TABLE users ALTER COLUMN group_ids SET NOT NULL; +ALTER TABLE users ADD CONSTRAINT users_min_one_group CHECK (cardinality(group_ids) >= 1); + +-- Drop role-dependent objects, then the role column itself, LAST. +DROP INDEX IF EXISTS idx_users_role; +DROP INDEX IF EXISTS idx_users_one_admin; -- 000025 partial unique idx (already dropped by 000050 on most DBs; IF EXISTS makes this idempotent) +ALTER TABLE users DROP CONSTRAINT IF EXISTS valid_role; +ALTER TABLE sessions DROP COLUMN IF EXISTS role; +ALTER TABLE users DROP COLUMN IF EXISTS role; diff --git a/internal/database/postgres/migrations/000057_drop_user_role_to_groups_test.go b/internal/database/postgres/migrations/000057_drop_user_role_to_groups_test.go new file mode 100644 index 000000000..0f8e2199b --- /dev/null +++ b/internal/database/postgres/migrations/000057_drop_user_role_to_groups_test.go @@ -0,0 +1,151 @@ +//go:build integration +// +build integration + +package migrations_test + +import ( + "context" + "sort" + "testing" + + "github.com/LeanerCloud/CUDly/internal/database/postgres/migrations" + "github.com/LeanerCloud/CUDly/internal/database/postgres/testhelpers" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + standardUsersGroupIDTest = "00000000-0000-5000-8000-000000000005" + readOnlyUsersGroupIDTest = "00000000-0000-5000-8000-000000000006" +) + +// queryGroupIDs returns the (sorted) group_ids of the user with the given +// email as UUID strings. +func queryGroupIDs(t *testing.T, ctx context.Context, pool *pgxpool.Pool, email string) []string { + t.Helper() + var ids []string + err := pool.QueryRow(ctx, ` + SELECT COALESCE(ARRAY(SELECT id::text FROM unnest(group_ids) AS id), '{}') + FROM users WHERE email = $1 + `, email).Scan(&ids) + require.NoError(t, err, "user row for %s must exist", email) + sort.Strings(ids) + return ids +} + +// TestMigration_DropUserRoleToGroups covers issue #907: migration 000057 maps +// each legacy role to the equivalent group BEFORE dropping the role column, so +// no user loses access; guarantees no user can be left with zero groups; and +// removes the role column from both users and sessions. +func TestMigration_DropUserRoleToGroups(t *testing.T) { + ctx := context.Background() + migrationsPath := getMigrationsPath() + + container, err := testhelpers.SetupPostgresContainer(ctx, t) + require.NoError(t, err) + defer container.Cleanup(ctx) + pool := container.DB.Pool() + + // Apply to head, then roll back 000057 so the DB sits at v56 where the + // `role` column still exists and we can seed role-bearing rows. + require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) + require.NoError(t, migrations.RollbackMigrations(ctx, pool, migrationsPath, 1)) + + // Seed one user per legacy role, each with empty group_ids (the pre-#907 + // state where authorization came from the role, not groups). Also seed a + // row with an unknown role to exercise the fail-safe net. + seed := func(email, role string) { + _, e := pool.Exec(ctx, ` + INSERT INTO users (id, email, password_hash, salt, role, active, group_ids, created_at, updated_at) + VALUES (gen_random_uuid(), $1, '', '', $2, true, '{}', NOW(), NOW()) + `, email, role) + require.NoError(t, e, "seeding %s", email) + } + seed("admin@test.example", "admin") + seed("user@test.example", "user") + seed("readonly@test.example", "readonly") + + // Re-apply 000057. + require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) + + // Role -> group mapping must preserve access. + assert.Equal(t, []string{defaultAdminGroupIDTest}, + queryGroupIDs(t, ctx, pool, "admin@test.example"), + "admin must be mapped to the Administrators group") + assert.Equal(t, []string{standardUsersGroupIDTest}, + queryGroupIDs(t, ctx, pool, "user@test.example"), + "user must be mapped to the Standard Users group") + assert.Equal(t, []string{readOnlyUsersGroupIDTest}, + queryGroupIDs(t, ctx, pool, "readonly@test.example"), + "readonly must be mapped to the Read-Only Users group") + + // The role column must be gone from both tables. + assertColumnAbsent(t, ctx, pool, "users", "role") + assertColumnAbsent(t, ctx, pool, "sessions", "role") + + // The DB must refuse a zero-group user (the >= 1-group CHECK). + _, err = pool.Exec(ctx, ` + INSERT INTO users (id, email, password_hash, salt, active, group_ids, created_at, updated_at) + VALUES (gen_random_uuid(), 'zero@test.example', '', '', true, '{}', NOW(), NOW()) + `) + require.Error(t, err, "the DB must reject a user with zero groups (users_min_one_group CHECK)") + + // A user WITH a group inserts fine. + _, err = pool.Exec(ctx, ` + INSERT INTO users (id, email, password_hash, salt, active, group_ids, created_at, updated_at) + VALUES (gen_random_uuid(), 'ok@test.example', '', '', true, + ARRAY['`+readOnlyUsersGroupIDTest+`']::uuid[], NOW(), NOW()) + `) + require.NoError(t, err, "a user with at least one group must insert successfully") +} + +// assertColumnAbsent fails if the named column still exists on the table. +func assertColumnAbsent(t *testing.T, ctx context.Context, pool *pgxpool.Pool, table, column string) { + t.Helper() + var exists bool + err := pool.QueryRow(ctx, ` + SELECT EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_name = $1 AND column_name = $2 + ) + `, table, column).Scan(&exists) + require.NoError(t, err) + assert.False(t, exists, "%s.%s column must be dropped", table, column) +} + +// TestMigration_DropUserRoleToGroups_Down restores the role column and +// reconstructs role from group membership. +func TestMigration_DropUserRoleToGroups_Down(t *testing.T) { + ctx := context.Background() + migrationsPath := getMigrationsPath() + + container, err := testhelpers.SetupPostgresContainer(ctx, t) + require.NoError(t, err) + defer container.Cleanup(ctx) + pool := container.DB.Pool() + + require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) + + // Seed group-only users (post-#907 shape) before rolling back. + _, err = pool.Exec(ctx, ` + INSERT INTO users (id, email, password_hash, salt, active, group_ids, created_at, updated_at) + VALUES + (gen_random_uuid(), 'adm@test.example', '', '', true, ARRAY['`+defaultAdminGroupIDTest+`']::uuid[], NOW(), NOW()), + (gen_random_uuid(), 'ro@test.example', '', '', true, ARRAY['`+readOnlyUsersGroupIDTest+`']::uuid[], NOW(), NOW()), + (gen_random_uuid(), 'std@test.example', '', '', true, ARRAY['`+standardUsersGroupIDTest+`']::uuid[], NOW(), NOW()) + `) + require.NoError(t, err) + + // Roll back 000057. + require.NoError(t, migrations.RollbackMigrations(ctx, pool, migrationsPath, 1)) + + roleOf := func(email string) string { + var role string + require.NoError(t, pool.QueryRow(ctx, `SELECT role FROM users WHERE email = $1`, email).Scan(&role)) + return role + } + assert.Equal(t, "admin", roleOf("adm@test.example")) + assert.Equal(t, "readonly", roleOf("ro@test.example")) + assert.Equal(t, "user", roleOf("std@test.example")) +} diff --git a/internal/database/postgres/migrations/backfill_admin_group_ids_test.go b/internal/database/postgres/migrations/backfill_admin_group_ids_test.go index 0ca79a805..5d39fd620 100644 --- a/internal/database/postgres/migrations/backfill_admin_group_ids_test.go +++ b/internal/database/postgres/migrations/backfill_admin_group_ids_test.go @@ -33,9 +33,12 @@ func TestMigration_BackfillAdminGroupIDs(t *testing.T) { defer container.Cleanup(ctx) pool := container.DB.Pool() - // Up to head (includes 000056), then roll back 000056 -> version 55. + // Up to head, then roll back to version 55 so the next RunMigrations + // re-applies 000056. Two steps because 000057 (drop role -> groups) now + // sits above 000056 at head; rolling back 000057 first also restores the + // `role` column this test seeds below. require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) - require.NoError(t, migrations.RollbackMigrations(ctx, pool, migrationsPath, 1)) + require.NoError(t, migrations.RollbackMigrations(ctx, pool, migrationsPath, 2)) // Simulate a restored/manually-seeded admin whose group_ids drifted to // empty (the bug pattern from issue #351). @@ -57,10 +60,12 @@ func TestMigration_BackfillAdminGroupIDs(t *testing.T) { assert.Equal(t, []string{defaultAdminGroupIDTest}, got, "migration 000056 must backfill the Administrators group onto a drifted admin row even without an admin email") - // Idempotent: re-running the migration path again must not duplicate. + // Idempotent: rolling back the head migration (000057) and re-applying it + // must not duplicate or drop the Administrators group on the already + // backfilled admin row. require.NoError(t, migrations.RollbackMigrations(ctx, pool, migrationsPath, 1)) require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) got = queryAdminGroupIDs(t, ctx, pool, adminEmail) assert.Equal(t, []string{defaultAdminGroupIDTest}, got, - "re-applying migration 000056 must not duplicate the Administrators group entry") + "re-applying the head migration must not duplicate the Administrators group entry") } diff --git a/internal/mocks/stores.go b/internal/mocks/stores.go index a9c64fab3..fdbffafa9 100644 --- a/internal/mocks/stores.go +++ b/internal/mocks/stores.go @@ -370,6 +370,12 @@ func (m *MockAuthStore) ListGroups(ctx context.Context) ([]auth.Group, error) { return args.Get(0).([]auth.Group), args.Error(1) } +// CountGroupMembers mocks the CountGroupMembers operation +func (m *MockAuthStore) CountGroupMembers(ctx context.Context, groupID string) (int, error) { + args := m.Called(ctx, groupID) + return args.Int(0), args.Error(1) +} + // CreateSession mocks the CreateSession operation func (m *MockAuthStore) CreateSession(ctx context.Context, session *auth.Session) error { args := m.Called(ctx, session) diff --git a/internal/server/adapter_test.go b/internal/server/adapter_test.go index 3c1512c94..5c590bec4 100644 --- a/internal/server/adapter_test.go +++ b/internal/server/adapter_test.go @@ -31,7 +31,7 @@ func TestAuthServiceAdapter_Login(t *testing.T) { ID: "user-1", Email: "test@example.com", PasswordHash: "$2a$10$invalidhash", - Role: "admin", + GroupIDs: []string{auth.DefaultAdminGroupID}, }, nil) _, err := adapter.Login(ctx, api.LoginRequest{ @@ -62,7 +62,6 @@ func TestAuthServiceAdapter_ValidateSession(t *testing.T) { Token: "hashed-token", UserID: "user-1", Email: "test@example.com", - Role: "admin", ExpiresAt: time.Now().Add(time.Hour), }, nil) @@ -70,7 +69,6 @@ func TestAuthServiceAdapter_ValidateSession(t *testing.T) { require.NoError(t, err) assert.Equal(t, "user-1", sess.UserID) assert.Equal(t, "test@example.com", sess.Email) - assert.Equal(t, "admin", sess.Role) } func TestAuthServiceAdapter_ValidateSession_Error(t *testing.T) { @@ -126,7 +124,7 @@ func TestAuthServiceAdapter_GetUser(t *testing.T) { mockStore.On("GetUserByID", ctx, "user-1").Return(&auth.User{ ID: "user-1", Email: "test@example.com", - Role: "admin", + GroupIDs: []string{auth.DefaultAdminGroupID}, MFAEnabled: false, }, nil) @@ -134,7 +132,7 @@ func TestAuthServiceAdapter_GetUser(t *testing.T) { require.NoError(t, err) assert.Equal(t, "user-1", user.ID) assert.Equal(t, "test@example.com", user.Email) - assert.Equal(t, "admin", user.Role) + assert.Equal(t, []string{auth.DefaultAdminGroupID}, user.Groups) } func TestAuthServiceAdapter_GetUser_Error(t *testing.T) { @@ -151,6 +149,10 @@ func TestAuthServiceAdapter_DeleteUser(t *testing.T) { adapter, mockStore := createMockAuthService(t) ctx := context.Background() + // Non-admin user: the last-admin guard is skipped after the GetUserByID + // lookup (issue #907), so deletion proceeds. + mockStore.On("GetUserByID", ctx, "user-1"). + Return(&auth.User{ID: "user-1", GroupIDs: []string{"group-1"}}, nil) mockStore.On("DeleteUser", ctx, "user-1").Return(nil) mockStore.On("DeleteUserSessions", ctx, "user-1").Return(nil) @@ -163,8 +165,8 @@ func TestAuthServiceAdapter_ListUsersAPI(t *testing.T) { ctx := context.Background() mockStore.On("ListUsers", ctx).Return([]auth.User{ - {ID: "user-1", Email: "user1@example.com", Role: "admin"}, - {ID: "user-2", Email: "user2@example.com", Role: "viewer"}, + {ID: "user-1", Email: "user1@example.com", GroupIDs: []string{auth.DefaultAdminGroupID}}, + {ID: "user-2", Email: "user2@example.com", GroupIDs: []string{"group-viewer"}}, }, nil) result, err := adapter.ListUsersAPI(ctx) @@ -244,10 +246,17 @@ func TestAuthServiceAdapter_HasPermissionAPI(t *testing.T) { adapter, mockStore := createMockAuthService(t) ctx := context.Background() + // Administrators-group member: the group's {admin, *} permission grants + // every action (issue #907 group-only authz). mockStore.On("GetUserByID", ctx, "user-1").Return(&auth.User{ - ID: "user-1", - Email: "test@example.com", - Role: "admin", + ID: "user-1", + Email: "test@example.com", + GroupIDs: []string{auth.DefaultAdminGroupID}, + }, nil) + mockStore.On("GetGroup", ctx, auth.DefaultAdminGroupID).Return(&auth.Group{ + ID: auth.DefaultAdminGroupID, + Name: "Administrators", + Permissions: []auth.Permission{{Action: auth.ActionAdmin, Resource: auth.ResourceAll}}, }, nil) has, err := adapter.HasPermissionAPI(ctx, "user-1", "read", "config") @@ -277,7 +286,7 @@ func TestAuthServiceAdapter_CreateUserAPI(t *testing.T) { _, err := adapter.CreateUserAPI(ctx, map[string]interface{}{ "email": "new@example.com", "password": "StrongPassword123!", - "role": "viewer", + "groups": []string{"group-viewer"}, }) // May fail depending on validation but exercises the code _ = err @@ -288,14 +297,16 @@ func TestAuthServiceAdapter_UpdateUserAPI(t *testing.T) { ctx := context.Background() mockStore.On("GetUserByID", ctx, "user-1").Return(&auth.User{ - ID: "user-1", - Email: "old@example.com", - Role: "viewer", + ID: "user-1", + Email: "old@example.com", + GroupIDs: []string{"group-viewer"}, }, nil) mockStore.On("UpdateUser", ctx, mock.AnythingOfType("*auth.User")).Return(nil) - _, err := adapter.UpdateUserAPI(ctx, "user-1", map[string]interface{}{ - "role": "admin", + // New signature carries the actor user ID (issue #907 self-escalation + // guard). The update only flips a non-group field, so no guard fires. + _, err := adapter.UpdateUserAPI(ctx, "actor-1", "user-1", map[string]interface{}{ + "active": true, }) _ = err } diff --git a/internal/server/app.go b/internal/server/app.go index 3bb317884..90a8ab254 100644 --- a/internal/server/app.go +++ b/internal/server/app.go @@ -864,7 +864,6 @@ func (a *authServiceAdapter) Login(ctx context.Context, req api.LoginRequest) (* User: &api.UserInfo{ ID: resp.User.ID, Email: resp.User.Email, - Role: resp.User.Role, Groups: resp.User.Groups, MFAEnabled: resp.User.MFAEnabled, }, @@ -884,7 +883,6 @@ func (a *authServiceAdapter) ValidateSession(ctx context.Context, token string) return &api.Session{ UserID: sess.UserID, Email: sess.Email, - Role: sess.Role, }, nil } @@ -903,7 +901,6 @@ func (a *authServiceAdapter) SetupAdmin(ctx context.Context, req api.SetupAdminR User: &api.UserInfo{ ID: resp.User.ID, Email: resp.User.Email, - Role: resp.User.Role, Groups: resp.User.Groups, MFAEnabled: resp.User.MFAEnabled, }, @@ -943,7 +940,7 @@ func (a *authServiceAdapter) GetUser(ctx context.Context, userID string) (*api.U return &api.User{ ID: user.ID, Email: user.Email, - Role: user.Role, + Groups: user.GroupIDs, MFAEnabled: user.MFAEnabled, }, nil } @@ -957,8 +954,8 @@ func (a *authServiceAdapter) CreateUserAPI(ctx context.Context, req any) (any, e return a.service.CreateUserAPI(ctx, req) } -func (a *authServiceAdapter) UpdateUserAPI(ctx context.Context, userID string, req any) (any, error) { - return a.service.UpdateUserAPI(ctx, userID, req) +func (a *authServiceAdapter) UpdateUserAPI(ctx context.Context, actorUserID, userID string, req any) (any, error) { + return a.service.UpdateUserAPI(ctx, actorUserID, userID, req) } func (a *authServiceAdapter) DeleteUser(ctx context.Context, userID string) error { diff --git a/internal/server/health_test.go b/internal/server/health_test.go index 1e60a6531..4f92cff2c 100644 --- a/internal/server/health_test.go +++ b/internal/server/health_test.go @@ -56,6 +56,10 @@ func (m *mockAuthStoreForHealth) GetGroup(ctx context.Context, groupID string) ( return nil, nil } +func (m *mockAuthStoreForHealth) CountGroupMembers(ctx context.Context, groupID string) (int, error) { + return 0, nil +} + func (m *mockAuthStoreForHealth) CreateGroup(ctx context.Context, group *auth.Group) error { return nil } From b15d398ba7beaa78812210def943025c513f0e76 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 2 Jun 2026 15:50:46 +0200 Subject: [PATCH 2/4] refactor(auth): extract helpers from guardGroupChange + Delete/Revoke APIKey to fit gocyclo budget Extract checkLastAdminConstraint from guardGroupChange (service_user.go) and a shared authorizeAPIKeyAccess helper used by both RevokeAPIKey and DeleteAPIKey (service_apikeys.go). All three functions drop from 11 to 9. No behaviour change: validation order, error messages, and audit-log writes are identical. --- internal/auth/service_apikeys.go | 70 ++++++++++++-------------------- internal/auth/service_user.go | 23 ++++++++--- 2 files changed, 44 insertions(+), 49 deletions(-) diff --git a/internal/auth/service_apikeys.go b/internal/auth/service_apikeys.go index 3a6290ad2..0546b18a1 100644 --- a/internal/auth/service_apikeys.go +++ b/internal/auth/service_apikeys.go @@ -141,42 +141,55 @@ func (s *Service) GetAPIKeyByHash(ctx context.Context, keyHash string) (*UserAPI return key, nil } -// RevokeAPIKey deactivates an API key (soft delete) -func (s *Service) RevokeAPIKey(ctx context.Context, userID, keyID string) error { - // Get the key to verify ownership +// authorizeAPIKeyAccess looks up the key and the caller, then returns the key +// together with a permission error if the caller is neither the key owner nor +// an admin. The action string ("revoke" / "delete") is used only in the error +// message so callers get a context-specific message. Pulled out of +// RevokeAPIKey and DeleteAPIKey to deduplicate the identical ownership check +// and keep both functions under the cyclomatic limit. +func (s *Service) authorizeAPIKeyAccess(ctx context.Context, userID, keyID, action string) (*UserAPIKey, error) { key, err := s.store.GetAPIKeyByID(ctx, keyID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return fmt.Errorf("API key not found") + return nil, fmt.Errorf("API key not found") } - return fmt.Errorf("failed to get API key: %w", err) + return nil, fmt.Errorf("failed to get API key: %w", err) } if key == nil { - return fmt.Errorf("API key not found") + return nil, fmt.Errorf("API key not found") } - // Verify ownership (unless admin) user, err := s.store.GetUserByID(ctx, userID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return fmt.Errorf("user not found") + return nil, fmt.Errorf("user not found") } - return fmt.Errorf("failed to get user: %w", err) + return nil, fmt.Errorf("failed to get user: %w", err) } if user == nil { - return fmt.Errorf("user not found") + return nil, fmt.Errorf("user not found") } if key.UserID != userID { isAdmin, err := s.UserHasAdminCapability(ctx, userID) if err != nil { - return fmt.Errorf("failed to check admin capability: %w", err) + return nil, fmt.Errorf("failed to check admin capability: %w", err) } if !isAdmin { - return fmt.Errorf("unauthorized: cannot revoke another user's API key") + return nil, fmt.Errorf("unauthorized: cannot %s another user's API key", action) } } + return key, nil +} + +// RevokeAPIKey deactivates an API key (soft delete) +func (s *Service) RevokeAPIKey(ctx context.Context, userID, keyID string) error { + key, err := s.authorizeAPIKeyAccess(ctx, userID, keyID, "revoke") + if err != nil { + return err + } + // Revoke the key key.IsActive = false if err := s.store.UpdateAPIKey(ctx, key); err != nil { @@ -190,38 +203,9 @@ func (s *Service) RevokeAPIKey(ctx context.Context, userID, keyID string) error // DeleteAPIKey permanently deletes an API key func (s *Service) DeleteAPIKey(ctx context.Context, userID, keyID string) error { - // Get the key to verify ownership - key, err := s.store.GetAPIKeyByID(ctx, keyID) + key, err := s.authorizeAPIKeyAccess(ctx, userID, keyID, "delete") if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return fmt.Errorf("API key not found") - } - return fmt.Errorf("failed to get API key: %w", err) - } - if key == nil { - return fmt.Errorf("API key not found") - } - - // Verify ownership (unless admin) - user, err := s.store.GetUserByID(ctx, userID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return fmt.Errorf("user not found") - } - return fmt.Errorf("failed to get user: %w", err) - } - if user == nil { - return fmt.Errorf("user not found") - } - - if key.UserID != userID { - isAdmin, err := s.UserHasAdminCapability(ctx, userID) - if err != nil { - return fmt.Errorf("failed to check admin capability: %w", err) - } - if !isAdmin { - return fmt.Errorf("unauthorized: cannot delete another user's API key") - } + return err } // Delete the key diff --git a/internal/auth/service_user.go b/internal/auth/service_user.go index bd7ccec23..6bb0d1546 100644 --- a/internal/auth/service_user.go +++ b/internal/auth/service_user.go @@ -333,12 +333,8 @@ func (s *Service) guardGroupChange(ctx context.Context, actorUserID, targetUserI // from a user who currently has it, ensure at least one other member // keeps it. if containsGroup(prior, DefaultAdminGroupID) && !containsGroup(next, DefaultAdminGroupID) { - count, err := s.store.CountGroupMembers(ctx, DefaultAdminGroupID) - if err != nil { - return fmt.Errorf("failed to count administrators: %w", err) - } - if count <= 1 { - return ErrLastAdmin + if err := s.checkLastAdminConstraint(ctx); err != nil { + return err } } @@ -359,6 +355,21 @@ func (s *Service) guardGroupChange(ctx context.Context, actorUserID, targetUserI return nil } +// checkLastAdminConstraint returns ErrLastAdmin if removing the +// Administrators group from its current holder would leave the group with +// zero members. Pulled out of guardGroupChange to keep that function under +// the cyclomatic limit. +func (s *Service) checkLastAdminConstraint(ctx context.Context) error { + count, err := s.store.CountGroupMembers(ctx, DefaultAdminGroupID) + if err != nil { + return fmt.Errorf("failed to count administrators: %w", err) + } + if count <= 1 { + return ErrLastAdmin + } + return nil +} + func containsGroup(groups []string, target string) bool { for _, g := range groups { if g == target { From 8fccb49cff12cdcca677fbd79b43dff75712e1b8 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 2 Jun 2026 16:30:30 +0200 Subject: [PATCH 3/4] test(auth): assert UpdateUserAPI adapter forwards actorUserID (CR #912) CodeRabbit flagged that the adapter test ignored its error and skipped mock expectation verification, so it silently passed even if the adapter stopped forwarding actorUserID to s.UpdateUser (the #907 self-escalation guard hook). It also passed a map[string]interface{} payload that fails the APIUpdateUserRequest type assertion in UpdateUserAPI, so the test only ever exercised the early-error path. Switch to a properly typed auth.APIUpdateUserRequest with a group change, add require.NoError + mockStore.AssertExpectations, and document why neither the self-escalation nor the last-admin guard fires for this shape (actor != target, group change does not remove Administrators). A regression in the actor-forwarding wiring now breaks this test instead of going unnoticed. Refs #907. --- internal/server/adapter_test.go | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/internal/server/adapter_test.go b/internal/server/adapter_test.go index 5c590bec4..b9d0933c5 100644 --- a/internal/server/adapter_test.go +++ b/internal/server/adapter_test.go @@ -300,15 +300,21 @@ func TestAuthServiceAdapter_UpdateUserAPI(t *testing.T) { ID: "user-1", Email: "old@example.com", GroupIDs: []string{"group-viewer"}, - }, nil) - mockStore.On("UpdateUser", ctx, mock.AnythingOfType("*auth.User")).Return(nil) - - // New signature carries the actor user ID (issue #907 self-escalation - // guard). The update only flips a non-group field, so no guard fires. - _, err := adapter.UpdateUserAPI(ctx, "actor-1", "user-1", map[string]interface{}{ - "active": true, + }, nil).Once() + mockStore.On("UpdateUser", ctx, mock.AnythingOfType("*auth.User")).Return(nil).Once() + + // The new signature threads the actor user ID through to s.UpdateUser + // (issue #907 self-escalation guard). Actor != target, so the + // self-escalation guard skips; the group change does not remove the + // Administrators group, so the last-admin guard skips; the store + // UpdateUser call must therefore fire. Without that, the test silently + // passes even if the adapter stopped forwarding actorUserID, which is + // exactly the regression CR flagged. + _, err := adapter.UpdateUserAPI(ctx, "actor-1", "user-1", auth.APIUpdateUserRequest{ + Groups: []string{"group-editor"}, }) - _ = err + require.NoError(t, err) + mockStore.AssertExpectations(t) } func TestAuthServiceAdapter_CreateGroupAPI(t *testing.T) { From 4e3263e783604bf1a703c2939f3cd7c2b5e63093 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 2 Jun 2026 16:30:42 +0200 Subject: [PATCH 4/4] test(migrations/000057): exercise unknown-role fail-safe fallback (CR #912) CodeRabbit noted the migration test seeded only the three known legacy roles (admin/user/readonly), so the fail-safe net at step 3 of the .up.sql (any user STILL with zero groups -> Read-Only Users) was never asserted. A bug in that step could land in production silently. Add one more seed row with a non-canonical role value to exercise the fallback path. The valid_role CHECK has to be dropped first because it restricts the column to {admin,user,readonly}; this matches the realistic shape of legacy databases where the CHECK may have been relaxed before hitting this migration. After re-applying 000057, assert the row landed in the Read-Only Users group, locking down the migration's promise that no migrated user can end up with zero groups regardless of their prior role value. Refs #907. --- .../000057_drop_user_role_to_groups_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/database/postgres/migrations/000057_drop_user_role_to_groups_test.go b/internal/database/postgres/migrations/000057_drop_user_role_to_groups_test.go index 0f8e2199b..44af19ef6 100644 --- a/internal/database/postgres/migrations/000057_drop_user_role_to_groups_test.go +++ b/internal/database/postgres/migrations/000057_drop_user_role_to_groups_test.go @@ -66,6 +66,14 @@ func TestMigration_DropUserRoleToGroups(t *testing.T) { seed("user@test.example", "user") seed("readonly@test.example", "readonly") + // Drop the valid_role CHECK so we can seed a row whose role is outside + // the legacy {admin,user,readonly} set; this is the path the migration's + // fail-safe net (step 3 in 000057.up.sql) is designed to catch -- without + // it, that net is exercised by no test and could rot silently. + _, err = pool.Exec(ctx, `ALTER TABLE users DROP CONSTRAINT IF EXISTS valid_role`) + require.NoError(t, err) + seed("unknown@test.example", "legacy-custom") + // Re-apply 000057. require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) @@ -79,6 +87,11 @@ func TestMigration_DropUserRoleToGroups(t *testing.T) { assert.Equal(t, []string{readOnlyUsersGroupIDTest}, queryGroupIDs(t, ctx, pool, "readonly@test.example"), "readonly must be mapped to the Read-Only Users group") + // Fail-safe net: a user whose role does not match any known mapping must + // land in the Read-Only Users group so the >= 1-group CHECK cannot fail. + assert.Equal(t, []string{readOnlyUsersGroupIDTest}, + queryGroupIDs(t, ctx, pool, "unknown@test.example"), + "unknown legacy roles must fall back to the Read-Only Users group") // The role column must be gone from both tables. assertColumnAbsent(t, ctx, pool, "users", "role")