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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions internal/api/coverage_extras_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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"))

Expand Down
18 changes: 14 additions & 4 deletions internal/api/coverage_gaps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -343,29 +344,38 @@ 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"},
}
_, 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)
}

// ---------------------------------------------------------------------------
Expand Down
32 changes: 20 additions & 12 deletions internal/api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/api/handler_accounts_router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 6 additions & 5 deletions internal/api/handler_accounts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ func adminAccountSession() *Session {
return &Session{
UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
Email: "admin@example.com",
Role: "admin",
}
}

Expand Down Expand Up @@ -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 ---
Expand Down Expand Up @@ -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)}

Expand Down Expand Up @@ -1548,7 +1550,6 @@ func scopedUserSession() *Session {
return &Session{
UserID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
Email: "viewer@example.com",
Role: "user",
}
}

Expand Down
7 changes: 4 additions & 3 deletions internal/api/handler_analytics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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{
Expand Down
Loading
Loading