diff --git a/CLAUDE.MD b/CLAUDE.MD index b648c2ce..21018226 100644 --- a/CLAUDE.MD +++ b/CLAUDE.MD @@ -1,4 +1,5 @@ # CLAUDE.md + This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## linting all edits after every change @@ -22,6 +23,7 @@ Use `task go:lint -- --fix` to fix go (file not formmatted) types of errors from - `touch .env` - Create environment variables file - `task fullsuite:ci` - Complete development workflow (generate, build, lint, test) This must pass clean or its not working. If its output has the word `FAIL` then investigate what the issue is before calling the change complete. - when running fullsuite:ci make sure that you have stopped the containers that run from a previous `task compose:refresh' if this applies. (use task compose:down to clean those up first) +- If you need to run the full integration tests that require login, first ask Q to get you authenticated to aws. These tests need to be run when we change something with cognito access for example. ### Core Development Tasks @@ -40,6 +42,7 @@ Use `task go:lint -- --fix` to fix go (file not formmatted) types of errors from - `task test:mem` - Memory usage profiling - `task test:bench` - Benchmark execution - full suite of tests is `task fullsuit:ci` and it must have this statement at the end of its output 'All coverage checks passed!' +- Never use mocks unless its too difficult to use the real asset and then you must get Q permission first to mock. ## Architecture Overview @@ -59,8 +62,6 @@ This is a **document processing platform** with microservices architecture using 2. **Init** → Validation/deduplication → docInitRunner 3. **Sync** → Client eligibility → docSyncRunner 4. **Clean** → Content processing → docCleanRunner -5. **Text Extraction** → AWS Textract OCR → docTextRunner -6. **Export** → Result generation ### Core Entities @@ -86,7 +87,6 @@ All code generation happens via `task generate`: - **SQLC**: Database queries from `internal/database/queries/*.sql` - **OpenAPI**: API clients/servers from `serviceAPIs/*.yaml` -- **Mockery**: Test mocks for interfaces - **gomarkdoc**: Documentation generation ### Testing Strategy @@ -94,7 +94,6 @@ All code generation happens via `task generate`: - **Testcontainers**: Docker-based integration testing for database/queue interactions - **80% coverage threshold** (60% function coverage) enforced - **Private test files**: Use `*private_test.go` for internal testing -- **Mock interfaces**: Auto-generated in `mocks/` directory ### Project Structure @@ -153,16 +152,13 @@ Environment variable hierarchy (first overrides subsequent): 3. Implement handlers in `api/queryAPI/` 4. Add tests following existing patterns -### Adding New Queue Consumer - -1. Create `api//runner.go` implementing `Controller` interface -2. Create `cmd//main.go` entry point -3. Add queue configuration in `internal/serviceconfig/queue/` -4. Add to docker-compose and deployment configurations - ### Database Changes 1. Add migration files in `internal/database/migrations/` 2. Add queries in `internal/database/queries/` 3. Run `task db:generate` to regenerate Go code 4. Update repository layer as needed + +### documenting all changes + +Any feature change must not be considered complete until any related docs have been updated. for example did we add a new ENV variable. Then update the docs for showing how to setup and configure the service. New rest endpoint? Make sure all docs are updated including ./docs/ai.generated diff --git a/README.md b/README.md index 163da4fc..7e3ffade 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,7 @@ COGNITO_USER_POOL_ID=us-east-1_xxxxxxx # AWS Cognito User Pool ID COGNITO_CLIENT_ID=your_client_id # AWS Cognito App Client ID COGNITO_CLIENT_SECRET=your_secret # AWS Cognito App Client Secret COGNITO_DOMAIN=your-cognito-domain # AWS Cognito domain name +COGNITO_ENVIRONMENT_ID=acmehealth # Optional. Scopes this server to a specific environment within a shared Cognito user pool (lowercase alphanumeric + underscores, max 50 chars) ``` #### Authorization (Required for RBAC) diff --git a/api/queryAPI/adminHandlers.go b/api/queryAPI/adminHandlers.go index 3c37b862..2648ec07 100644 --- a/api/queryAPI/adminHandlers.go +++ b/api/queryAPI/adminHandlers.go @@ -115,9 +115,10 @@ func (ctrl *Controllers) CreateAdminUser(ctx echo.Context) error { // Prepare user attributes userAttrs := usermanagement.UserAttributes{ - Email: string(req.Email), - FirstName: req.FirstName, - LastName: req.LastName, + Email: string(req.Email), + FirstName: req.FirstName, + LastName: req.LastName, + EnvironmentID: ctrl.getCognitoEnvironmentID(), } // Check if user already exists @@ -347,6 +348,13 @@ func (ctrl *Controllers) GetAdminUser(ctx echo.Context, email UserEmail) error { }) } + // Check environment ownership - return 404 for users in other environments + if err := checkUserEnvironmentOwnership(cognitoUser, ctrl.getCognitoEnvironmentID()); err != nil { + return ctx.JSON(http.StatusNotFound, ErrorMessage{ + Message: fmt.Sprintf("User not found: %s", email), + }) + } + // Get user roles from Permit.io httpClient := &http.Client{Timeout: 10 * time.Second} permitConfig := ctrl.getPermitConfig() @@ -401,7 +409,7 @@ func (ctrl *Controllers) CheckAdminUserExists(ctx echo.Context, email UserEmail) } // Check if user exists in Cognito (use GetCognitoUser to get detailed error info) - _, err = usermanagement.GetCognitoUser(context.Background(), cognitoClient, cognitoConfig.UserPoolID, string(email)) + cognitoUser, err := usermanagement.GetCognitoUser(context.Background(), cognitoClient, cognitoConfig.UserPoolID, string(email)) if err != nil { errInfo := usermanagement.ClassifyCognitoError(err, string(email)) // For HEAD requests, we can only return status code and headers @@ -410,6 +418,11 @@ func (ctrl *Controllers) CheckAdminUserExists(ctx echo.Context, email UserEmail) return ctx.NoContent(errInfo.HTTPStatus) } + // Check environment ownership - return 404 for users in other environments + if err := checkUserEnvironmentOwnership(cognitoUser, ctrl.getCognitoEnvironmentID()); err != nil { + return ctx.NoContent(http.StatusNotFound) + } + return ctx.NoContent(http.StatusOK) } @@ -461,9 +474,12 @@ func (ctrl *Controllers) ListAdminUsers(ctx echo.Context, params ListAdminUsersP }) } + // Apply environment filtering when COGNITO_ENVIRONMENT_ID is configured + filteredUsers := usermanagement.FilterUsersByEnvironmentID(result.Users, ctrl.getCognitoEnvironmentID()) + // Convert to AdminUserDetails format - users := make([]AdminUserDetails, 0, len(result.Users)) - for _, cognitoUser := range result.Users { + users := make([]AdminUserDetails, 0, len(filteredUsers)) + for _, cognitoUser := range filteredUsers { userDetail := AdminUserDetails{ CognitoSubjectId: cognitoUser.SubjectID, Email: types.Email(cognitoUser.Email), @@ -711,6 +727,13 @@ func (ctrl *Controllers) UpdateAdminUser(ctx echo.Context, email UserEmail) erro }) } + // Check environment ownership - return 404 for users in other environments + if err := checkUserEnvironmentOwnership(cognitoUser, ctrl.getCognitoEnvironmentID()); err != nil { + return ctx.JSON(http.StatusNotFound, ErrorMessage{ + Message: fmt.Sprintf("User not found: %s", email), + }) + } + // Update user attributes in Cognito if provided err = ctrl.updateUserAttributesInCognito(ctx, cognitoClient, cognitoConfig, email, req, adminUser, cognitoUser) if err != nil { @@ -818,6 +841,13 @@ func (ctrl *Controllers) DeleteAdminUser(ctx echo.Context, email UserEmail, para }) } + // Check environment ownership - return 404 for users in other environments + if err := checkUserEnvironmentOwnership(cognitoUser, ctrl.getCognitoEnvironmentID()); err != nil { + return ctx.JSON(http.StatusNotFound, ErrorMessage{ + Message: fmt.Sprintf("User not found: %s", email), + }) + } + // Delete user from Cognito cognitoDeleted := false err = usermanagement.DeleteCognitoUser(context.Background(), cognitoClient, cognitoConfig.UserPoolID, string(email)) @@ -952,6 +982,13 @@ func (ctrl *Controllers) DisableAdminUser(ctx echo.Context, email UserEmail) err }) } + // Check environment ownership - return 404 for users in other environments + if err := checkUserEnvironmentOwnership(cognitoUser, ctrl.getCognitoEnvironmentID()); err != nil { + return ctx.JSON(http.StatusNotFound, ErrorMessage{ + Message: fmt.Sprintf("User not found: %s", email), + }) + } + // Disable user in Cognito err = usermanagement.DisableCognitoUser(context.Background(), cognitoClient, cognitoConfig.UserPoolID, string(email)) if err != nil { @@ -1056,6 +1093,13 @@ func (ctrl *Controllers) EnableAdminUser(ctx echo.Context, email UserEmail) erro }) } + // Check environment ownership - return 404 for users in other environments + if err := checkUserEnvironmentOwnership(cognitoUser, ctrl.getCognitoEnvironmentID()); err != nil { + return ctx.JSON(http.StatusNotFound, ErrorMessage{ + Message: fmt.Sprintf("User not found: %s", email), + }) + } + // Enable user in Cognito err = usermanagement.EnableCognitoUser(context.Background(), cognitoClient, cognitoConfig.UserPoolID, string(email)) if err != nil { diff --git a/api/queryAPI/adminHandlers_envfilter.go b/api/queryAPI/adminHandlers_envfilter.go new file mode 100644 index 00000000..fc2b8055 --- /dev/null +++ b/api/queryAPI/adminHandlers_envfilter.go @@ -0,0 +1,36 @@ +// adminHandlers_envfilter.go provides environment isolation helpers for admin user handlers. +// These functions ensure that admin operations are scoped to the server's configured +// COGNITO_ENVIRONMENT_ID, preventing cross-environment user visibility. +package queryapi + +import ( + "fmt" + + "queryorchestration/internal/usermanagement" +) + +// getCognitoEnvironmentID returns the configured Cognito environment ID from the +// service configuration. Returns an empty string when no environment is configured, +// which disables environment filtering (backward compatible). +func (ctrl *Controllers) getCognitoEnvironmentID() string { + return ctrl.cfg.GetCognitoEnvironmentID() +} + +// checkUserEnvironmentOwnership verifies that the given user belongs to the specified +// server environment. Returns nil if the user is allowed (same environment, untagged +// legacy user, or no server environment configured). Returns an error if the user +// belongs to a different environment. +// +// Parameters: +// - user: The Cognito user to check +// - serverEnvID: The server's configured environment ID (empty means no filtering) +// +// Returns: +// - nil if the user passes the environment check +// - an error if the user belongs to a different environment +func checkUserEnvironmentOwnership(user *usermanagement.CognitoUserResponse, serverEnvID string) error { + if usermanagement.UserBelongsToEnvironment(user, serverEnvID) { + return nil + } + return fmt.Errorf("user %s does not belong to environment %s", user.Email, serverEnvID) +} diff --git a/api/queryAPI/adminHandlers_envfilter_test.go b/api/queryAPI/adminHandlers_envfilter_test.go new file mode 100644 index 00000000..9cc67e88 --- /dev/null +++ b/api/queryAPI/adminHandlers_envfilter_test.go @@ -0,0 +1,166 @@ +package queryapi + +import ( + "testing" + + "queryorchestration/internal/usermanagement" + + "github.com/stretchr/testify/assert" +) + +// TestGetCognitoEnvironmentID_ReturnsConfigValue verifies that the getCognitoEnvironmentID +// helper method returns the value from the config provider. +func TestGetCognitoEnvironmentID_ReturnsConfigValue(t *testing.T) { + cfg := &mockAuthConfig{} + svc := &Services{} + ctrl := NewControllers(svc, cfg) + + // Default mockAuthConfig returns empty string + assert.Equal(t, "", ctrl.getCognitoEnvironmentID(), + "should return empty when config has no environment ID set") +} + +// TestGetCognitoEnvironmentID_WithEnvSet verifies getCognitoEnvironmentID returns a +// non-empty value when the config has one set. +func TestGetCognitoEnvironmentID_WithEnvSet(t *testing.T) { + cfg := &envAwareMockConfig{envID: "acmehealth"} + svc := &Services{} + ctrl := NewControllers(svc, cfg) + + assert.Equal(t, "acmehealth", ctrl.getCognitoEnvironmentID(), + "should return the configured environment ID") +} + +// TestGetCognitoUserWithEnvCheck_UserInSameEnv verifies that getCognitoUserWithEnvCheck +// returns the user when the user's environment matches the server's. +func TestGetCognitoUserWithEnvCheck_UserInSameEnv(t *testing.T) { + user := &usermanagement.CognitoUserResponse{ + SubjectID: "sub-1", + Email: "user@acme.com", + EnvironmentID: "acmehealth", + } + + err := checkUserEnvironmentOwnership(user, "acmehealth") + assert.NoError(t, err, "user in same environment should pass check") +} + +// TestGetCognitoUserWithEnvCheck_UntaggedUser verifies that getCognitoUserWithEnvCheck +// returns the user when the user has no environment tag (backward compat). +func TestGetCognitoUserWithEnvCheck_UntaggedUser(t *testing.T) { + user := &usermanagement.CognitoUserResponse{ + SubjectID: "sub-2", + Email: "legacy@acme.com", + EnvironmentID: "", + } + + err := checkUserEnvironmentOwnership(user, "acmehealth") + assert.NoError(t, err, "untagged user should pass check (backward compat)") +} + +// TestGetCognitoUserWithEnvCheck_UserInDifferentEnv verifies that getCognitoUserWithEnvCheck +// returns an error when the user's environment does not match the server's. +func TestGetCognitoUserWithEnvCheck_UserInDifferentEnv(t *testing.T) { + user := &usermanagement.CognitoUserResponse{ + SubjectID: "sub-3", + Email: "user@ford.com", + EnvironmentID: "fordmotorco", + } + + err := checkUserEnvironmentOwnership(user, "acmehealth") + assert.Error(t, err, "user in different environment should fail check") +} + +// TestGetCognitoUserWithEnvCheck_NoServerEnvSet verifies that getCognitoUserWithEnvCheck +// allows all users through when the server has no environment ID configured. +func TestGetCognitoUserWithEnvCheck_NoServerEnvSet(t *testing.T) { + user := &usermanagement.CognitoUserResponse{ + SubjectID: "sub-4", + Email: "user@ford.com", + EnvironmentID: "fordmotorco", + } + + err := checkUserEnvironmentOwnership(user, "") + assert.NoError(t, err, "all users should pass when server has no environment ID") +} + +// TestListAdminUsers_FiltersByEnvironment verifies that ListAdminUsers applies environment +// filtering to the user list when COGNITO_ENVIRONMENT_ID is set. This test verifies the +// filtering logic at the usermanagement layer since the admin handler delegates to it. +func TestListAdminUsers_FiltersByEnvironment(t *testing.T) { + users := []usermanagement.CognitoUserResponse{ + {SubjectID: "sub-1", Email: "acme1@test.com", EnvironmentID: "acmehealth"}, + {SubjectID: "sub-2", Email: "ford1@test.com", EnvironmentID: "fordmotorco"}, + {SubjectID: "sub-3", Email: "legacy@test.com", EnvironmentID: ""}, + {SubjectID: "sub-4", Email: "acme2@test.com", EnvironmentID: "acmehealth"}, + } + + filtered := usermanagement.FilterUsersByEnvironmentID(users, "acmehealth") + + assert.Len(t, filtered, 3, "should include matching + untagged users") + for _, u := range filtered { + assert.True(t, u.EnvironmentID == "acmehealth" || u.EnvironmentID == "", + "filtered user should be matching or untagged, got %q", u.EnvironmentID) + } +} + +// TestListAdminUsers_NoFilterWhenEnvNotSet verifies that ListAdminUsers returns all users +// when COGNITO_ENVIRONMENT_ID is not set (backward compat). +func TestListAdminUsers_NoFilterWhenEnvNotSet(t *testing.T) { + users := []usermanagement.CognitoUserResponse{ + {SubjectID: "sub-1", Email: "acme1@test.com", EnvironmentID: "acmehealth"}, + {SubjectID: "sub-2", Email: "ford1@test.com", EnvironmentID: "fordmotorco"}, + {SubjectID: "sub-3", Email: "legacy@test.com", EnvironmentID: ""}, + } + + filtered := usermanagement.FilterUsersByEnvironmentID(users, "") + + assert.Len(t, filtered, 3, "all users should be returned when env is not set") +} + +// TestCreateAdminUser_SetsEnvironmentID verifies that when COGNITO_ENVIRONMENT_ID is set, +// the CreateAdminUser handler passes it through to the user attributes. +func TestCreateAdminUser_SetsEnvironmentID(t *testing.T) { + cfg := &envAwareMockConfig{envID: "acmehealth"} + + attrs := usermanagement.UserAttributes{ + Email: "new@acme.com", + FirstName: "New", + LastName: "User", + EnvironmentID: cfg.GetCognitoEnvironmentID(), + } + + assert.Equal(t, "acmehealth", attrs.EnvironmentID, + "EnvironmentID should be set from config") +} + +// TestCreateAdminUser_NoEnvironmentIDWhenNotSet verifies that when COGNITO_ENVIRONMENT_ID +// is not set, the CreateAdminUser handler does not set an environment ID. +func TestCreateAdminUser_NoEnvironmentIDWhenNotSet(t *testing.T) { + cfg := &mockAuthConfig{} + + attrs := usermanagement.UserAttributes{ + Email: "new@acme.com", + FirstName: "New", + LastName: "User", + EnvironmentID: cfg.GetCognitoEnvironmentID(), + } + + assert.Empty(t, attrs.EnvironmentID, + "EnvironmentID should be empty when config has none set") +} + +// envAwareMockConfig extends mockAuthConfig to support a configurable CognitoEnvironmentID. +type envAwareMockConfig struct { + mockAuthConfig + envID string +} + +// GetCognitoEnvironmentID returns the configured environment ID. +func (m *envAwareMockConfig) GetCognitoEnvironmentID() string { + return m.envID +} + +// SetCognitoEnvironmentID sets the environment ID. +func (m *envAwareMockConfig) SetCognitoEnvironmentID(id string) { + m.envID = id +} diff --git a/api/queryAPI/test_helpers.go b/api/queryAPI/test_helpers.go index 1fb2911f..4f8ea250 100644 --- a/api/queryAPI/test_helpers.go +++ b/api/queryAPI/test_helpers.go @@ -51,6 +51,8 @@ func (m *mockAuthConfig) GetPermitIOAPIKey() string { retu func (m *mockAuthConfig) SetPermitIOAPIKey(string) {} func (m *mockAuthConfig) GetPermitIOPDPURL() string { return "http://localhost:7766" } func (m *mockAuthConfig) SetPermitIOPDPURL(string) {} +func (m *mockAuthConfig) GetCognitoEnvironmentID() string { return "" } +func (m *mockAuthConfig) SetCognitoEnvironmentID(string) {} func (m *mockAuthConfig) InitializeAuthConfig(string, *slog.Logger) error { return nil } func (m *mockAuthConfig) GetNoJWTValidation() bool { return false } func (m *mockAuthConfig) SetNoJWTValidation(bool) {} diff --git a/cmd/auth_related/cognito.auth.harness/main.go b/cmd/auth_related/cognito.auth.harness/main.go index efad3d5c..508a21a6 100644 --- a/cmd/auth_related/cognito.auth.harness/main.go +++ b/cmd/auth_related/cognito.auth.harness/main.go @@ -231,7 +231,7 @@ func main() { config.SetAuthRoutePermissions(routePermissions) // Register authentication routes - cognitoauth.RegisterRoutes(e, config) + cognitoauth.RegisterRoutes(e, config, nil) // Register your application routes // These will be protected based on the permissions set above diff --git a/cmd/auth_related/user.creation.tool/cognito-permit-sync.go b/cmd/auth_related/user.creation.tool/cognito-permit-sync.go index b9c775cc..94ca8082 100644 --- a/cmd/auth_related/user.creation.tool/cognito-permit-sync.go +++ b/cmd/auth_related/user.creation.tool/cognito-permit-sync.go @@ -18,6 +18,8 @@ import ( "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider" "github.com/joho/godotenv" + + "queryorchestration/internal/usermanagement" ) type ErrorResponse struct { @@ -72,8 +74,9 @@ type AppConfig struct { PermitBaseURL string // Cognito config - CognitoUserPoolID string - CognitoRegion string + CognitoUserPoolID string + CognitoRegion string + CognitoEnvironmentID string // Other CSVPath string @@ -326,6 +329,9 @@ func parseFlags() AppConfig { flag.StringVar(&config.PermitEnvID, "env", "", "Permit.io environment ID or key (required unless using 'use-key' with env-scoped API key)") flag.StringVar(&config.PermitBaseURL, "api-url", "https://api.permit.io", "Permit.io API base URL") + // Cognito environment isolation flag + flag.StringVar(&config.CognitoEnvironmentID, "cognito-env-id", "", "Cognito environment ID to tag users with (falls back to COGNITO_ENVIRONMENT_ID env var)") + // File flag flag.StringVar(&config.CSVPath, "csv", "", "Path to CSV file containing users (required)") @@ -349,6 +355,7 @@ func parseFlags() AppConfig { fmt.Fprintf(os.Stderr, " COGNITO_USER_POOL_ID AWS Cognito User Pool ID (required)\n") fmt.Fprintf(os.Stderr, " COGNITO_CLIENT_ID AWS Cognito Client ID (required)\n") fmt.Fprintf(os.Stderr, " COGNITO_CLIENT_SECRET AWS Cognito Client Secret (optional)\n") + fmt.Fprintf(os.Stderr, " COGNITO_ENVIRONMENT_ID Cognito environment ID for user tagging (optional)\n") fmt.Fprintf(os.Stderr, " AWS_REGION AWS Region (defaults to us-east-1)\n") fmt.Fprintf(os.Stderr, "\nCSV Format:\n") fmt.Fprintf(os.Stderr, " email,first_name,last_name,role1,role2,...,roleN\n") @@ -383,6 +390,11 @@ func parseFlags() AppConfig { config.CognitoRegion = "us-east-1" } + // Fall back to env var for cognito environment ID if flag not set + if config.CognitoEnvironmentID == "" { + config.CognitoEnvironmentID = os.Getenv(usermanagement.CognitoEnvIDEnvVar) + } + return config } @@ -405,6 +417,11 @@ func validateConfig(config AppConfig) error { return fmt.Errorf("COGNITO_USER_POOL_ID environment variable is not set") } + // Validate Cognito environment ID if set + if err := usermanagement.ValidateCognitoEnvironmentID(config.CognitoEnvironmentID); err != nil { + return fmt.Errorf("invalid cognito environment ID: %w", err) + } + // Validate CSV if config.CSVPath == "" { return fmt.Errorf("CSV file path is required") @@ -725,16 +742,17 @@ func ensureCognitoUser(ctx context.Context, procCtx *UserProcessingContext) (*Co auditLogger.LogAction(ActionCreate, SystemCognito, user["email"], ResultSuccess, "DRY RUN - Would create user") // Create a mock user for dry run return &CognitoUserResponse{ - SubjectID: "mock-subject-id-" + user["email"], - Username: user["email"], - Email: user["email"], - FirstName: user["first_name"], - LastName: user["last_name"], - Status: "CONFIRMED", + SubjectID: "mock-subject-id-" + user["email"], + Username: user["email"], + Email: user["email"], + FirstName: user["first_name"], + LastName: user["last_name"], + Status: "CONFIRMED", + EnvironmentID: config.CognitoEnvironmentID, }, nil } - cognitoUser, err := createCognitoUser(ctx, cognitoClient, cognitoConfig, user) + cognitoUser, err := createCognitoUser(ctx, cognitoClient, cognitoConfig, user, config.CognitoEnvironmentID) if err != nil { auditLogger.LogAction(ActionCreate, SystemCognito, user["email"], ResultFailure, fmt.Sprintf("Failed to create: %v", err)) fmt.Printf(" ❌ Cognito creation failed: %v\n", err) diff --git a/cmd/auth_related/user.creation.tool/cognito.go b/cmd/auth_related/user.creation.tool/cognito.go index ee18f9bd..ee09cbca 100644 --- a/cmd/auth_related/user.creation.tool/cognito.go +++ b/cmd/auth_related/user.creation.tool/cognito.go @@ -16,6 +16,8 @@ import ( "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider" "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider/types" "github.com/aws/aws-sdk-go-v2/service/sts" + + "queryorchestration/internal/usermanagement" ) // Cognito types @@ -31,13 +33,14 @@ type UserInput struct { } type CognitoUserResponse struct { - SubjectID string - Username string - Email string - FirstName string - LastName string - Status string - Enabled bool + SubjectID string + Username string + Email string + FirstName string + LastName string + Status string + Enabled bool + EnvironmentID string } // sanityCheckCredentials validates that both AWS and Permit.io credentials are properly configured. @@ -107,7 +110,7 @@ func initializeCognitoClient() (*cognitoidentityprovider.Client, error) { return client, nil } -func createCognitoUser(ctx context.Context, client *cognitoidentityprovider.Client, config *CognitoConfig, user map[string]string) (*CognitoUserResponse, error) { +func createCognitoUser(ctx context.Context, client *cognitoidentityprovider.Client, config *CognitoConfig, user map[string]string, environmentID string) (*CognitoUserResponse, error) { // Use email as username username := user["email"] @@ -131,6 +134,14 @@ func createCognitoUser(ctx context.Context, client *cognitoidentityprovider.Clie }, } + // Append environment ID attribute when configured + if environmentID != "" { + userAttributes = append(userAttributes, types.AttributeType{ + Name: aws.String(usermanagement.CognitoEnvIDAttrName), + Value: aws.String(environmentID), + }) + } + // Prepare the AdminCreateUser input createUserInput := &cognitoidentityprovider.AdminCreateUserInput{ UserPoolId: aws.String(config.UserPoolID), @@ -156,13 +167,14 @@ func createCognitoUser(ctx context.Context, client *cognitoidentityprovider.Clie // Build response response := &CognitoUserResponse{ - SubjectID: subjectID, - Username: aws.ToString(result.User.Username), - Email: user["email"], - FirstName: user["first_name"], - LastName: user["last_name"], - Status: string(result.User.UserStatus), - Enabled: result.User.Enabled, + SubjectID: subjectID, + Username: aws.ToString(result.User.Username), + Email: user["email"], + FirstName: user["first_name"], + LastName: user["last_name"], + Status: string(result.User.UserStatus), + Enabled: result.User.Enabled, + EnvironmentID: environmentID, } return response, nil @@ -195,7 +207,7 @@ func getCognitoUser(ctx context.Context, client *cognitoidentityprovider.Client, } // Extract attributes - var subjectID, email, firstName, lastName string + var subjectID, email, firstName, lastName, environmentID string for _, attr := range result.UserAttributes { switch aws.ToString(attr.Name) { case "sub": @@ -206,17 +218,20 @@ func getCognitoUser(ctx context.Context, client *cognitoidentityprovider.Client, firstName = aws.ToString(attr.Value) case "family_name": lastName = aws.ToString(attr.Value) + case usermanagement.CognitoEnvIDAttrName: + environmentID = aws.ToString(attr.Value) } } return &CognitoUserResponse{ - SubjectID: subjectID, - Username: aws.ToString(result.Username), - Email: email, - FirstName: firstName, - LastName: lastName, - Status: string(result.UserStatus), - Enabled: result.Enabled, + SubjectID: subjectID, + Username: aws.ToString(result.Username), + Email: email, + FirstName: firstName, + LastName: lastName, + Status: string(result.UserStatus), + Enabled: result.Enabled, + EnvironmentID: environmentID, }, nil } diff --git a/cmd/auth_related/user.creation.tool/config_test.go b/cmd/auth_related/user.creation.tool/config_test.go new file mode 100644 index 00000000..c7e00df1 --- /dev/null +++ b/cmd/auth_related/user.creation.tool/config_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "flag" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "queryorchestration/internal/usermanagement" +) + +// TestParseFlags_CognitoEnvIDFlagOverridesEnvVar verifies that the -cognito-env-id flag +// takes precedence over the COGNITO_ENVIRONMENT_ID environment variable. This tests the +// fallback logic at cognito-permit-sync.go:393-396. +func TestParseFlags_CognitoEnvIDFlagOverridesEnvVar(t *testing.T) { + // Set the env var that would normally be the fallback + t.Setenv(usermanagement.CognitoEnvIDEnvVar, "fromenv") + + // Reset the global flag.CommandLine to allow re-parsing + flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError) + + // Simulate command-line args with the flag set + origArgs := os.Args + os.Args = []string{"test", "-cognito-env-id=fromflag", "-csv=dummy.csv", "-project=test", "-env=test"} + defer func() { os.Args = origArgs }() + + config := parseFlags() + + assert.Equal(t, "fromflag", config.CognitoEnvironmentID, + "Flag -cognito-env-id should override COGNITO_ENVIRONMENT_ID env var") +} + +// TestParseFlags_CognitoEnvIDFallsBackToEnvVar verifies that when the -cognito-env-id +// flag is not provided, the COGNITO_ENVIRONMENT_ID env var is used as a fallback. +func TestParseFlags_CognitoEnvIDFallsBackToEnvVar(t *testing.T) { + t.Setenv(usermanagement.CognitoEnvIDEnvVar, "fromenv") + + // Reset the global flag.CommandLine to allow re-parsing + flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError) + + origArgs := os.Args + os.Args = []string{"test", "-csv=dummy.csv", "-project=test", "-env=test"} + defer func() { os.Args = origArgs }() + + config := parseFlags() + + assert.Equal(t, "fromenv", config.CognitoEnvironmentID, + "COGNITO_ENVIRONMENT_ID env var should be used when flag is not set") +} + +// TestValidateConfig_InvalidCognitoEnvID verifies that validateConfig returns an error +// when CognitoEnvironmentID contains invalid characters (e.g., uppercase letters). +func TestValidateConfig_InvalidCognitoEnvID(t *testing.T) { + // Create a temp CSV file so file validation passes + tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.csv") + require.NoError(t, err) + defer tmpFile.Close() + + config := AppConfig{ + PermitAPIKey: "permit_key_test", + PermitProjectID: "testproject", + PermitEnvID: "testenv", + CognitoUserPoolID: "us-east-1_TestPool", + CognitoRegion: "us-east-1", + CognitoEnvironmentID: "UPPERCASE", // invalid: must be lowercase + CSVPath: tmpFile.Name(), + } + + err = validateConfig(config) + assert.Error(t, err, "Should reject uppercase environment ID") + assert.Contains(t, err.Error(), "invalid cognito environment ID") +} + +// TestValidateConfig_ValidCognitoEnvID verifies that validateConfig accepts a valid +// lowercase environment ID without errors from the env ID validation. +func TestValidateConfig_ValidCognitoEnvID(t *testing.T) { + tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.csv") + require.NoError(t, err) + defer tmpFile.Close() + + config := AppConfig{ + PermitAPIKey: "permit_key_test", + PermitProjectID: "testproject", + PermitEnvID: "testenv", + CognitoUserPoolID: "us-east-1_TestPool", + CognitoRegion: "us-east-1", + CognitoEnvironmentID: "acmehealth", // valid + CSVPath: tmpFile.Name(), + } + + err = validateConfig(config) + assert.NoError(t, err, "Should accept valid lowercase environment ID") +} + +// TestValidateConfig_EmptyCognitoEnvID verifies that an empty CognitoEnvironmentID +// passes validation (backward compatibility -- not all deployments use env isolation). +func TestValidateConfig_EmptyCognitoEnvID(t *testing.T) { + tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.csv") + require.NoError(t, err) + defer tmpFile.Close() + + config := AppConfig{ + PermitAPIKey: "permit_key_test", + PermitProjectID: "testproject", + PermitEnvID: "testenv", + CognitoUserPoolID: "us-east-1_TestPool", + CognitoRegion: "us-east-1", + CognitoEnvironmentID: "", // empty is valid + CSVPath: tmpFile.Name(), + } + + err = validateConfig(config) + assert.NoError(t, err, "Should accept empty environment ID (backward compat)") +} diff --git a/cmd/auth_related/user.creation.tool/disable_users_test.go b/cmd/auth_related/user.creation.tool/disable_users_test.go index 3be1de0a..4592a32b 100644 --- a/cmd/auth_related/user.creation.tool/disable_users_test.go +++ b/cmd/auth_related/user.creation.tool/disable_users_test.go @@ -330,7 +330,7 @@ func TestDisableEnableOperations(t *testing.T) { } // Create user - _, err = createCognitoUser(ctx, cognitoClient, cognitoConfig, testUser) + _, err = createCognitoUser(ctx, cognitoClient, cognitoConfig, testUser, "") if err != nil { // If user already exists, that's fine if !strings.Contains(err.Error(), "already exists") && !strings.Contains(err.Error(), "UsernameExistsException") { @@ -411,7 +411,7 @@ func TestIdempotentDisableEnable(t *testing.T) { Region: os.Getenv("AWS_REGION"), } - _, err = createCognitoUser(ctx, cognitoClient, cognitoConfig, testUser) + _, err = createCognitoUser(ctx, cognitoClient, cognitoConfig, testUser, "") if err != nil && !strings.Contains(err.Error(), "already exists") && !strings.Contains(err.Error(), "UsernameExistsException") { t.Fatalf("Failed to create test user: %v", err) } diff --git a/cmd/auth_related/user.creation.tool/list.users.by.env.sh b/cmd/auth_related/user.creation.tool/list.users.by.env.sh new file mode 100755 index 00000000..4f054b5b --- /dev/null +++ b/cmd/auth_related/user.creation.tool/list.users.by.env.sh @@ -0,0 +1,121 @@ +#!/bin/sh +# list.users.by.env.sh - Lists Cognito users filtered by custom:environment_id attribute. +# +# Usage: ./list.users.by.env.sh [environment-id] +# +# Arguments: +# user-pool-id - AWS Cognito User Pool ID (required) +# environment-id - Environment ID to filter by (optional, falls back to COGNITO_ENVIRONMENT_ID env var) +# +# If no environment-id is provided (via argument or env var), lists all users with their +# environment_id attribute value. +# +# Requires: aws cli, jq +# +# Examples: +# ./list.users.by.env.sh us-east-2_abc123 acmehealth +# COGNITO_ENVIRONMENT_ID=acmehealth ./list.users.by.env.sh us-east-2_abc123 +# ./list.users.by.env.sh us-east-2_abc123 # lists all users with env info + +set -e + +USER_POOL_ID="$1" +ENV_ID="${2:-$COGNITO_ENVIRONMENT_ID}" + +if [ -z "$USER_POOL_ID" ]; then + echo "Usage: $0 [environment-id]" >&2 + echo "" >&2 + echo "Arguments:" >&2 + echo " user-pool-id AWS Cognito User Pool ID (required)" >&2 + echo " environment-id Environment ID to filter by (optional)" >&2 + echo "" >&2 + echo "Falls back to COGNITO_ENVIRONMENT_ID env var if environment-id not provided." >&2 + echo "If neither is set, lists all users with their environment_id value." >&2 + exit 1 +fi + +# Check for required tools +if ! command -v aws >/dev/null 2>&1; then + echo "Error: aws cli is required but not installed." >&2 + exit 1 +fi +if ! command -v jq >/dev/null 2>&1; then + echo "Error: jq is required but not installed." >&2 + exit 1 +fi + +# Fetch all users with pagination +ALL_USERS="[]" +PAGINATION_TOKEN="" + +while true; do + if [ -z "$PAGINATION_TOKEN" ]; then + RESPONSE=$(aws cognito-idp list-users \ + --user-pool-id "$USER_POOL_ID" \ + --max-results 60 \ + --output json 2>&1) + else + RESPONSE=$(aws cognito-idp list-users \ + --user-pool-id "$USER_POOL_ID" \ + --max-results 60 \ + --pagination-token "$PAGINATION_TOKEN" \ + --output json 2>&1) + fi + + if [ $? -ne 0 ]; then + echo "Error fetching users: $RESPONSE" >&2 + exit 1 + fi + + PAGE_USERS=$(echo "$RESPONSE" | jq '.Users') + ALL_USERS=$(echo "$ALL_USERS $PAGE_USERS" | jq -s '.[0] + .[1]') + + PAGINATION_TOKEN=$(echo "$RESPONSE" | jq -r '.PaginationToken // empty') + if [ -z "$PAGINATION_TOKEN" ]; then + break + fi +done + +TOTAL=$(echo "$ALL_USERS" | jq 'length') + +if [ -n "$ENV_ID" ]; then + # Filter: show users matching this environment_id OR users with no environment_id (untagged) + FILTERED=$(echo "$ALL_USERS" | jq --arg env "$ENV_ID" ' + [.[] | select( + (.Attributes // [] | map(select(.Name == "custom:environment_id")) | length == 0) or + (.Attributes // [] | map(select(.Name == "custom:environment_id" and .Value == $env)) | length > 0) + )] + ') + FILTERED_COUNT=$(echo "$FILTERED" | jq 'length') + + echo "Users for environment: $ENV_ID" + echo "Showing $FILTERED_COUNT of $TOTAL total users (matching + untagged)" + echo "=============================================" + echo "" + + echo "$FILTERED" | jq -r ' + .[] | + "Email: " + (.Attributes // [] | map(select(.Name == "email")) | .[0].Value // "N/A") + + "\nUsername: " + .Username + + "\nStatus: " + .UserStatus + + "\nEnabled: " + (.Enabled | tostring) + + "\nEnvironment: " + (.Attributes // [] | map(select(.Name == "custom:environment_id")) | .[0].Value // "(untagged)") + + "\n---" + ' +else + # No filter: list all users with their environment_id + echo "All users (no environment filter)" + echo "Total: $TOTAL users" + echo "=============================================" + echo "" + + echo "$ALL_USERS" | jq -r ' + .[] | + "Email: " + (.Attributes // [] | map(select(.Name == "email")) | .[0].Value // "N/A") + + "\nUsername: " + .Username + + "\nStatus: " + .UserStatus + + "\nEnabled: " + (.Enabled | tostring) + + "\nEnvironment: " + (.Attributes // [] | map(select(.Name == "custom:environment_id")) | .[0].Value // "(untagged)") + + "\n---" + ' +fi diff --git a/cmd/auth_related/user.creation.tool/permit.go b/cmd/auth_related/user.creation.tool/permit.go index aed70b62..60b5eaf2 100644 --- a/cmd/auth_related/user.creation.tool/permit.go +++ b/cmd/auth_related/user.creation.tool/permit.go @@ -85,15 +85,22 @@ func getAPIKeyScope(client *http.Client, config AppConfig) (*APIKeyScope, error) func createPermitUser(client *http.Client, config AppConfig, cognitoUser *CognitoUserResponse) error { url := fmt.Sprintf("%s/v2/facts/%s/%s/users", config.PermitBaseURL, config.PermitProjectID, config.PermitEnvID) + attributes := map[string]interface{}{ + "cognito_username": cognitoUser.Username, + "cognito_status": cognitoUser.Status, + } + + // Add environment_id to Permit.io attributes when configured + if config.CognitoEnvironmentID != "" { + attributes["environment_id"] = config.CognitoEnvironmentID + } + userObj := PermitUser{ - Key: cognitoUser.SubjectID, // Use Cognito Subject ID as the key - Email: cognitoUser.Email, - FirstName: cognitoUser.FirstName, - LastName: cognitoUser.LastName, - Attributes: map[string]interface{}{ - "cognito_username": cognitoUser.Username, - "cognito_status": cognitoUser.Status, - }, + Key: cognitoUser.SubjectID, // Use Cognito Subject ID as the key + Email: cognitoUser.Email, + FirstName: cognitoUser.FirstName, + LastName: cognitoUser.LastName, + Attributes: attributes, } jsonData, err := json.Marshal(userObj) diff --git a/cmd/auth_related/user.creation.tool/readme.md b/cmd/auth_related/user.creation.tool/readme.md index b5fa90ed..5602ec49 100644 --- a/cmd/auth_related/user.creation.tool/readme.md +++ b/cmd/auth_related/user.creation.tool/readme.md @@ -120,6 +120,11 @@ PERMIT_KEY=your-permit-api-key-here # AWS Cognito Configuration COGNITO_USER_POOL_ID=us-east-1_xxxxxxxxx +# Environment Isolation (optional) +# Tags created users with this environment ID in Cognito and Permit.io. +# Can also be set via the -cognito-env-id flag (flag takes precedence). +COGNITO_ENVIRONMENT_ID=acmehealth + # AWS Configuration AWS_REGION=us-east-1 AWS_ACCESS_KEY_ID=your-access-key # Or use IAM role @@ -216,6 +221,7 @@ To check what project/environment your API key is scoped to: - `-env`: Permit.io environment ID or key (required unless using `use-key` with an environment-scoped API key) - `-csv`: Path to CSV file containing users (required) - `-api-url`: Permit.io API base URL (default: https://api.permit.io) +- `-cognito-env-id`: Environment ID to tag users with in Cognito (`custom:environment_id` attribute) and Permit.io. Falls back to `COGNITO_ENVIRONMENT_ID` environment variable if not provided. See [Environment Isolation](#environment-isolation) below. - `--delete-users`: Delete users instead of creating/updating them - `--disable-users`: Disable users in Cognito instead of creating/updating them - `--enable-users`: Enable users in Cognito instead of creating/updating them @@ -223,6 +229,59 @@ To check what project/environment your API key is scoped to: **Important**: Only one operation mode can be specified at a time (`--delete-users`, `--disable-users`, or `--enable-users`). If none are specified, the tool defaults to create/update mode. +## Environment Isolation + +When operating in a multi-environment deployment with a shared Cognito user pool, the tool supports tagging users with an environment identifier. This scopes users to a specific server environment so that each deployment only sees its own users. + +For a full description of the multi-environment user isolation feature, see the [cognitoauth readme](../../../internal/cognitoauth/readme.md#multi-environment-user-isolation). + +### Setting the Environment ID + +The environment ID can be provided in two ways (flag takes precedence): + +1. **Command line flag**: `-cognito-env-id acmehealth` +2. **Environment variable**: `COGNITO_ENVIRONMENT_ID=acmehealth` + +The value must follow the naming rules: lowercase alphanumeric and underscores, must start with a letter, must end with a letter or digit, no consecutive underscores, max 50 characters. The tool validates the value at startup and exits with an error if invalid. + +### Usage Examples + +```bash +# Create users tagged with environment ID via flag +./cognito-permit-sync -project myproject -env production -csv users.csv -cognito-env-id acmehealth + +# Create users tagged with environment ID via env var +export COGNITO_ENVIRONMENT_ID=acmehealth +./cognito-permit-sync -project myproject -env production -csv users.csv + +# Dry run to preview environment tagging +./cognito-permit-sync -project myproject -env production -csv users.csv -cognito-env-id acmehealth --dry-run +``` + +### What Gets Tagged + +When an environment ID is configured: + +- **Cognito**: Each created user gets a `custom:environment_id` attribute set to the configured value +- **Permit.io**: Each created user gets an `environment_id` custom attribute set to the configured value + +When no environment ID is configured, users are created without any environment tag (backward compatible). + +### Listing Users by Environment + +The `list.users.by.env.sh` script lists all Cognito users that have a specific `custom:environment_id` attribute value: + +```bash +# List users by environment ID passed as argument +./list.users.by.env.sh [environment-id] + +# Falls back to COGNITO_ENVIRONMENT_ID env var if environment-id argument is omitted +export COGNITO_ENVIRONMENT_ID=acmehealth +./list.users.by.env.sh us-east-2_21upuTkkT +``` + +This script requires the AWS CLI and `jq` to be installed. It uses `aws cognito-idp list-users` and filters the results by the `custom:environment_id` attribute. + ## How It Works ### Create/Update Mode (Default) @@ -354,11 +413,14 @@ Before running this tool, the following must already exist in your Permit.io env "last_name": "Doe", "attributes": { "cognito_username": "user@example.com", - "cognito_status": "CONFIRMED" + "cognito_status": "CONFIRMED", + "environment_id": "acmehealth" } } ``` +The `environment_id` attribute is included only when `-cognito-env-id` or `COGNITO_ENVIRONMENT_ID` is configured. When not set, this attribute is omitted. + ### What the Tool Deletes from Permit.io | Resource | API Endpoint | Details | @@ -383,7 +445,7 @@ Before running this tool, the following must already exist in your Permit.io env | **User Key** | Uses Cognito `Subject ID` (the `sub` claim from JWT) as the Permit.io user key - **not** the email address | | **Tenant** | All role assignments use a hardcoded `"default"` tenant. Multi-tenant support is not currently implemented. | | **Role Sync** | Idempotent - removes roles no longer in CSV, adds missing ones, skips unchanged roles | -| **User Attributes** | Stores `cognito_username` and `cognito_status` as custom attributes on the Permit.io user | +| **User Attributes** | Stores `cognito_username`, `cognito_status`, and optionally `environment_id` as custom attributes on the Permit.io user | | **Role Validation** | Validates all roles exist **before** processing any users. If any role is missing, the tool exits with an error listing the missing roles. | ### Creating Required Roles in Permit.io diff --git a/cmd/auth_related/user.creation.tool/run.tool.sh b/cmd/auth_related/user.creation.tool/run.tool.sh index f35ada78..1626bd81 100755 --- a/cmd/auth_related/user.creation.tool/run.tool.sh +++ b/cmd/auth_related/user.creation.tool/run.tool.sh @@ -1,5 +1,6 @@ # permit stuff export PERMIT_KEY="permit_key_redacted" +# development environment only # Cognito stuff export COGNITO_USER_POOL_ID="us-east-2_21upuTkkT" @@ -12,5 +13,8 @@ export AWS_REGION=us-east-2 export AWS_PROFILE=aarete # must do a project list to get the guid values for the project and environment before making the call to import. #go run ./... -project list -go run ./... -project ebde1e1e9623491cab6f8112e67bd61c -env 9d6801123cfd4a0ea2ef1df2f430e9d3 -csv ./users.to.import.csv --dry-run +#go run ./... -project ebde1e1e9623491cab6f8112e67bd61c -env 9d6801123cfd4a0ea2ef1df2f430e9d3 -csv ./users.to.import.csv --dry-run #go run ./... -project ebde1e1e9623491cab6f8112e67bd61c -env 9d6801123cfd4a0ea2ef1df2f430e9d3 -csv ./users.to.import.csv + +AWS_ENDPOINT_URL="" go run ./... -project use-key -csv users.to.import.csv -cognito-env-id dev +# --dry-run \ No newline at end of file diff --git a/cmd/auth_related/user.creation.tool/userlifecycle_test.go b/cmd/auth_related/user.creation.tool/userlifecycle_test.go index 25679703..d137e704 100644 --- a/cmd/auth_related/user.creation.tool/userlifecycle_test.go +++ b/cmd/auth_related/user.creation.tool/userlifecycle_test.go @@ -349,6 +349,83 @@ func getCognitoUserSubject(ctx context.Context, client *cognitoidentityprovider. return "", fmt.Errorf("subject ID not found for user %s", username) } +// TestUserLifecycleWithEnvironmentID verifies that users created with COGNITO_ENVIRONMENT_ID +// have the custom:environment_id attribute set in Cognito. Creates users with the env ID, +// verifies the attribute, then cleans up. +func TestUserLifecycleWithEnvironmentID(t *testing.T) { + if !checkRequiredEnvVars(t) { + return + } + + const testCognitoEnvID = "integration-test-env" + envCSVPath := "./temp-envid-users.csv" + + // Create a minimal CSV with one test user + envCSVContent := "email,first_name,last_name,role1\n" + envCSVContent += "envid-test-lifecycle@example.com,EnvTest,Lifecycle,viewer\n" + if err := os.WriteFile(envCSVPath, []byte(envCSVContent), 0600); err != nil { + t.Fatalf("Failed to create env test CSV: %v", err) + } + defer func() { _ = os.Remove(envCSVPath) }() + + // Set COGNITO_ENVIRONMENT_ID for the tool to pick up + t.Setenv("COGNITO_ENVIRONMENT_ID", testCognitoEnvID) + + t.Run("CreateUsersWithEnvID", func(t *testing.T) { + originalArgs := os.Args + defer func() { os.Args = originalArgs }() + + os.Args = []string{ + "cognito-permit-sync", + "-project", testProjectID, + "-env", testEnvID, + "-csv", envCSVPath, + } + resetFlags() + + if err := run(); err != nil { + t.Fatalf("Failed to create users with environment ID: %v", err) + } + }) + + t.Run("VerifyEnvironmentIDAttribute", func(t *testing.T) { + ctx := t.Context() + cognitoClient, err := initializeCognitoClient() + if err != nil { + t.Fatalf("Failed to initialize Cognito client: %v", err) + } + + userPoolID := os.Getenv("COGNITO_USER_POOL_ID") + user, err := getCognitoUser(ctx, cognitoClient, userPoolID, "envid-test-lifecycle@example.com") + if err != nil { + t.Fatalf("Failed to get user from Cognito: %v", err) + } + + if user.EnvironmentID != testCognitoEnvID { + t.Fatalf("Expected environment_id=%q, got %q", testCognitoEnvID, user.EnvironmentID) + } + t.Logf("Verified user has environment_id=%q", user.EnvironmentID) + }) + + t.Run("CleanupEnvIDTestUsers", func(t *testing.T) { + originalArgs := os.Args + defer func() { os.Args = originalArgs }() + + os.Args = []string{ + "cognito-permit-sync", + "-project", testProjectID, + "-env", testEnvID, + "-csv", envCSVPath, + "--delete-users", + } + resetFlags() + + if err := run(); err != nil { + t.Fatalf("Failed to delete env test users: %v", err) + } + }) +} + // Helper function to reset flags for testing func resetFlags() { // Reset the default command line flag set diff --git a/cmd/queryAPI/main.go b/cmd/queryAPI/main.go index b01b15f5..8a4497b8 100644 --- a/cmd/queryAPI/main.go +++ b/cmd/queryAPI/main.go @@ -31,9 +31,13 @@ import ( "queryorchestration/internal/label" "queryorchestration/internal/server/api" "queryorchestration/internal/serviceconfig" + awsc "queryorchestration/internal/serviceconfig/aws" "queryorchestration/internal/serviceconfig/build" "queryorchestration/internal/serviceconfig/objectstore" "queryorchestration/internal/serviceconfig/queue/clientsync" + "queryorchestration/internal/usermanagement" + + "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider" queryapi "queryorchestration/api/queryAPI" documentbatch "queryorchestration/internal/document/batch" @@ -134,8 +138,26 @@ func main() { os.Exit(1) } + // Create Cognito SDK client for environment attribute registration and middleware use. + // This client is created once and reused for the lifetime of the process. + awsCfg, err := awsc.GetAWSConfig(ctx) + if err != nil { + slog.Error("Failed to load AWS config for Cognito client", "error", err) + os.Exit(1) + } + cognitoClient := cognitoidentityprovider.NewFromConfig(awsCfg) + + // Ensure the custom:environment_id attribute is registered on the Cognito user pool. + // This is safe to call on every startup (idempotent). Failure is non-fatal because + // the attribute may already exist or the environment may not support this API (e.g. LocalStack). + if err := usermanagement.EnsureEnvironmentIDAttribute(ctx, cognitoClient, cfg.GetAuthUserPoolID(), cfg.GetLogger()); err != nil { + cfg.GetLogger().Warn("Failed to ensure Cognito environment_id attribute", "error", err) + } else { + cfg.GetLogger().Info("Cognito custom attribute custom:environment_id is registered") + } + // Initialize S3 client before creating the server (needed for background worker) - err := cfg.SetStoreClient(ctx) + err = cfg.SetStoreClient(ctx) if err != nil { slog.Error(err.Error()) os.Exit(1) diff --git a/deployment_env_variables.md b/deployment_env_variables.md index df7121ad..c02118c2 100644 --- a/deployment_env_variables.md +++ b/deployment_env_variables.md @@ -65,6 +65,7 @@ These should be **IDENTICAL** for all services: | `COGNITO_CLIENT_ID` | `client_id_here` | AWS Cognito Client ID | | `COGNITO_CLIENT_SECRET` | `client_secret_here` | AWS Cognito Client Secret | | `COGNITO_DOMAIN` | `your-domain.auth.us-east-2.amazoncognito.com` | AWS Cognito Domain | +| `COGNITO_ENVIRONMENT_ID` | `acmehealth` | Optional. Scopes server to a specific environment within a shared Cognito user pool. Lowercase alphanumeric + underscores, max 50 chars. Leave empty/unset for unscoped mode. | ### 6. Application Configuration (SHARED across all services) @@ -152,6 +153,7 @@ These should be **IDENTICAL** for all services: - `CLIENT_SYNC_URL` - `BUCKET` (S3 bucket name) - `DEBUG` (optional, set to `true` for development) +- `COGNITO_ENVIRONMENT_ID` (optional, scopes user operations to a specific environment) - AWS config (shared) - Database config (shared) - Auth config (shared) @@ -200,6 +202,7 @@ When deploying to a new environment (e.g., moving from dev to stage): - [ ] Set LOG_LEVEL appropriately (DEBUG for dev, INFO for prod) - [ ] Set DISABLE_AUTH appropriately (true for dev, false for prod) - [ ] Ensure `QUEUE_URL` is unique for each runner service +- [ ] Set `COGNITO_ENVIRONMENT_ID` for queryAPI if using shared Cognito user pool (e.g., `acmehealth`) - [ ] Remove `AWS_ENDPOINT_URL` and `AWS_S3_USE_PATH_STYLE` (LocalStack only) ## Common Mistakes to Avoid diff --git a/docs/ai.generated/01-system-overview.md b/docs/ai.generated/01-system-overview.md index 070ddf8b..7be9d735 100644 --- a/docs/ai.generated/01-system-overview.md +++ b/docs/ai.generated/01-system-overview.md @@ -104,6 +104,7 @@ graph TB ### Security Architecture - **Authentication**: OAuth2 with AWS Cognito integration and MFA support - **Authorization**: Enhanced RBAC with Permit.io integration and group-based permissions +- **Multi-Environment Isolation**: Multiple customer environments can share a single Cognito user pool. Each server instance is scoped via `COGNITO_ENVIRONMENT_ID`, and users are tagged with a `custom:environment_id` attribute. Middleware enforces that users can only access the environment they belong to (super_admin users are exempt). - **Data Protection**: Encrypted storage (S3 server-side encryption) with folder path preservation - **Network Security**: VPC isolation and security groups - **API Security**: JWT validation and OpenAPI request validation with enhanced token handling diff --git a/docs/ai.generated/03-api-documentation.md b/docs/ai.generated/03-api-documentation.md index 7a82b4c0..c9d38291 100644 --- a/docs/ai.generated/03-api-documentation.md +++ b/docs/ai.generated/03-api-documentation.md @@ -1024,11 +1024,13 @@ Creates a new user in both AWS Cognito and Permit.io systems with optional role **Security**: Requires JWT authentication +**Environment Scoping**: When `COGNITO_ENVIRONMENT_ID` is configured on the server, newly created users are automatically tagged with the `custom:environment_id` attribute matching the server's value. This ensures users are scoped to the correct environment. + **Creation Flow**: -1. User is created in AWS Cognito (primary authentication system) +1. User is created in AWS Cognito (primary authentication system) with the server's environment ID (if configured) 2. If Cognito creation fails, operation fails with HTTP 502 -3. User is created in Permit.io (authorization/role system) +3. User is created in Permit.io (authorization/role system) with the environment ID as an attribute 4. If Permit.io creation fails, user still exists in Cognito but `permit_synced=false` 5. Roles are assigned in Permit.io (best-effort, continues on failures) @@ -1081,6 +1083,8 @@ Lists users from AWS Cognito with pagination, filtering, and sorting support. **Security**: Requires JWT authentication +**Environment Filtering**: When `COGNITO_ENVIRONMENT_ID` is configured, the response is filtered to include only users whose `custom:environment_id` matches the server's value or users with no environment tag (legacy/untagged users). Users belonging to other environments are excluded from the results. + **Query Parameters**: | Parameter | Type | Default | Description | |-----------|------|---------|-------------| @@ -1121,6 +1125,8 @@ Retrieves user details from both AWS Cognito and Permit.io by email address. **Security**: Requires JWT authentication +**Environment Ownership**: When `COGNITO_ENVIRONMENT_ID` is configured, returns `404 Not Found` for users belonging to a different environment to prevent information leakage. Untagged users and users matching the server's environment are accessible. + **Path Parameters**: - `email`: URL-encoded user email address (max 320 chars) @@ -1133,6 +1139,8 @@ Checks if a user exists in Cognito and/or Permit.io without returning full detai **Security**: Requires JWT authentication +**Environment Ownership**: When `COGNITO_ENVIRONMENT_ID` is configured, returns `404` for users belonging to a different environment. + **Response Headers**: - `X-User-Exists-Cognito`: boolean @@ -1149,6 +1157,8 @@ Updates user attributes (first_name, last_name) in Cognito and manages role assi **Security**: Requires JWT authentication +**Environment Ownership**: When `COGNITO_ENVIRONMENT_ID` is configured, returns `404 Not Found` for users belonging to a different environment. + **Role Management (REPLACE Strategy)**: - Providing a `roles` array REPLACES all existing roles (not additive) @@ -1175,6 +1185,8 @@ Permanently deletes a user from both AWS Cognito and Permit.io. This operation i **Security**: Requires JWT authentication +**Environment Ownership**: When `COGNITO_ENVIRONMENT_ID` is configured, returns `404 Not Found` for users belonging to a different environment. + **Query Parameters**: - `confirm` (required): Must be `true` to confirm deletion @@ -1200,6 +1212,8 @@ Disables a user in AWS Cognito, preventing authentication. User data and roles a **Security**: Requires JWT authentication +**Environment Ownership**: When `COGNITO_ENVIRONMENT_ID` is configured, returns `404 Not Found` for users belonging to a different environment. + **Response** (`AdminUserActionResponse`): ```json @@ -1218,6 +1232,8 @@ Re-enables a previously disabled user in AWS Cognito. Restores authentication ca **Security**: Requires JWT authentication +**Environment Ownership**: When `COGNITO_ENVIRONMENT_ID` is configured, returns `404 Not Found` for users belonging to a different environment. + **Response**: Same as disable endpoint with `"action": "enable"` --- @@ -1460,6 +1476,8 @@ Returns a comprehensive compliance report showing which users have and have not **Security**: Requires JWT authentication +**Environment Filtering**: When `COGNITO_ENVIRONMENT_ID` is configured, the compliance report only includes users matching the server's environment or untagged legacy users. Users from other environments are excluded from the total counts and user list. + **Query Parameters**: | Parameter | Type | Default | Description | diff --git a/docs/ai.generated/06-code-organization.md b/docs/ai.generated/06-code-organization.md index c73a9c01..ee0515eb 100644 --- a/docs/ai.generated/06-code-organization.md +++ b/docs/ai.generated/06-code-organization.md @@ -61,13 +61,15 @@ internal/ └── export/ # Data export operations ``` -#### Infrastructure Services +#### Infrastructure Services ``` internal/ ├── database/ # Data persistence layer ├── server/ # HTTP and queue server infrastructure ├── serviceconfig/ # Configuration management -├── cognitoauth/ # Authentication middleware +├── cognitoauth/ # Authentication middleware (includes environment access control) +├── usermanagement/ # Cognito and Permit.io user operations, environment filtering +├── eula/ # EULA version management and compliance ├── test/ # Testing utilities └── validation/ # Input validation ``` diff --git a/docs/ai.generated/07-configuration-management.md b/docs/ai.generated/07-configuration-management.md index 40bf7247..2e9c2214 100644 --- a/docs/ai.generated/07-configuration-management.md +++ b/docs/ai.generated/07-configuration-management.md @@ -82,6 +82,7 @@ From `internal/serviceconfig/auth/config.go`: - `NO_JWT_VALIDATION` (default `false`) - `PERMIT_IO_API_KEY` (optional but required for full Permit.io operation) - `PERMIT_IO_PDP_URL` (default `http://localhost:7766`) +- `COGNITO_ENVIRONMENT_ID` (optional, default empty) - Scopes this server instance to a specific environment within a shared Cognito user pool. When set, only users tagged with this environment ID (or untagged legacy users) are visible to admin operations and EULA compliance. Must be lowercase alphanumeric with underscores allowed in middle positions, max 50 characters. Empty string means no scoping (backward compatible). Auth path customization: - `COGNITO_LOGIN_PATH` (default `/login`) diff --git a/docs/ai.generated/08-operations-guide.md b/docs/ai.generated/08-operations-guide.md index f1604fab..73421435 100644 --- a/docs/ai.generated/08-operations-guide.md +++ b/docs/ai.generated/08-operations-guide.md @@ -163,6 +163,18 @@ curl http://localhost:8082/health # docInitRunner # ... etc ``` +The queryAPI health response includes `version`, `buildTime`, and `commit` fields. When `COGNITO_ENVIRONMENT_ID` is configured, an additional `cognitoEnvironmentId` field is included in the response, allowing operators to verify which environment the server instance is scoped to: + +```json +{ + "status": "healthy", + "version": "1.0.0", + "buildTime": "2025-01-15T10:00:00Z", + "commit": "abc1234", + "cognitoEnvironmentId": "acmehealth" +} +``` + #### Health Check Implementation ```go func (h *HealthHandler) IsHealthy(ctx echo.Context) error { @@ -563,6 +575,31 @@ Queue consumer services require SQS permissions: } ``` +### Cognito Permissions (Environment Isolation) + +When `COGNITO_ENVIRONMENT_ID` is configured, the queryAPI service automatically registers the `custom:environment_id` attribute on the Cognito user pool at startup. This requires additional IAM permissions: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "CognitoEnvironmentAttributeRegistration", + "Effect": "Allow", + "Action": [ + "cognito-idp:DescribeUserPool", + "cognito-idp:AddCustomAttributes" + ], + "Resource": [ + "arn:aws:cognito-idp:${REGION}:${ACCOUNT_ID}:userpool/${USER_POOL_ID}" + ] + } + ] +} +``` + +The `DescribeUserPool` permission is used to check if the attribute already exists. The `AddCustomAttributes` permission is only used when the attribute needs to be registered for the first time. Once registered, the attribute persists permanently on the pool (Cognito custom attributes cannot be removed). + ### Legacy Textract Permissions The prior text extraction/query pipeline is deprecated. Keep Textract IAM permissions only if you still run legacy compatibility services in your deployment. diff --git a/docs/ai.generated/09-authentication-guide.md b/docs/ai.generated/09-authentication-guide.md index 6bae001f..bba6fd49 100644 --- a/docs/ai.generated/09-authentication-guide.md +++ b/docs/ai.generated/09-authentication-guide.md @@ -460,6 +460,17 @@ Error responses typically include a `message` field, but the structure varies by **Recommended error handling**: Check for both `error` and `message` fields when parsing error responses. +### Environment Access Denied (403) + +When the server is configured with `COGNITO_ENVIRONMENT_ID`, an additional access control check runs after JWT validation. Users whose Cognito `custom:environment_id` attribute does not match the server's configured value will receive a `403 Forbidden` response with the message `"environment access denied"`. This applies to all authenticated endpoints. + +Exceptions: +- Users with the `super_admin` role bypass environment checks entirely. +- Users with no `custom:environment_id` attribute (legacy/untagged users) are allowed through for backward compatibility. +- If the server has no `COGNITO_ENVIRONMENT_ID` set, this check is skipped entirely. + +The environment check result is cached per user for 30 minutes. If a user's environment assignment changes, the new assignment takes effect after the cache expires. + ### Handling Authentication Errors (401) ```typescript diff --git a/docs/ai.generated/software-architecture.md b/docs/ai.generated/software-architecture.md index 0bb7ded3..226612f3 100644 --- a/docs/ai.generated/software-architecture.md +++ b/docs/ai.generated/software-architecture.md @@ -159,6 +159,7 @@ queryapi.RegisterHandlers(cfg.Router, cons) | `permitio.go` | Permit.io authorization client | | `token.go` | JWT token operations (refresh, validation) | | `validation.go` | Token signature and claims validation | +| `envcheck.go` | Environment access control check with in-memory cache (30min TTL) | **Authentication Flow**: ```mermaid diff --git a/internal/cognitoauth/auth.go b/internal/cognitoauth/auth.go index 171eafe9..8aa2c43c 100644 --- a/internal/cognitoauth/auth.go +++ b/internal/cognitoauth/auth.go @@ -5,12 +5,12 @@ import ( "fmt" "net/http" "os" + "strings" "time" "queryorchestration/internal/serviceconfig/auth" - "strings" - + "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider" "github.com/labstack/echo/v4" "github.com/lestrrat-go/jwx/v2/jwt" ) @@ -18,11 +18,14 @@ import ( // RegisterRoutes registers all authentication-related routes to the Echo engine. // It configures login and callback endpoints, and applies JWT authentication middleware. // If the DISABLE_AUTH environment variable is set to "true", authentication is bypassed. +// When cognitoClient is provided and COGNITO_ENVIRONMENT_ID is configured, environment +// access control is enforced after token validation. // // Parameters: // - e: The Echo instance to register routes on // - config: Configuration provider for authentication settings -func RegisterRoutes(e *echo.Echo, config auth.ConfigProvider) { +// - cognitoClient: AWS Cognito client for environment access checks (may be nil) +func RegisterRoutes(e *echo.Echo, config auth.ConfigProvider, cognitoClient *cognitoidentityprovider.Client) { // check the auth feature flag fmt.Println("Registering routes for Auth.") disableAuth := os.Getenv("DISABLE_AUTH") @@ -57,8 +60,8 @@ func RegisterRoutes(e *echo.Echo, config auth.ConfigProvider) { // Apply the JWT Auth middleware first e.Use(JWTAuthMiddleware(config)) - // Then apply the token validation middleware - e.Use(TokenValidationMiddleware(config)) + // Then apply the token validation middleware (with optional environment access control) + e.Use(TokenValidationMiddleware(config, cognitoClient)) } // GetTokenFromRequest extracts the JWT token from the HTTP request. diff --git a/internal/cognitoauth/cognitotest/auth_full_test.go b/internal/cognitoauth/cognitotest/auth_full_test.go index ed4da414..420629e2 100644 --- a/internal/cognitoauth/cognitotest/auth_full_test.go +++ b/internal/cognitoauth/cognitotest/auth_full_test.go @@ -79,7 +79,7 @@ func setupTestServer(t *testing.T) (*http.Server, *auth.CognitoConfig) { config.SetAuthRoutePermissions(routePermissions) // Register Cognito auth routes and middleware - cognitoauth.RegisterRoutes(e, config) + cognitoauth.RegisterRoutes(e, config, nil) // Add test endpoints e.GET("/home", func(c echo.Context) error { diff --git a/internal/cognitoauth/cognitotest/auto_refresh_test.go b/internal/cognitoauth/cognitotest/auto_refresh_test.go index 70f47aab..1fbf5ad8 100644 --- a/internal/cognitoauth/cognitotest/auto_refresh_test.go +++ b/internal/cognitoauth/cognitotest/auto_refresh_test.go @@ -164,7 +164,7 @@ func setupTestServerForRefresh(t *testing.T) (*http.Server, *auth.CognitoConfig) config.SetAuthRoutePermissions(routePermissions) // Register Cognito auth routes and middleware - cognitoauth.RegisterRoutes(e, config) + cognitoauth.RegisterRoutes(e, config, nil) // Add test endpoints e.GET("/home", func(c echo.Context) error { diff --git a/internal/cognitoauth/envcheck.go b/internal/cognitoauth/envcheck.go new file mode 100644 index 00000000..b35199e8 --- /dev/null +++ b/internal/cognitoauth/envcheck.go @@ -0,0 +1,184 @@ +// envcheck.go provides environment access control for authenticated requests. +// When COGNITO_ENVIRONMENT_ID is configured, it verifies that the authenticated user +// belongs to the same environment as this server instance. Super-admins bypass this check. +package cognitoauth + +import ( + "context" + "net/http" + "os" + "sync" + "time" + + "queryorchestration/internal/serviceconfig/auth" + "queryorchestration/internal/usermanagement" + + "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider" + "github.com/labstack/echo/v4" +) + +// EnvCheckCacheTTL is the duration that environment check results are cached per user. +const EnvCheckCacheTTL = 30 * time.Minute + +// envCheckCacheEntry stores a cached environment check result for a single user. +type envCheckCacheEntry struct { + allowed bool + expiresAt time.Time +} + +// envCheckCache stores cached environment check results to avoid repeated Cognito lookups. +type envCheckCache struct { + mu sync.RWMutex + entries map[string]envCheckCacheEntry +} + +// globalEnvCheckCache is the package-level cache shared across all calls. +var globalEnvCheckCache = &envCheckCache{ + entries: make(map[string]envCheckCacheEntry), +} + +// get retrieves a cached result for the given user sub. Returns the allowed value and +// true if a valid (non-expired) entry exists, or false if not found or expired. +func (c *envCheckCache) get(sub string) (bool, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + entry, ok := c.entries[sub] + if !ok { + return false, false + } + if time.Now().After(entry.expiresAt) { + return false, false + } + return entry.allowed, true +} + +// set stores a cached result for the given user sub with the configured TTL. +func (c *envCheckCache) set(sub string, allowed bool) { + c.mu.Lock() + defer c.mu.Unlock() + + c.entries[sub] = envCheckCacheEntry{ + allowed: allowed, + expiresAt: time.Now().Add(EnvCheckCacheTTL), + } +} + +// CheckUserEnvironmentAccess verifies that the authenticated user belongs to the server's +// configured environment. If COGNITO_ENVIRONMENT_ID is not set, all users are allowed. +// Results are cached per user sub for EnvCheckCacheTTL to avoid repeated API calls. +// +// The check flow is: +// 1. If no server environment ID is configured, allow all users (return nil) +// 2. Extract user sub from echo context ("user_claims") +// 3. Check cache for existing result +// 4. On cache miss, call GetCognitoUser to retrieve the user's custom:environment_id +// 5. If environment matches or user is untagged (legacy), allow and cache +// 6. If mismatch, check Permit.io for super_admin role (super_admins bypass env checks) +// 7. Cache and return the result +// +// Parameters: +// - c: Echo context with user_claims set by auth middleware +// - config: Auth configuration provider with environment ID and Permit.io API key +// - cognitoClient: AWS Cognito client for user attribute lookup +// +// Returns: +// - nil if the user is allowed access +// - echo.NewHTTPError(403) if the user is denied +func CheckUserEnvironmentAccess( + c echo.Context, + config auth.ConfigProvider, + cognitoClient *cognitoidentityprovider.Client, +) error { + serverEnvID := config.GetCognitoEnvironmentID() + if serverEnvID == "" { + return nil + } + + // Extract user sub from claims + sub := extractSubFromContext(c) + if sub == "" { + return echo.NewHTTPError(http.StatusForbidden, "missing user identity") + } + + // Check cache + if allowed, found := globalEnvCheckCache.get(sub); found { + if allowed { + return nil + } + return echo.NewHTTPError(http.StatusForbidden, "environment access denied") + } + + // Cache miss -- look up user's environment from Cognito + user, err := usermanagement.GetCognitoUser(context.Background(), cognitoClient, config.GetAuthUserPoolID(), sub) + if err != nil { + // If we can't look up the user, deny access + return echo.NewHTTPError(http.StatusForbidden, "failed to verify environment access") + } + + // Check if user belongs to this environment + if usermanagement.UserBelongsToEnvironment(user, serverEnvID) { + globalEnvCheckCache.set(sub, true) + return nil + } + + // Environment mismatch -- check if user has super_admin role in Permit.io + if hasSuperAdmin(config, user.SubjectID) { + globalEnvCheckCache.set(sub, true) + return nil + } + + // Denied + globalEnvCheckCache.set(sub, false) + return echo.NewHTTPError(http.StatusForbidden, "environment access denied") +} + +// extractSubFromContext extracts the user's subject ID from the echo context's user_claims. +func extractSubFromContext(c echo.Context) string { + claims, ok := c.Get("user_claims").(map[string]interface{}) + if !ok { + return "" + } + sub, ok := claims["sub"].(string) + if !ok { + return "" + } + return sub +} + +// hasSuperAdmin checks if the user has the super_admin role in Permit.io. +// Returns true if the user is a super_admin, false otherwise (including on errors). +func hasSuperAdmin(config auth.ConfigProvider, userKey string) bool { + permitConfig := &usermanagement.PermitConfig{ + APIKey: config.GetPermitIOAPIKey(), + BaseURL: getPermitBaseURL(), + } + + httpClient := &http.Client{Timeout: 10 * time.Second} + roles, err := usermanagement.GetPermitUserRoles(httpClient, permitConfig, userKey) + if err != nil { + return false + } + + for _, role := range roles { + if role == "super_admin" { + return true + } + } + return false +} + +// getPermitBaseURL returns the Permit.io API base URL from environment or default. +func getPermitBaseURL() string { + if url := os.Getenv("PERMIT_IO_BASE_URL"); url != "" { + return url + } + return "https://api.permit.io" +} + +// ResetEnvCheckCache clears the environment check cache. Intended for testing. +func ResetEnvCheckCache() { + globalEnvCheckCache.mu.Lock() + defer globalEnvCheckCache.mu.Unlock() + globalEnvCheckCache.entries = make(map[string]envCheckCacheEntry) +} diff --git a/internal/cognitoauth/envcheck_test.go b/internal/cognitoauth/envcheck_test.go new file mode 100644 index 00000000..ac3409ea --- /dev/null +++ b/internal/cognitoauth/envcheck_test.go @@ -0,0 +1,422 @@ +package cognitoauth + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "queryorchestration/internal/usermanagement" +) + +// roundTripFunc is a function adapter for http.RoundTripper. +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +// newTestCognitoClientForEnvCheck creates a Cognito client with a custom HTTP transport. +func newTestCognitoClientForEnvCheck(t *testing.T, rt roundTripFunc) *cognitoidentityprovider.Client { + t.Helper() + + cfg, err := awsconfig.LoadDefaultConfig(t.Context(), + awsconfig.WithRegion("us-east-1"), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("AKID", "SECRET", "SESSION")), + awsconfig.WithHTTPClient(&http.Client{Transport: rt}), + ) + require.NoError(t, err) + + return cognitoidentityprovider.NewFromConfig(cfg) +} + +// jsonResp builds an HTTP response with a JSON body. +func jsonResp(statusCode int, body interface{}) *http.Response { + b, _ := json.Marshal(body) + return &http.Response{ + StatusCode: statusCode, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(string(b))), + } +} + +// envCheckMockConfig extends mockConfigProvider to support a configurable environment ID. +type envCheckMockConfig struct { + mockConfigProvider + envID string +} + +// GetCognitoEnvironmentID returns the configured environment ID. +func (m *envCheckMockConfig) GetCognitoEnvironmentID() string { + return m.envID +} + +// SetCognitoEnvironmentID sets the environment ID. +func (m *envCheckMockConfig) SetCognitoEnvironmentID(id string) { + m.envID = id +} + +// createEnvCheckEchoContext builds an echo.Context with user_claims set for testing. +func createEnvCheckEchoContext(sub string) echo.Context { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/api/test", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.Set("user_claims", map[string]interface{}{ + "sub": sub, + }) + return c +} + +// TestCheckUserEnvironmentAccess_NoServerEnv verifies that when the server has no +// COGNITO_ENVIRONMENT_ID set, all users pass the environment check. +func TestCheckUserEnvironmentAccess_NoServerEnv(t *testing.T) { + config := &envCheckMockConfig{ + mockConfigProvider: mockConfigProvider{ + region: "us-east-1", + userPoolID: "us-east-1_TestPool", + }, + envID: "", // no environment set + } + + cognitoClient := newTestCognitoClientForEnvCheck(t, func(req *http.Request) (*http.Response, error) { + t.Fatal("Cognito should NOT be called when server has no environment ID") + return nil, nil + }) + + c := createEnvCheckEchoContext("sub-123") + + err := CheckUserEnvironmentAccess(c, config, cognitoClient) + assert.NoError(t, err, "all users should pass when server has no environment ID") +} + +// TestCheckUserEnvironmentAccess_UserEnvMatchesServer verifies that when the user's +// environment_id matches the server's, the check passes. +func TestCheckUserEnvironmentAccess_UserEnvMatchesServer(t *testing.T) { + config := &envCheckMockConfig{ + mockConfigProvider: mockConfigProvider{ + region: "us-east-1", + userPoolID: "us-east-1_TestPool", + }, + envID: "acmehealth", + } + + cognitoClient := newTestCognitoClientForEnvCheck(t, func(req *http.Request) (*http.Response, error) { + resp := map[string]interface{}{ + "Username": "user@acme.com", + "Enabled": true, + "UserStatus": "CONFIRMED", + "UserAttributes": []map[string]string{ + {"Name": "sub", "Value": "sub-match"}, + {"Name": "email", "Value": "user@acme.com"}, + {"Name": usermanagement.CognitoEnvIDAttrName, "Value": "acmehealth"}, + }, + } + return jsonResp(200, resp), nil + }) + + c := createEnvCheckEchoContext("sub-match") + + err := CheckUserEnvironmentAccess(c, config, cognitoClient) + assert.NoError(t, err, "user with matching environment should pass") +} + +// TestCheckUserEnvironmentAccess_UntaggedUser verifies that an untagged user +// (no environment_id attribute) passes the check (backward compatibility). +func TestCheckUserEnvironmentAccess_UntaggedUser(t *testing.T) { + config := &envCheckMockConfig{ + mockConfigProvider: mockConfigProvider{ + region: "us-east-1", + userPoolID: "us-east-1_TestPool", + }, + envID: "acmehealth", + } + + cognitoClient := newTestCognitoClientForEnvCheck(t, func(req *http.Request) (*http.Response, error) { + resp := map[string]interface{}{ + "Username": "legacy@acme.com", + "Enabled": true, + "UserStatus": "CONFIRMED", + "UserAttributes": []map[string]string{ + {"Name": "sub", "Value": "sub-legacy"}, + {"Name": "email", "Value": "legacy@acme.com"}, + // No environment_id attribute + }, + } + return jsonResp(200, resp), nil + }) + + c := createEnvCheckEchoContext("sub-legacy") + + err := CheckUserEnvironmentAccess(c, config, cognitoClient) + assert.NoError(t, err, "untagged user should pass (backward compat)") +} + +// TestCheckUserEnvironmentAccess_MismatchNotSuperAdmin verifies that a user with a +// mismatched environment_id who is NOT a super_admin gets a 403 error. +func TestCheckUserEnvironmentAccess_MismatchNotSuperAdmin(t *testing.T) { + // Set up Permit.io mock server for role check + permitMux := http.NewServeMux() + permitMux.HandleFunc("/v2/api-key/scope", func(w http.ResponseWriter, r *http.Request) { + resp := map[string]string{ + "organization_id": "test-org", + "project_id": "test-project", + "environment_id": "test-env", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + }) + permitMux.HandleFunc("/v2/facts/test-project/test-env/role_assignments", func(w http.ResponseWriter, r *http.Request) { + // User has "editor" role, NOT super_admin + resp := []map[string]interface{}{ + {"role": "editor", "user": "sub-mismatch", "tenant": "default"}, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + }) + permitServer := httptest.NewServer(permitMux) + defer permitServer.Close() + + t.Setenv("PERMIT_IO_BASE_URL", permitServer.URL) + + config := &envCheckMockConfig{ + mockConfigProvider: mockConfigProvider{ + region: "us-east-1", + userPoolID: "us-east-1_TestPool", + }, + envID: "acmehealth", + } + + cognitoClient := newTestCognitoClientForEnvCheck(t, func(req *http.Request) (*http.Response, error) { + resp := map[string]interface{}{ + "Username": "user@ford.com", + "Enabled": true, + "UserStatus": "CONFIRMED", + "UserAttributes": []map[string]string{ + {"Name": "sub", "Value": "sub-mismatch"}, + {"Name": "email", "Value": "user@ford.com"}, + {"Name": usermanagement.CognitoEnvIDAttrName, "Value": "fordmotorco"}, + }, + } + return jsonResp(200, resp), nil + }) + + c := createEnvCheckEchoContext("sub-mismatch") + + err := CheckUserEnvironmentAccess(c, config, cognitoClient) + assert.Error(t, err, "user with mismatched environment should be denied") +} + +// TestCheckUserEnvironmentAccess_MismatchButSuperAdmin verifies that a super_admin user +// with a mismatched environment_id is allowed through (super_admins bypass env checks). +func TestCheckUserEnvironmentAccess_MismatchButSuperAdmin(t *testing.T) { + // Set up Permit.io mock server for role check + permitMux := http.NewServeMux() + permitMux.HandleFunc("/v2/api-key/scope", func(w http.ResponseWriter, r *http.Request) { + resp := map[string]string{ + "organization_id": "test-org", + "project_id": "test-project", + "environment_id": "test-env", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + }) + permitMux.HandleFunc("/v2/facts/test-project/test-env/role_assignments", func(w http.ResponseWriter, r *http.Request) { + // User has super_admin role + resp := []map[string]interface{}{ + {"role": "super_admin", "user": "sub-superadmin", "tenant": "default"}, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + }) + permitServer := httptest.NewServer(permitMux) + defer permitServer.Close() + + t.Setenv("PERMIT_IO_BASE_URL", permitServer.URL) + + config := &envCheckMockConfig{ + mockConfigProvider: mockConfigProvider{ + region: "us-east-1", + userPoolID: "us-east-1_TestPool", + }, + envID: "acmehealth", + } + + cognitoClient := newTestCognitoClientForEnvCheck(t, func(req *http.Request) (*http.Response, error) { + resp := map[string]interface{}{ + "Username": "admin@ford.com", + "Enabled": true, + "UserStatus": "CONFIRMED", + "UserAttributes": []map[string]string{ + {"Name": "sub", "Value": "sub-superadmin"}, + {"Name": "email", "Value": "admin@ford.com"}, + {"Name": usermanagement.CognitoEnvIDAttrName, "Value": "fordmotorco"}, + }, + } + return jsonResp(200, resp), nil + }) + + c := createEnvCheckEchoContext("sub-superadmin") + + err := CheckUserEnvironmentAccess(c, config, cognitoClient) + assert.NoError(t, err, "super_admin should bypass environment check") +} + +// TestCheckUserEnvironmentAccess_CacheHit verifies that repeated calls for the same +// user sub use the cache and do NOT make additional Cognito/Permit.io API calls. +func TestCheckUserEnvironmentAccess_CacheHit(t *testing.T) { + config := &envCheckMockConfig{ + mockConfigProvider: mockConfigProvider{ + region: "us-east-1", + userPoolID: "us-east-1_TestPool", + }, + envID: "acmehealth", + } + + callCount := 0 + cognitoClient := newTestCognitoClientForEnvCheck(t, func(req *http.Request) (*http.Response, error) { + callCount++ + resp := map[string]interface{}{ + "Username": "user@acme.com", + "Enabled": true, + "UserStatus": "CONFIRMED", + "UserAttributes": []map[string]string{ + {"Name": "sub", "Value": "sub-cached"}, + {"Name": "email", "Value": "user@acme.com"}, + {"Name": usermanagement.CognitoEnvIDAttrName, "Value": "acmehealth"}, + }, + } + return jsonResp(200, resp), nil + }) + + // First call -- should hit Cognito + c1 := createEnvCheckEchoContext("sub-cached") + err := CheckUserEnvironmentAccess(c1, config, cognitoClient) + assert.NoError(t, err) + firstCallCount := callCount + + // Second call with same sub -- should use cache, NOT call Cognito again + c2 := createEnvCheckEchoContext("sub-cached") + err = CheckUserEnvironmentAccess(c2, config, cognitoClient) + assert.NoError(t, err) + + assert.Equal(t, firstCallCount, callCount, + "second call should use cache and not make additional Cognito API calls") +} + +// TestCheckUserEnvironmentAccess_MissingSub verifies that when user_claims has no +// "sub" field, CheckUserEnvironmentAccess returns 403 without calling Cognito. +func TestCheckUserEnvironmentAccess_MissingSub(t *testing.T) { + ResetEnvCheckCache() + + config := &envCheckMockConfig{ + mockConfigProvider: mockConfigProvider{ + region: "us-east-1", + userPoolID: "us-east-1_TestPool", + }, + envID: "acmehealth", + } + + // Cognito client that fatals if called -- proves no Cognito call is made + cognitoClient := newTestCognitoClientForEnvCheck(t, func(req *http.Request) (*http.Response, error) { + t.Fatal("Cognito should NOT be called when sub is missing") + return nil, nil + }) + + // Create context with empty user_claims (no sub) + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/api/test", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.Set("user_claims", map[string]interface{}{}) + + err := CheckUserEnvironmentAccess(c, config, cognitoClient) + require.Error(t, err, "Missing sub should return an error") + assert.Contains(t, err.Error(), "missing user identity") +} + +// TestCheckUserEnvironmentAccess_NegativeCacheHit verifies that a denied user is +// cached and subsequent calls do NOT make additional Cognito API calls. +func TestCheckUserEnvironmentAccess_NegativeCacheHit(t *testing.T) { + ResetEnvCheckCache() + + // Set up Permit.io mock -- user has "editor" role, NOT super_admin + permitMux := http.NewServeMux() + permitMux.HandleFunc("/v2/api-key/scope", func(w http.ResponseWriter, r *http.Request) { + resp := map[string]string{ + "organization_id": "test-org", + "project_id": "test-project", + "environment_id": "test-env", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + }) + permitMux.HandleFunc("/v2/facts/test-project/test-env/role_assignments", func(w http.ResponseWriter, r *http.Request) { + resp := []map[string]interface{}{ + {"role": "editor", "user": "sub-negcache", "tenant": "default"}, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + }) + permitServer := httptest.NewServer(permitMux) + defer permitServer.Close() + t.Setenv("PERMIT_IO_BASE_URL", permitServer.URL) + + config := &envCheckMockConfig{ + mockConfigProvider: mockConfigProvider{ + region: "us-east-1", + userPoolID: "us-east-1_TestPool", + }, + envID: "acmehealth", + } + + cognitoCallCount := 0 + cognitoClient := newTestCognitoClientForEnvCheck(t, func(req *http.Request) (*http.Response, error) { + cognitoCallCount++ + resp := map[string]interface{}{ + "Username": "user@ford.com", + "Enabled": true, + "UserStatus": "CONFIRMED", + "UserAttributes": []map[string]string{ + {"Name": "sub", "Value": "sub-negcache"}, + {"Name": "email", "Value": "user@ford.com"}, + {"Name": usermanagement.CognitoEnvIDAttrName, "Value": "fordmotorco"}, + }, + } + return jsonResp(200, resp), nil + }) + + // First call -- should call Cognito, get denied, and cache the denial + c1 := createEnvCheckEchoContext("sub-negcache") + err := CheckUserEnvironmentAccess(c1, config, cognitoClient) + assert.Error(t, err, "First call should be denied (env mismatch, not super_admin)") + assert.Equal(t, 1, cognitoCallCount, "First call should hit Cognito once") + + // Second call with same sub -- should use negative cache, NOT call Cognito + c2 := createEnvCheckEchoContext("sub-negcache") + err = CheckUserEnvironmentAccess(c2, config, cognitoClient) + assert.Error(t, err, "Second call should also be denied (from cache)") + assert.Contains(t, err.Error(), "environment access denied") + assert.Equal(t, 1, cognitoCallCount, "Second call should use cache, not call Cognito again") +} + +// TestEnvCheckCacheTTL verifies the cache TTL constant is set to 30 minutes. +func TestEnvCheckCacheTTL(t *testing.T) { + assert.Equal(t, 30*60, int(EnvCheckCacheTTL.Seconds()), + "cache TTL should be 30 minutes") +} diff --git a/internal/cognitoauth/middleware.go b/internal/cognitoauth/middleware.go index a0e82bf1..e8355cf0 100644 --- a/internal/cognitoauth/middleware.go +++ b/internal/cognitoauth/middleware.go @@ -3,12 +3,12 @@ package cognitoauth import ( "fmt" "net/http" + "strings" "time" "queryorchestration/internal/serviceconfig/auth" - "strings" - + "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider" "github.com/labstack/echo/v4" "github.com/lestrrat-go/jwx/v2/jwk" ) @@ -364,7 +364,7 @@ func verifyTokenAndSetContext(c echo.Context, tokenStr string, config auth.Confi // // Returns: // - echo.MiddlewareFunc: An Echo middleware function that can be added to the middleware chain -func TokenValidationMiddleware(config auth.ConfigProvider) echo.MiddlewareFunc { +func TokenValidationMiddleware(config auth.ConfigProvider, cognitoClient *cognitoidentityprovider.Client) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { requestPath := c.Request().URL.Path @@ -399,6 +399,13 @@ func TokenValidationMiddleware(config auth.ConfigProvider) echo.MiddlewareFunc { return err } + // Check environment access when COGNITO_ENVIRONMENT_ID is configured + if cognitoClient != nil { + if err := CheckUserEnvironmentAccess(c, config, cognitoClient); err != nil { + return err + } + } + // Perform Permit.io authorization if err := performPermitIOAuthorization(c, requestPath, config); err != nil { return err diff --git a/internal/cognitoauth/middleware_test.go b/internal/cognitoauth/middleware_test.go index 0dcaa70f..2889644f 100644 --- a/internal/cognitoauth/middleware_test.go +++ b/internal/cognitoauth/middleware_test.go @@ -199,7 +199,7 @@ func TestTokenValidationMiddleware(t *testing.T) { config.SetAuthLogger(logger) // Create middleware - middleware := TokenValidationMiddleware(config) + middleware := TokenValidationMiddleware(config, nil) // Test handler that simply returns success testHandler := func(c echo.Context) error { @@ -313,6 +313,54 @@ func TestTokenValidationMiddleware(t *testing.T) { } } +// TestTokenValidationMiddleware_NilClientWithEnvConfigured documents the fail-open behavior +// when COGNITO_ENVIRONMENT_ID is configured but the Cognito client is nil (e.g., AWS config +// failed at startup). In this scenario, the environment check at middleware.go:403 is silently +// skipped because the guard `if cognitoClient != nil` evaluates to false. +// +// SECURITY NOTE: This is a known fail-open behavior. If COGNITO_ENVIRONMENT_ID is set but +// the Cognito client cannot be initialized (bad AWS credentials, network issue), the +// environment isolation check is bypassed while other security layers (JWT validation, +// Permit.io authorization) still operate. This means a user with a valid JWT from a +// different environment could access this server's resources. +func TestTokenValidationMiddleware_NilClientWithEnvConfigured(t *testing.T) { + e := echo.New() + + // Config has environment ID set, simulating a deployment with env isolation enabled + config := &envCheckMockConfig{ + mockConfigProvider: mockConfigProvider{ + region: "us-east-2", + userPoolID: "us-east-2_testpool", + }, + envID: "acmehealth", + } + logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}) + config.SetAuthLogger(slog.New(logHandler)) + + // cognitoClient is nil -- simulating AWS config failure at startup + middleware := TokenValidationMiddleware(config, nil) + + testHandler := func(c echo.Context) error { + return c.String(http.StatusOK, "success") + } + + // A request to a protected path without auth gets 401 (token validation), + // NOT a 403 from the env check. The env check is never reached because + // cognitoClient is nil. + req := httptest.NewRequest(http.MethodGet, "/api/test", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/api/test") + + _ = middleware(testHandler)(c) + + // The request fails at token validation (401), not env check (403). + // This confirms the env check is skipped when cognitoClient is nil. + if rec.Code != http.StatusUnauthorized { + t.Errorf("Expected 401 (auth required), got %d -- env check should not trigger with nil client", rec.Code) + } +} + // TestIsAuthOnlyPath verifies that certain paths bypass authorization while still requiring authentication. // The /identity endpoint should be accessible to all authenticated users regardless of their Permit.io roles, // allowing them to discover their own roles/permissions. diff --git a/internal/cognitoauth/readme.md b/internal/cognitoauth/readme.md index 58ff040c..2f162a8c 100644 --- a/internal/cognitoauth/readme.md +++ b/internal/cognitoauth/readme.md @@ -19,6 +19,7 @@ A reusable Go package for AWS Cognito authentication and Permit.io authorization - Middleware for token handling and permission enforcement - Cookie-based token storage with refresh token support - Default home page with authentication status +- Multi-environment user isolation via `COGNITO_ENVIRONMENT_ID` - Simple integration with Echo framework ## Feature flag for Auth @@ -35,11 +36,12 @@ The package reads configuration from environment variables: ### AWS Cognito (Authentication) - `COGNITO_CLIENT_ID`: Your AWS Cognito App Client ID -- `COGNITO_CLIENT_SECRET`: Your AWS Cognito App Client Secret +- `COGNITO_CLIENT_SECRET`: Your AWS Cognito App Client Secret - `COGNITO_USER_POOL_ID`: Your AWS Cognito User Pool ID - `COGNITO_DOMAIN`: Your AWS Cognito domain - `AWS_REGION`: AWS region where your Cognito User Pool is located (default: us-east-2) - `COGNITO_REGION`: AWS region for Cognito service (default: us-east-2) +- `COGNITO_ENVIRONMENT_ID`: Optional. Tags this server instance with an environment identifier for multi-environment user isolation. See [Multi-Environment User Isolation](#multi-environment-user-isolation). ### Permit.io (Authorization) - `PERMIT_IO_API_KEY`: Your Permit.io API key for accessing the PDP @@ -257,4 +259,108 @@ HTTP methods are mapped to lowercase Permit.io actions: 4. Route is mapped to resource name (first path segment) 5. HTTP method is mapped to action name 6. Permit.io PDP is queried: `CheckPermission(user, action, resource)` -7. Request is allowed/denied based on PDP response \ No newline at end of file +7. Request is allowed/denied based on PDP response + +## Multi-Environment User Isolation + +### Overview + +The platform supports hosting multiple customer environments in a **shared Cognito user pool**. Each server instance is tagged with a `COGNITO_ENVIRONMENT_ID` to scope its user visibility. Users are tagged with a `custom:environment_id` attribute in their Cognito profile, and all user operations are filtered to only show users belonging to the current server's environment. + +For example, a server configured with `COGNITO_ENVIRONMENT_ID=acmehealth` will only see users tagged `acmehealth` and untagged (legacy) users, but will never see users tagged `fordmotorco`. + +### Environment Variable + +- **`COGNITO_ENVIRONMENT_ID`**: Optional. When set, scopes all user operations to this environment. When not set, the system operates in "unscoped" mode where all users are visible (backward compatible). + +#### Naming Rules + +Values for `COGNITO_ENVIRONMENT_ID` must follow these rules: + +| Rule | Detail | +|------|--------| +| **Start character** | Must start with a lowercase alpha character (`a-z`) | +| **End character** | Must end with a lowercase alphanumeric character (`a-z`, `0-9`) | +| **Allowed characters** | Lowercase alphanumeric (`a-z`, `0-9`) and underscores (`_`) | +| **Underscore position** | Never in first or last position, no consecutive underscores | +| **Case** | All lowercase enforced | +| **Length** | 1 to 50 characters | +| **Empty string** | Valid -- means "not set" | + +Valid examples: `acme`, `client1`, `acme_health`, `a_b_c`, `a` + +Invalid examples: `1client` (starts with digit), `Acme` (uppercase), `_acme` (starts with underscore), `acme_` (ends with underscore), `acme__health` (consecutive underscores) + +The value is validated at startup. If the format is invalid, the server logs an error and halts. + +### Access Control Rules + +When `COGNITO_ENVIRONMENT_ID` is set on the server, the `TokenValidationMiddleware` performs an environment access check after JWT validation and before Permit.io authorization. The rules are: + +| User Condition | Result | +|----------------|--------| +| User's `custom:environment_id` matches the server's value | Allowed | +| User has no `custom:environment_id` attribute (legacy/untagged) | Allowed (backward compatible) | +| User's `custom:environment_id` does not match the server's value | Blocked with `403 Forbidden` | +| User has the `super_admin` role in Permit.io | Allowed regardless of environment tag | +| Server has no `COGNITO_ENVIRONMENT_ID` set | No check performed (backward compatible) | + +The `super_admin` bypass exists because super admins need to operate across environments for administrative tasks. + +#### Caching + +The environment access check uses an in-memory cache (keyed by user `sub`, TTL 30 minutes via `EnvCheckCacheTTL`) to avoid per-request Cognito and Permit.io API calls. This means changes to a user's `custom:environment_id` attribute or `super_admin` role assignment take up to 30 minutes to take effect. + +#### Environment Mismatch Error + +When a non-super-admin user with a mismatched environment ID attempts access: + +```json +{ + "message": "environment access denied" +} +``` +- **Status Code**: `403 Forbidden` + +### Data Filtering + +All user list and query operations filter results to show only users belonging to the current environment: + +- **Admin user list endpoints**: Results are filtered to include only users whose `custom:environment_id` matches the server's value or is empty (untagged) +- **Admin user CRUD endpoints** (get, update, delete, disable, enable): Return `404 Not Found` (not `403`) for users in other environments to avoid information leakage +- **New user creation**: Users created via admin API endpoints are automatically tagged with the server's `COGNITO_ENVIRONMENT_ID` +- **EULA compliance reports**: User lists are filtered by environment before generating compliance data + +Users from other environments are never visible in any API response. + +### Health Endpoint + +When `COGNITO_ENVIRONMENT_ID` is configured, the `/health` endpoint response includes an additional field: + +```json +{ + "status": "healthy", + "version": "1.2.3", + "buildTime": "2025-01-15T10:00:00Z", + "commit": "abc1234", + "cognitoEnvironmentId": "acmehealth" +} +``` + +The `cognitoEnvironmentId` field is omitted when the environment variable is not set. + +### Auto-Registration of Custom Attribute + +At startup, the queryAPI automatically ensures the `custom:environment_id` attribute is registered on the Cognito user pool. This makes new environment deployments self-configuring with no manual AWS CLI commands needed. + +The registration process: +1. Calls `DescribeUserPool` to check if `custom:environment_id` already exists in the pool's schema +2. If not found, calls `AddCustomAttributes` to register it (String type, Mutable, MaxLength 50) +3. If already registered, skips silently (debug-level log) +4. If registration fails, the server halts (the attribute is required for correct operation) + +**IAM permissions required**: The IAM role or user running the queryAPI needs `cognito-idp:DescribeUserPool` and `cognito-idp:AddCustomAttributes` permissions on the user pool. + +### Permit.io Attribute Sync + +When users are created via the admin API or the user creation tool, the `environment_id` value is also stored as a custom attribute on the corresponding Permit.io user object. This keeps Permit.io user records consistent with Cognito for audit and policy purposes. \ No newline at end of file diff --git a/internal/cognitoauth/token_test.go b/internal/cognitoauth/token_test.go index e2da7646..62c97dc1 100644 --- a/internal/cognitoauth/token_test.go +++ b/internal/cognitoauth/token_test.go @@ -58,9 +58,11 @@ func (m *mockConfigProvider) SetAuthRoutePermissions(map[string][]string) {} func (m *mockConfigProvider) GetPermitIOAPIKey() string { return "permit_key_test_mock_key_for_testing_purposes_only" } -func (m *mockConfigProvider) SetPermitIOAPIKey(s string) {} -func (m *mockConfigProvider) GetPermitIOPDPURL() string { return "http://localhost:7766" } -func (m *mockConfigProvider) SetPermitIOPDPURL(s string) {} +func (m *mockConfigProvider) SetPermitIOAPIKey(s string) {} +func (m *mockConfigProvider) GetPermitIOPDPURL() string { return "http://localhost:7766" } +func (m *mockConfigProvider) SetPermitIOPDPURL(s string) {} +func (m *mockConfigProvider) GetCognitoEnvironmentID() string { return "" } +func (m *mockConfigProvider) SetCognitoEnvironmentID(string) {} func (m *mockConfigProvider) InitializeAuthConfig(string, *slog.Logger) error { return nil } diff --git a/internal/eula/service_cognito.go b/internal/eula/service_cognito.go index 292296e0..ccd64c7b 100644 --- a/internal/eula/service_cognito.go +++ b/internal/eula/service_cognito.go @@ -117,5 +117,9 @@ func (s *Service) fetchAllEnabledCognitoUsers( paginationToken = result.PaginationKey } - return allUsers, nil + // Filter users by environment ID if configured. When COGNITO_ENVIRONMENT_ID is set, + // only users belonging to this environment (or untagged legacy users) are included. + filtered := usermanagement.FilterUsersByEnvironmentID(allUsers, s.cfg.GetCognitoEnvironmentID()) + + return filtered, nil } diff --git a/internal/server/api/listener.go b/internal/server/api/listener.go index 24d9f705..dc26de97 100644 --- a/internal/server/api/listener.go +++ b/internal/server/api/listener.go @@ -17,12 +17,14 @@ import ( "queryorchestration/internal/cognitoauth" "queryorchestration/internal/document/batch" documentupload "queryorchestration/internal/document/upload" + awsc "queryorchestration/internal/serviceconfig/aws" "queryorchestration/internal/serviceconfig/build" "queryorchestration/internal/serviceconfig/objectstore" "queryorchestration/internal/serviceconfig/ratelimit" "queryorchestration/internal/server" + "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider" "github.com/getkin/kin-openapi/openapi3" "github.com/getkin/kin-openapi/openapi3filter" "github.com/google/uuid" @@ -290,12 +292,16 @@ func New(ctx context.Context, cfg Config) (*Server, error) { // Health endpoint with version information e.GET("/health", func(c echo.Context) error { - return c.JSON(http.StatusOK, map[string]interface{}{ + healthResponse := map[string]interface{}{ "status": "healthy", "version": build.GetVersion(), "buildTime": build.GetBuildTime(), "commit": build.GetGitCommit(), - }) + } + if envID := cfg.GetCognitoEnvironmentID(); envID != "" { + healthResponse["cognitoEnvironmentId"] = envID + } + return c.JSON(http.StatusOK, healthResponse) }) e.Use(getSlogCustomEchoMiddleware(cfg.GetLogger())) @@ -328,9 +334,20 @@ func New(ctx context.Context, cfg Config) (*Server, error) { "overrides_count", len(rateLimitConfig.EndpointOverrides), ) + // Create Cognito client for environment access control in auth middleware + var cognitoClient *cognitoidentityprovider.Client + if envID := cfg.GetCognitoEnvironmentID(); envID != "" { + awsCfg, awsErr := awsc.GetAWSConfig(ctx) + if awsErr != nil { + logger.Warn("Failed to create AWS config for environment access control", "error", awsErr) + } else { + cognitoClient = cognitoidentityprovider.NewFromConfig(awsCfg) + } + } + // NOW register auth routes (after rate limiting to protect auth endpoints) // auth start - using Permit.io for authorization - cognitoauth.RegisterRoutes(cfg.GetRouter(), cfg) + cognitoauth.RegisterRoutes(cfg.GetRouter(), cfg, cognitoClient) // auth end // Test user injection middleware - only active when DISABLE_AUTH=true diff --git a/internal/server/api/listener_test.go b/internal/server/api/listener_test.go index 0926312a..d1e38dae 100644 --- a/internal/server/api/listener_test.go +++ b/internal/server/api/listener_test.go @@ -268,6 +268,86 @@ func TestHealthEndpointWithVersionInfo(t *testing.T) { assert.Equal(t, build.GetGitCommit(), response["commit"], "Commit should match build.GetGitCommit()") } +// TestHealthEndpoint_IncludesEnvIDWhenConfigured verifies that the health endpoint +// includes the cognitoEnvironmentId field in the JSON response when +// COGNITO_ENVIRONMENT_ID is configured. This matches the conditional logic at +// listener.go:301-303. +func TestHealthEndpoint_IncludesEnvIDWhenConfigured(t *testing.T) { + e := echo.New() + + cfg := &BaseConfig{} + _ = serviceconfig.InitializeConfig(cfg) + cfg.CognitoEnvironmentID = "acmehealth" + + // Register health handler matching the real listener.go implementation + e.GET("/health", func(c echo.Context) error { + healthResponse := map[string]interface{}{ + "status": "healthy", + "version": build.GetVersion(), + "buildTime": build.GetBuildTime(), + "commit": build.GetGitCommit(), + } + if envID := cfg.GetCognitoEnvironmentID(); envID != "" { + healthResponse["cognitoEnvironmentId"] = envID + } + return c.JSON(http.StatusOK, healthResponse) + }) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var response map[string]interface{} + err := json.Unmarshal(rec.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, "acmehealth", response["cognitoEnvironmentId"], + "Health response should include cognitoEnvironmentId when configured") + assert.Equal(t, "healthy", response["status"]) +} + +// TestHealthEndpoint_OmitsEnvIDWhenNotConfigured verifies that the health endpoint +// does NOT include the cognitoEnvironmentId field when COGNITO_ENVIRONMENT_ID is +// empty (the default). This matches the conditional logic at listener.go:301-303. +func TestHealthEndpoint_OmitsEnvIDWhenNotConfigured(t *testing.T) { + e := echo.New() + + cfg := &BaseConfig{} + _ = serviceconfig.InitializeConfig(cfg) + cfg.CognitoEnvironmentID = "" // explicitly empty + + // Register health handler matching the real listener.go implementation + e.GET("/health", func(c echo.Context) error { + healthResponse := map[string]interface{}{ + "status": "healthy", + "version": build.GetVersion(), + "buildTime": build.GetBuildTime(), + "commit": build.GetGitCommit(), + } + if envID := cfg.GetCognitoEnvironmentID(); envID != "" { + healthResponse["cognitoEnvironmentId"] = envID + } + return c.JSON(http.StatusOK, healthResponse) + }) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var response map[string]interface{} + err := json.Unmarshal(rec.Body.Bytes(), &response) + require.NoError(t, err) + + _, hasCognitoEnvID := response["cognitoEnvironmentId"] + assert.False(t, hasCognitoEnvID, + "Health response should NOT include cognitoEnvironmentId when not configured") + assert.Equal(t, "healthy", response["status"]) +} + func TestGetSlogCustomEchoMiddleware(t *testing.T) { l := &logger.TestLogger{ T: t, diff --git a/internal/serviceconfig/auth/config.go b/internal/serviceconfig/auth/config.go index 23841b74..e6c988fc 100644 --- a/internal/serviceconfig/auth/config.go +++ b/internal/serviceconfig/auth/config.go @@ -8,6 +8,8 @@ import ( "strings" "github.com/caarlos0/env/v11" + + "queryorchestration/internal/usermanagement" ) // CognitoConfig holds the configuration for AWS Cognito @@ -26,6 +28,11 @@ type CognitoConfig struct { PermitIOAPIKey string `env:"PERMIT_IO_API_KEY"` PermitIOPDPURL string `env:"PERMIT_IO_PDP_URL" envDefault:"http://localhost:7766"` + // CognitoEnvironmentID scopes this server instance to a specific environment within + // a shared Cognito user pool. When set, only users tagged with this environment ID + // (or untagged users) are visible. Empty string means no scoping (backward compat). + CognitoEnvironmentID string `env:"COGNITO_ENVIRONMENT_ID" envDefault:""` + AuthRedirectURI string // URL where Cognito redirects after authentication AuthTokenURL string // Cognito endpoint for token operations AuthJwksURL string // URL for JSON Web Key Set (for token verification) @@ -61,6 +68,10 @@ type ConfigProvider interface { GetPermitIOPDPURL() string SetPermitIOPDPURL(string) + // Environment isolation methods + GetCognitoEnvironmentID() string + SetCognitoEnvironmentID(string) + // Non-env fields GetAuthRedirectURI() string SetAuthRedirectURI(string) @@ -148,6 +159,15 @@ func InitializeConfig(baseURL string, logger *slog.Logger) *CognitoConfig { return nil } + // Validate CognitoEnvironmentID if set + if tempConfig.CognitoEnvironmentID != "" { + if err := usermanagement.ValidateCognitoEnvironmentID(tempConfig.CognitoEnvironmentID); err != nil { + logger.Error("Invalid COGNITO_ENVIRONMENT_ID", "error", err) + return nil + } + logger.Info("Cognito environment isolation enabled", "environmentID", tempConfig.CognitoEnvironmentID) + } + tempConfig.AuthRedirectURI = baseURL + tempConfig.AuthCallbackPath tempConfig.AuthRedirectURI = baseURL + tempConfig.AuthCallbackPath tempConfig.AuthTokenURL = fmt.Sprintf("%s/oauth2/token", tempConfig.AuthDomain) @@ -184,7 +204,8 @@ func (c *CognitoConfig) PrettyPrintDebugOnly() { "DisableAuth", c.DisableAuth, "AuthDomain", c.AuthDomain, "PermitIOAPIKey", c.PermitIOAPIKey, - "PermitIOPDPURL", c.PermitIOPDPURL) + "PermitIOPDPURL", c.PermitIOPDPURL, + "CognitoEnvironmentID", c.CognitoEnvironmentID) // pretty print the route permissions map for route, permissions := range c.AuthRoutePermissions { @@ -348,6 +369,14 @@ func (c *CognitoConfig) SetPermitIOPDPURL(val string) { c.PermitIOPDPURL = val } +func (c *CognitoConfig) GetCognitoEnvironmentID() string { + return c.CognitoEnvironmentID +} + +func (c *CognitoConfig) SetCognitoEnvironmentID(val string) { + c.CognitoEnvironmentID = val +} + func (c *CognitoConfig) GetNoJWTValidation() bool { return c.NoJWTValidation } diff --git a/internal/serviceconfig/auth/config_test.go b/internal/serviceconfig/auth/config_test.go index 56ed2e8c..fac15640 100644 --- a/internal/serviceconfig/auth/config_test.go +++ b/internal/serviceconfig/auth/config_test.go @@ -285,6 +285,12 @@ func TestGettersAndSetters(t *testing.T) { getter: func() interface{} { return cfg.GetNoJWTValidation() }, expected: false, }, + { + name: "CognitoEnvironmentID", + setter: func() { cfg.SetCognitoEnvironmentID("env-123") }, + getter: func() interface{} { return cfg.GetCognitoEnvironmentID() }, + expected: "env-123", + }, } // Run all test cases diff --git a/internal/usermanagement/cognito.go b/internal/usermanagement/cognito.go index 4bcb3214..1d31be50 100644 --- a/internal/usermanagement/cognito.go +++ b/internal/usermanagement/cognito.go @@ -48,6 +48,14 @@ func CreateCognitoUser(ctx context.Context, client *cognitoidentityprovider.Clie }, } + // Tag the user with the environment ID if specified + if attrs.EnvironmentID != "" { + userAttributes = append(userAttributes, types.AttributeType{ + Name: aws.String(CognitoEnvIDAttrName), + Value: aws.String(attrs.EnvironmentID), + }) + } + // Prepare the AdminCreateUser input createUserInput := &cognitoidentityprovider.AdminCreateUserInput{ UserPoolId: aws.String(config.UserPoolID), @@ -68,24 +76,27 @@ func CreateCognitoUser(ctx context.Context, client *cognitoidentityprovider.Clie return nil, err } - // Extract the subject ID from attributes - var subjectID string + // Extract attributes from the response + var subjectID, environmentID string for _, attr := range result.User.Attributes { - if aws.ToString(attr.Name) == "sub" { + switch aws.ToString(attr.Name) { + case "sub": subjectID = aws.ToString(attr.Value) - break + case CognitoEnvIDAttrName: + environmentID = aws.ToString(attr.Value) } } // Build response response := &CognitoUserResponse{ - SubjectID: subjectID, - Username: aws.ToString(result.User.Username), - Email: attrs.Email, - FirstName: attrs.FirstName, - LastName: attrs.LastName, - Status: string(result.User.UserStatus), - Enabled: result.User.Enabled, + SubjectID: subjectID, + Username: aws.ToString(result.User.Username), + Email: attrs.Email, + FirstName: attrs.FirstName, + LastName: attrs.LastName, + Status: string(result.User.UserStatus), + Enabled: result.User.Enabled, + EnvironmentID: environmentID, } return response, nil @@ -115,7 +126,7 @@ func GetCognitoUser(ctx context.Context, client *cognitoidentityprovider.Client, } // Extract attributes - var subjectID, email, firstName, lastName string + var subjectID, email, firstName, lastName, environmentID string for _, attr := range result.UserAttributes { switch aws.ToString(attr.Name) { case "sub": @@ -126,17 +137,20 @@ func GetCognitoUser(ctx context.Context, client *cognitoidentityprovider.Client, firstName = aws.ToString(attr.Value) case "family_name": lastName = aws.ToString(attr.Value) + case CognitoEnvIDAttrName: + environmentID = aws.ToString(attr.Value) } } return &CognitoUserResponse{ - SubjectID: subjectID, - Username: aws.ToString(result.Username), - Email: email, - FirstName: firstName, - LastName: lastName, - Status: string(result.UserStatus), - Enabled: result.Enabled, + SubjectID: subjectID, + Username: aws.ToString(result.Username), + Email: email, + FirstName: firstName, + LastName: lastName, + Status: string(result.UserStatus), + Enabled: result.Enabled, + EnvironmentID: environmentID, }, nil } @@ -263,6 +277,13 @@ func UpdateCognitoUserAttributes(ctx context.Context, client *cognitoidentitypro }) } + if attrs.EnvironmentID != "" { + userAttributes = append(userAttributes, types.AttributeType{ + Name: aws.String(CognitoEnvIDAttrName), + Value: aws.String(attrs.EnvironmentID), + }) + } + if len(userAttributes) == 0 { return nil } @@ -329,7 +350,7 @@ func ListCognitoUsers(ctx context.Context, client *cognitoidentityprovider.Clien // Convert Cognito users to our response format users := make([]CognitoUserResponse, 0, len(result.Users)) for _, cognitoUser := range result.Users { - var subjectID, email, firstName, lastName string + var subjectID, email, firstName, lastName, environmentID string for _, attr := range cognitoUser.Attributes { switch aws.ToString(attr.Name) { case "sub": @@ -340,17 +361,20 @@ func ListCognitoUsers(ctx context.Context, client *cognitoidentityprovider.Clien firstName = aws.ToString(attr.Value) case "family_name": lastName = aws.ToString(attr.Value) + case CognitoEnvIDAttrName: + environmentID = aws.ToString(attr.Value) } } users = append(users, CognitoUserResponse{ - SubjectID: subjectID, - Username: aws.ToString(cognitoUser.Username), - Email: email, - FirstName: firstName, - LastName: lastName, - Status: string(cognitoUser.UserStatus), - Enabled: cognitoUser.Enabled, + SubjectID: subjectID, + Username: aws.ToString(cognitoUser.Username), + Email: email, + FirstName: firstName, + LastName: lastName, + Status: string(cognitoUser.UserStatus), + Enabled: cognitoUser.Enabled, + EnvironmentID: environmentID, }) } diff --git a/internal/usermanagement/cognito_envid_test.go b/internal/usermanagement/cognito_envid_test.go new file mode 100644 index 00000000..f41a16be --- /dev/null +++ b/internal/usermanagement/cognito_envid_test.go @@ -0,0 +1,390 @@ +package usermanagement + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// roundTripFunc is a function adapter for http.RoundTripper, used to intercept +// AWS SDK HTTP requests in tests. +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +// newTestCognitoClient creates a Cognito client that uses a custom HTTP round-tripper +// to intercept SDK requests. This avoids calling real AWS services during unit tests. +func newTestCognitoClient(t *testing.T, rt roundTripFunc) *cognitoidentityprovider.Client { + t.Helper() + + cfg, err := awsconfig.LoadDefaultConfig(t.Context(), + awsconfig.WithRegion("us-east-1"), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("AKID", "SECRET", "SESSION")), + awsconfig.WithHTTPClient(&http.Client{Transport: rt}), + ) + require.NoError(t, err) + + return cognitoidentityprovider.NewFromConfig(cfg) +} + +// jsonResponse builds an HTTP response with a JSON body for use in test round-trippers. +func jsonResponse(statusCode int, body interface{}) *http.Response { + b, _ := json.Marshal(body) + return &http.Response{ + StatusCode: statusCode, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(string(b))), + } +} + +// TestCreateCognitoUser_IncludesEnvironmentID verifies that CreateCognitoUser includes +// the custom:environment_id attribute in the Cognito API request when EnvironmentID is set. +func TestCreateCognitoUser_IncludesEnvironmentID(t *testing.T) { + var capturedBody map[string]interface{} + + client := newTestCognitoClient(t, func(req *http.Request) (*http.Response, error) { + bodyBytes, err := io.ReadAll(req.Body) + require.NoError(t, err) + err = json.Unmarshal(bodyBytes, &capturedBody) + require.NoError(t, err) + + // Return a successful CreateUser response + resp := map[string]interface{}{ + "User": map[string]interface{}{ + "Username": "test@example.com", + "Enabled": true, + "UserStatus": "FORCE_CHANGE_PASSWORD", + "Attributes": []map[string]string{ + {"Name": "sub", "Value": "sub-123"}, + {"Name": "email", "Value": "test@example.com"}, + {"Name": "given_name", "Value": "Test"}, + {"Name": "family_name", "Value": "User"}, + {"Name": CognitoEnvIDAttrName, "Value": "acmehealth"}, + }, + }, + } + return jsonResponse(200, resp), nil + }) + + config := &CognitoConfig{UserPoolID: "us-east-1_TestPool", Region: "us-east-1"} + attrs := UserAttributes{ + Email: "test@example.com", + FirstName: "Test", + LastName: "User", + EnvironmentID: "acmehealth", + } + + result, err := CreateCognitoUser(t.Context(), client, config, attrs) + require.NoError(t, err) + require.NotNil(t, result) + + // Verify the request included the environment_id attribute + userAttrs, ok := capturedBody["UserAttributes"].([]interface{}) + require.True(t, ok, "UserAttributes should be present in request") + + foundEnvID := false + for _, attr := range userAttrs { + a, ok := attr.(map[string]interface{}) + if !ok { + continue + } + if a["Name"] == CognitoEnvIDAttrName { + assert.Equal(t, "acmehealth", a["Value"], "environment_id value should match") + foundEnvID = true + } + } + assert.True(t, foundEnvID, "request should include custom:environment_id attribute") +} + +// TestCreateCognitoUser_OmitsEnvironmentIDWhenEmpty verifies that CreateCognitoUser +// does NOT include the custom:environment_id attribute when EnvironmentID is empty. +func TestCreateCognitoUser_OmitsEnvironmentIDWhenEmpty(t *testing.T) { + var capturedBody map[string]interface{} + + client := newTestCognitoClient(t, func(req *http.Request) (*http.Response, error) { + bodyBytes, err := io.ReadAll(req.Body) + require.NoError(t, err) + err = json.Unmarshal(bodyBytes, &capturedBody) + require.NoError(t, err) + + resp := map[string]interface{}{ + "User": map[string]interface{}{ + "Username": "test@example.com", + "Enabled": true, + "UserStatus": "FORCE_CHANGE_PASSWORD", + "Attributes": []map[string]string{ + {"Name": "sub", "Value": "sub-123"}, + {"Name": "email", "Value": "test@example.com"}, + {"Name": "given_name", "Value": "Test"}, + {"Name": "family_name", "Value": "User"}, + }, + }, + } + return jsonResponse(200, resp), nil + }) + + config := &CognitoConfig{UserPoolID: "us-east-1_TestPool", Region: "us-east-1"} + attrs := UserAttributes{ + Email: "test@example.com", + FirstName: "Test", + LastName: "User", + EnvironmentID: "", // empty -- should NOT appear in request + } + + result, err := CreateCognitoUser(t.Context(), client, config, attrs) + require.NoError(t, err) + require.NotNil(t, result) + + // Verify the request did NOT include the environment_id attribute + userAttrs, ok := capturedBody["UserAttributes"].([]interface{}) + require.True(t, ok, "UserAttributes should be present in request") + + for _, attr := range userAttrs { + a, ok := attr.(map[string]interface{}) + if !ok { + continue + } + assert.NotEqual(t, CognitoEnvIDAttrName, a["Name"], + "request should NOT include custom:environment_id when empty") + } +} + +// TestCreateCognitoUser_ResponseIncludesEnvironmentID verifies that the returned +// CognitoUserResponse has the EnvironmentID field populated from the Cognito response. +func TestCreateCognitoUser_ResponseIncludesEnvironmentID(t *testing.T) { + client := newTestCognitoClient(t, func(req *http.Request) (*http.Response, error) { + resp := map[string]interface{}{ + "User": map[string]interface{}{ + "Username": "test@example.com", + "Enabled": true, + "UserStatus": "FORCE_CHANGE_PASSWORD", + "Attributes": []map[string]string{ + {"Name": "sub", "Value": "sub-456"}, + {"Name": "email", "Value": "test@example.com"}, + {"Name": "given_name", "Value": "Test"}, + {"Name": "family_name", "Value": "User"}, + {"Name": CognitoEnvIDAttrName, "Value": "fordmotorco"}, + }, + }, + } + return jsonResponse(200, resp), nil + }) + + config := &CognitoConfig{UserPoolID: "us-east-1_TestPool", Region: "us-east-1"} + attrs := UserAttributes{ + Email: "test@example.com", + FirstName: "Test", + LastName: "User", + EnvironmentID: "fordmotorco", + } + + result, err := CreateCognitoUser(t.Context(), client, config, attrs) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, "fordmotorco", result.EnvironmentID, + "response should include environment_id from Cognito attributes") +} + +// TestGetCognitoUser_ExtractsEnvironmentID verifies that GetCognitoUser extracts the +// custom:environment_id attribute from the Cognito user's attributes. +func TestGetCognitoUser_ExtractsEnvironmentID(t *testing.T) { + client := newTestCognitoClient(t, func(req *http.Request) (*http.Response, error) { + resp := map[string]interface{}{ + "Username": "test@example.com", + "Enabled": true, + "UserStatus": "CONFIRMED", + "UserAttributes": []map[string]string{ + {"Name": "sub", "Value": "sub-789"}, + {"Name": "email", "Value": "test@example.com"}, + {"Name": "given_name", "Value": "Test"}, + {"Name": "family_name", "Value": "User"}, + {"Name": CognitoEnvIDAttrName, "Value": "acmehealth"}, + }, + } + return jsonResponse(200, resp), nil + }) + + result, err := GetCognitoUser(t.Context(), client, "us-east-1_TestPool", "test@example.com") + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, "acmehealth", result.EnvironmentID, + "GetCognitoUser should extract custom:environment_id from attributes") + assert.Equal(t, "sub-789", result.SubjectID) + assert.Equal(t, "test@example.com", result.Email) +} + +// TestGetCognitoUser_NoEnvironmentID verifies that GetCognitoUser returns empty +// EnvironmentID when the attribute is not present (legacy/untagged users). +func TestGetCognitoUser_NoEnvironmentID(t *testing.T) { + client := newTestCognitoClient(t, func(req *http.Request) (*http.Response, error) { + resp := map[string]interface{}{ + "Username": "legacy@example.com", + "Enabled": true, + "UserStatus": "CONFIRMED", + "UserAttributes": []map[string]string{ + {"Name": "sub", "Value": "sub-legacy"}, + {"Name": "email", "Value": "legacy@example.com"}, + {"Name": "given_name", "Value": "Legacy"}, + {"Name": "family_name", "Value": "User"}, + }, + } + return jsonResponse(200, resp), nil + }) + + result, err := GetCognitoUser(t.Context(), client, "us-east-1_TestPool", "legacy@example.com") + require.NoError(t, err) + require.NotNil(t, result) + + assert.Empty(t, result.EnvironmentID, + "GetCognitoUser should return empty EnvironmentID for users without the attribute") +} + +// TestListCognitoUsers_ExtractsEnvironmentID verifies that ListCognitoUsers extracts +// the custom:environment_id attribute for each user in the result set. +func TestListCognitoUsers_ExtractsEnvironmentID(t *testing.T) { + client := newTestCognitoClient(t, func(req *http.Request) (*http.Response, error) { + resp := map[string]interface{}{ + "Users": []map[string]interface{}{ + { + "Username": "user1@example.com", + "Enabled": true, + "UserStatus": "CONFIRMED", + "Attributes": []map[string]string{ + {"Name": "sub", "Value": "sub-1"}, + {"Name": "email", "Value": "user1@example.com"}, + {"Name": "given_name", "Value": "User"}, + {"Name": "family_name", "Value": "One"}, + {"Name": CognitoEnvIDAttrName, "Value": "acmehealth"}, + }, + }, + { + "Username": "user2@example.com", + "Enabled": true, + "UserStatus": "CONFIRMED", + "Attributes": []map[string]string{ + {"Name": "sub", "Value": "sub-2"}, + {"Name": "email", "Value": "user2@example.com"}, + {"Name": "given_name", "Value": "User"}, + {"Name": "family_name", "Value": "Two"}, + {"Name": CognitoEnvIDAttrName, "Value": "fordmotorco"}, + }, + }, + { + "Username": "user3@example.com", + "Enabled": true, + "UserStatus": "CONFIRMED", + "Attributes": []map[string]string{ + {"Name": "sub", "Value": "sub-3"}, + {"Name": "email", "Value": "user3@example.com"}, + {"Name": "given_name", "Value": "User"}, + {"Name": "family_name", "Value": "Three"}, + // No environment_id -- legacy user + }, + }, + }, + } + return jsonResponse(200, resp), nil + }) + + result, err := ListCognitoUsers(t.Context(), client, "us-east-1_TestPool", 10, nil) + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, result.Users, 3) + + assert.Equal(t, "acmehealth", result.Users[0].EnvironmentID, + "first user should have acmehealth environment") + assert.Equal(t, "fordmotorco", result.Users[1].EnvironmentID, + "second user should have fordmotorco environment") + assert.Empty(t, result.Users[2].EnvironmentID, + "third user should have empty environment (legacy)") +} + +// TestUpdateCognitoUserAttributes_IncludesEnvironmentID verifies that +// UpdateCognitoUserAttributes includes custom:environment_id in the update +// request when EnvironmentID is set. +func TestUpdateCognitoUserAttributes_IncludesEnvironmentID(t *testing.T) { + var capturedBody map[string]interface{} + + client := newTestCognitoClient(t, func(req *http.Request) (*http.Response, error) { + bodyBytes, err := io.ReadAll(req.Body) + require.NoError(t, err) + err = json.Unmarshal(bodyBytes, &capturedBody) + require.NoError(t, err) + + return jsonResponse(200, map[string]interface{}{}), nil + }) + + attrs := UserAttributes{ + FirstName: "Updated", + EnvironmentID: "acmehealth", + } + + err := UpdateCognitoUserAttributes(t.Context(), client, "us-east-1_TestPool", "test@example.com", attrs) + require.NoError(t, err) + + // Verify the request included the environment_id attribute + userAttrs, ok := capturedBody["UserAttributes"].([]interface{}) + require.True(t, ok, "UserAttributes should be present in request") + + foundEnvID := false + for _, attr := range userAttrs { + a, ok := attr.(map[string]interface{}) + if !ok { + continue + } + if a["Name"] == CognitoEnvIDAttrName { + assert.Equal(t, "acmehealth", a["Value"], "environment_id value should match") + foundEnvID = true + } + } + assert.True(t, foundEnvID, "update request should include custom:environment_id attribute") +} + +// TestUpdateCognitoUserAttributes_OmitsEnvironmentIDWhenEmpty verifies that +// UpdateCognitoUserAttributes does NOT include custom:environment_id in the +// update request when EnvironmentID is empty. +func TestUpdateCognitoUserAttributes_OmitsEnvironmentIDWhenEmpty(t *testing.T) { + var capturedBody map[string]interface{} + + client := newTestCognitoClient(t, func(req *http.Request) (*http.Response, error) { + bodyBytes, err := io.ReadAll(req.Body) + require.NoError(t, err) + err = json.Unmarshal(bodyBytes, &capturedBody) + require.NoError(t, err) + + return jsonResponse(200, map[string]interface{}{}), nil + }) + + attrs := UserAttributes{ + FirstName: "Updated", + EnvironmentID: "", // empty -- should NOT appear in request + } + + err := UpdateCognitoUserAttributes(t.Context(), client, "us-east-1_TestPool", "test@example.com", attrs) + require.NoError(t, err) + + // Verify the request did NOT include the environment_id attribute + userAttrs, ok := capturedBody["UserAttributes"].([]interface{}) + require.True(t, ok, "UserAttributes should be present in request") + + for _, attr := range userAttrs { + a, ok := attr.(map[string]interface{}) + if !ok { + continue + } + assert.NotEqual(t, CognitoEnvIDAttrName, a["Name"], + "update request should NOT include custom:environment_id when empty") + } +} diff --git a/internal/usermanagement/envfilter.go b/internal/usermanagement/envfilter.go new file mode 100644 index 00000000..0838b0fd --- /dev/null +++ b/internal/usermanagement/envfilter.go @@ -0,0 +1,51 @@ +// envfilter.go provides user filtering utilities for the multi-environment isolation feature. +// When a server has a COGNITO_ENVIRONMENT_ID set, these functions filter Cognito user lists +// to show only users belonging to that environment (or untagged users for backward compatibility). +package usermanagement + +// FilterUsersByEnvironmentID filters a slice of CognitoUserResponse to only include users +// that belong to the specified environment. If envID is empty (server has no environment set), +// all users are returned unfiltered. Otherwise, only users whose EnvironmentID matches envID +// or whose EnvironmentID is empty (untagged/legacy users) are included. +// +// Parameters: +// - users: slice of CognitoUserResponse to filter +// - envID: the server's environment ID; empty string means no filtering +// +// Returns: +// - []CognitoUserResponse: filtered slice preserving original order +func FilterUsersByEnvironmentID(users []CognitoUserResponse, envID string) []CognitoUserResponse { + if envID == "" { + return users + } + + filtered := make([]CognitoUserResponse, 0, len(users)) + for _, u := range users { + if u.EnvironmentID == envID || u.EnvironmentID == "" { + filtered = append(filtered, u) + } + } + return filtered +} + +// UserBelongsToEnvironment checks whether a single user belongs to the given environment. +// Returns true if: +// - envID is empty (server has no environment set, backward compat) +// - user's EnvironmentID is empty (untagged/legacy user, backward compat) +// - user's EnvironmentID matches envID exactly (case-sensitive) +// +// Parameters: +// - user: pointer to the CognitoUserResponse to check +// - envID: the server's environment ID +// +// Returns: +// - bool: true if the user belongs to the environment +func UserBelongsToEnvironment(user *CognitoUserResponse, envID string) bool { + if envID == "" { + return true + } + if user.EnvironmentID == "" { + return true + } + return user.EnvironmentID == envID +} diff --git a/internal/usermanagement/envfilter_test.go b/internal/usermanagement/envfilter_test.go new file mode 100644 index 00000000..a53841fb --- /dev/null +++ b/internal/usermanagement/envfilter_test.go @@ -0,0 +1,159 @@ +package usermanagement + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// buildTestUsers creates a slice of CognitoUserResponse with the given environment IDs. +// Each user gets a unique email and subject ID based on the index. +func buildTestUsers(envIDs ...string) []CognitoUserResponse { + users := make([]CognitoUserResponse, len(envIDs)) + for i, envID := range envIDs { + users[i] = CognitoUserResponse{ + SubjectID: "sub-" + string(rune('a'+i)), + Username: "user" + string(rune('0'+i)) + "@example.com", + Email: "user" + string(rune('0'+i)) + "@example.com", + FirstName: "User", + LastName: string(rune('A' + i)), + Status: "CONFIRMED", + Enabled: true, + EnvironmentID: envID, + } + } + return users +} + +// TestFilterUsersByEnvironmentID_NoEnvIDReturnsAll verifies that when no server +// environment ID is set (empty string), all users are returned unfiltered. +func TestFilterUsersByEnvironmentID_NoEnvIDReturnsAll(t *testing.T) { + users := buildTestUsers("acme", "ford", "", "acme") + + result := FilterUsersByEnvironmentID(users, "") + + assert.Equal(t, len(users), len(result), "all users should be returned when envID is empty") + assert.Equal(t, users, result) +} + +// TestFilterUsersByEnvironmentID_MatchingAndUntagged verifies that when a server +// environment ID is set, only users matching that env OR untagged users are returned. +func TestFilterUsersByEnvironmentID_MatchingAndUntagged(t *testing.T) { + users := buildTestUsers("acme", "ford", "", "acme") + + result := FilterUsersByEnvironmentID(users, "acme") + + // Should include: "acme" (index 0), "" (index 2), "acme" (index 3) + // Should exclude: "ford" (index 1) + assert.Equal(t, 3, len(result), "should include matching + untagged, exclude non-matching") + + for _, u := range result { + assert.True(t, u.EnvironmentID == "acme" || u.EnvironmentID == "", + "filtered user should have matching envID or empty, got %q", u.EnvironmentID) + } +} + +// TestFilterUsersByEnvironmentID_ExcludesNonMatching verifies that users tagged +// with a different environment ID are excluded. +func TestFilterUsersByEnvironmentID_ExcludesNonMatching(t *testing.T) { + users := buildTestUsers("ford", "gm", "toyota") + + result := FilterUsersByEnvironmentID(users, "acme") + + assert.Equal(t, 0, len(result), "no users should match 'acme' environment") +} + +// TestFilterUsersByEnvironmentID_EmptyUserList verifies behavior with no users. +func TestFilterUsersByEnvironmentID_EmptyUserList(t *testing.T) { + var users []CognitoUserResponse + + result := FilterUsersByEnvironmentID(users, "acme") + + assert.Empty(t, result, "filtering empty list should return empty") +} + +// TestFilterUsersByEnvironmentID_AllUntagged verifies that all untagged users +// are returned when a server environment ID is set. +func TestFilterUsersByEnvironmentID_AllUntagged(t *testing.T) { + users := buildTestUsers("", "", "") + + result := FilterUsersByEnvironmentID(users, "acme") + + assert.Equal(t, 3, len(result), "all untagged users should pass through") +} + +// TestFilterUsersByEnvironmentID_AllMatching verifies that all matching users +// are returned. +func TestFilterUsersByEnvironmentID_AllMatching(t *testing.T) { + users := buildTestUsers("acme", "acme", "acme") + + result := FilterUsersByEnvironmentID(users, "acme") + + assert.Equal(t, 3, len(result), "all matching users should pass through") +} + +// TestFilterUsersByEnvironmentID_PreservesOrder verifies that the order of users +// is preserved after filtering. +func TestFilterUsersByEnvironmentID_PreservesOrder(t *testing.T) { + users := buildTestUsers("acme", "ford", "", "acme") + + result := FilterUsersByEnvironmentID(users, "acme") + + assert.Equal(t, 3, len(result)) + assert.Equal(t, users[0].Email, result[0].Email, "first result should be first matching user") + assert.Equal(t, users[2].Email, result[1].Email, "second result should be untagged user") + assert.Equal(t, users[3].Email, result[2].Email, "third result should be second matching user") +} + +// TestUserBelongsToEnvironment_MatchingEnv verifies that a user with a matching +// environment ID belongs to the environment. +func TestUserBelongsToEnvironment_MatchingEnv(t *testing.T) { + user := &CognitoUserResponse{EnvironmentID: "acme"} + + assert.True(t, UserBelongsToEnvironment(user, "acme")) +} + +// TestUserBelongsToEnvironment_EmptyServerEnv verifies that when the server has no +// environment ID set, all users belong (backward compat). +func TestUserBelongsToEnvironment_EmptyServerEnv(t *testing.T) { + user := &CognitoUserResponse{EnvironmentID: "acme"} + + assert.True(t, UserBelongsToEnvironment(user, ""), + "all users belong when server envID is empty") +} + +// TestUserBelongsToEnvironment_UntaggedUser verifies that an untagged user +// (no environment ID) belongs to any environment (backward compat). +func TestUserBelongsToEnvironment_UntaggedUser(t *testing.T) { + user := &CognitoUserResponse{EnvironmentID: ""} + + assert.True(t, UserBelongsToEnvironment(user, "acme"), + "untagged users should belong to any environment") +} + +// TestUserBelongsToEnvironment_MismatchedEnv verifies that a user tagged with a +// different environment ID does NOT belong to the server's environment. +func TestUserBelongsToEnvironment_MismatchedEnv(t *testing.T) { + user := &CognitoUserResponse{EnvironmentID: "ford"} + + assert.False(t, UserBelongsToEnvironment(user, "acme"), + "user with different envID should not belong") +} + +// TestUserBelongsToEnvironment_BothEmpty verifies that when both server and user +// have no environment ID, the user belongs. +func TestUserBelongsToEnvironment_BothEmpty(t *testing.T) { + user := &CognitoUserResponse{EnvironmentID: ""} + + assert.True(t, UserBelongsToEnvironment(user, ""), + "both empty should mean user belongs") +} + +// TestUserBelongsToEnvironment_CaseSensitive verifies that the comparison is +// case-sensitive (environment IDs should always be lowercase per validation). +func TestUserBelongsToEnvironment_CaseSensitive(t *testing.T) { + user := &CognitoUserResponse{EnvironmentID: "Acme"} + + assert.False(t, UserBelongsToEnvironment(user, "acme"), + "comparison should be case-sensitive") +} diff --git a/internal/usermanagement/envid.go b/internal/usermanagement/envid.go new file mode 100644 index 00000000..f2cd174a --- /dev/null +++ b/internal/usermanagement/envid.go @@ -0,0 +1,59 @@ +// envid.go provides validation and constants for the COGNITO_ENVIRONMENT_ID feature. +// This feature allows multiple production environments to share a single Cognito user pool +// while keeping user visibility scoped to each environment. +package usermanagement + +import ( + "fmt" + "regexp" +) + +// CognitoEnvIDMaxLen is the maximum allowed length for a COGNITO_ENVIRONMENT_ID value. +// This is a conservative limit for human-readable environment abbreviations. +const CognitoEnvIDMaxLen = 50 + +// CognitoEnvIDAttrName is the Cognito custom attribute name used to tag users +// with their environment. Follows Cognito's "custom:" prefix convention. +const CognitoEnvIDAttrName = "custom:environment_id" + +// CognitoEnvIDEnvVar is the server-side environment variable that sets the +// environment identity for this service instance. +const CognitoEnvIDEnvVar = "COGNITO_ENVIRONMENT_ID" + +// envIDPattern matches a valid environment ID: starts with lowercase alpha, +// ends with lowercase alphanumeric, middle characters allow lowercase alphanumeric +// and single underscores (no consecutive underscores). Minimum 1 char. +var envIDPattern = regexp.MustCompile(`^[a-z]([a-z0-9]|_[a-z0-9])*$`) + +// ValidateCognitoEnvironmentID checks whether the given environment ID is valid. +// An empty string is valid and means "not set". A valid non-empty value must: +// - Start with a lowercase alpha character (a-z) +// - End with a lowercase alphanumeric character (a-z, 0-9) +// - Contain only lowercase alphanumeric characters and underscores +// - Not have consecutive underscores +// - Not start or end with an underscore +// - Be at most CognitoEnvIDMaxLen (50) characters long +// +// Parameters: +// - id: the environment ID string to validate +// +// Returns: +// - error: nil if valid, descriptive error if invalid +func ValidateCognitoEnvironmentID(id string) error { + if id == "" { + return nil + } + + if len(id) > CognitoEnvIDMaxLen { + return fmt.Errorf("cognito environment ID %q exceeds maximum length of %d characters (got %d)", + id, CognitoEnvIDMaxLen, len(id)) + } + + if !envIDPattern.MatchString(id) { + return fmt.Errorf("cognito environment ID %q is invalid: must start with lowercase letter, "+ + "end with lowercase alphanumeric, contain only lowercase alphanumeric and non-consecutive underscores", + id) + } + + return nil +} diff --git a/internal/usermanagement/envid_register.go b/internal/usermanagement/envid_register.go new file mode 100644 index 00000000..44412391 --- /dev/null +++ b/internal/usermanagement/envid_register.go @@ -0,0 +1,69 @@ +// envid_register.go provides auto-registration of the custom:environment_id attribute +// on a Cognito user pool at service startup. This makes new environment deployments +// self-configuring without requiring manual AWS CLI commands. +package usermanagement + +import ( + "context" + "log/slog" + "strconv" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider" + "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider/types" +) + +// EnsureEnvironmentIDAttribute checks if the custom:environment_id attribute exists +// on the Cognito user pool and registers it if not found. This is safe to call on +// every startup because: +// - DescribeUserPool is read-only +// - AddCustomAttributes only runs when the attribute is missing +// - Custom attributes in Cognito cannot be deleted once created (non-destructive) +// +// Parameters: +// - ctx: context for AWS API calls +// - client: Cognito Identity Provider client +// - userPoolID: the Cognito user pool ID to register the attribute on +// - logger: structured logger for status messages +// +// Returns: +// - error: nil on success, error on unexpected API failures +func EnsureEnvironmentIDAttribute(ctx context.Context, client *cognitoidentityprovider.Client, userPoolID string, logger *slog.Logger) error { + // Describe the user pool to check existing schema attributes + describeOutput, err := client.DescribeUserPool(ctx, &cognitoidentityprovider.DescribeUserPoolInput{ + UserPoolId: aws.String(userPoolID), + }) + if err != nil { + return err + } + + // Check if custom:environment_id already exists in the schema + for _, attr := range describeOutput.UserPool.SchemaAttributes { + if aws.ToString(attr.Name) == CognitoEnvIDAttrName { + logger.Debug("Cognito custom attribute already registered", "attribute", CognitoEnvIDAttrName) + return nil + } + } + + // Attribute not found, register it + maxLen := int32(CognitoEnvIDMaxLen) + _, err = client.AddCustomAttributes(ctx, &cognitoidentityprovider.AddCustomAttributesInput{ + UserPoolId: aws.String(userPoolID), + CustomAttributes: []types.SchemaAttributeType{ + { + Name: aws.String("environment_id"), + AttributeDataType: types.AttributeDataTypeString, + Mutable: aws.Bool(true), + StringAttributeConstraints: &types.StringAttributeConstraintsType{ + MaxLength: aws.String(strconv.FormatInt(int64(maxLen), 10)), + }, + }, + }, + }) + if err != nil { + return err + } + + logger.Info("Cognito custom attribute registered", "attribute", CognitoEnvIDAttrName) + return nil +} diff --git a/internal/usermanagement/envid_register_test.go b/internal/usermanagement/envid_register_test.go new file mode 100644 index 00000000..88f316ef --- /dev/null +++ b/internal/usermanagement/envid_register_test.go @@ -0,0 +1,148 @@ +package usermanagement + +import ( + "fmt" + "log/slog" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestEnsureEnvironmentIDAttribute_AlreadyExists verifies that when the user pool +// already has custom:environment_id in its schema, AddCustomAttributes is NOT called +// and the function returns nil. +func TestEnsureEnvironmentIDAttribute_AlreadyExists(t *testing.T) { + addAttrsCalled := false + + client := newTestCognitoClient(t, func(req *http.Request) (*http.Response, error) { + target := req.Header.Get("X-Amz-Target") + switch target { + case "AWSCognitoIdentityProviderService.DescribeUserPool": + resp := map[string]interface{}{ + "UserPool": map[string]interface{}{ + "Id": "us-east-1_TestPool", + "Name": "TestPool", + "SchemaAttributes": []map[string]interface{}{ + {"Name": "sub", "AttributeDataType": "String"}, + {"Name": "email", "AttributeDataType": "String"}, + {"Name": CognitoEnvIDAttrName, "AttributeDataType": "String"}, + }, + }, + } + return jsonResponse(200, resp), nil + + case "AWSCognitoIdentityProviderService.AddCustomAttributes": + addAttrsCalled = true + t.Fatal("AddCustomAttributes should NOT be called when attribute already exists") + return nil, nil + + default: + return nil, fmt.Errorf("unexpected API call: %s", target) + } + }) + + logger := slog.Default() + err := EnsureEnvironmentIDAttribute(t.Context(), client, "us-east-1_TestPool", logger) + require.NoError(t, err) + assert.False(t, addAttrsCalled, "AddCustomAttributes should not have been called") +} + +// TestEnsureEnvironmentIDAttribute_RegistersNewAttribute verifies that when the attribute +// does not exist in the pool schema, AddCustomAttributes is called successfully. +func TestEnsureEnvironmentIDAttribute_RegistersNewAttribute(t *testing.T) { + addAttrsCalled := false + + client := newTestCognitoClient(t, func(req *http.Request) (*http.Response, error) { + target := req.Header.Get("X-Amz-Target") + switch target { + case "AWSCognitoIdentityProviderService.DescribeUserPool": + // No custom:environment_id in schema + resp := map[string]interface{}{ + "UserPool": map[string]interface{}{ + "Id": "us-east-1_TestPool", + "Name": "TestPool", + "SchemaAttributes": []map[string]interface{}{ + {"Name": "sub", "AttributeDataType": "String"}, + {"Name": "email", "AttributeDataType": "String"}, + }, + }, + } + return jsonResponse(200, resp), nil + + case "AWSCognitoIdentityProviderService.AddCustomAttributes": + addAttrsCalled = true + return jsonResponse(200, map[string]interface{}{}), nil + + default: + return nil, fmt.Errorf("unexpected API call: %s", target) + } + }) + + logger := slog.Default() + err := EnsureEnvironmentIDAttribute(t.Context(), client, "us-east-1_TestPool", logger) + require.NoError(t, err) + assert.True(t, addAttrsCalled, "AddCustomAttributes should have been called for new attribute") +} + +// TestEnsureEnvironmentIDAttribute_DescribeFailure verifies that when DescribeUserPool +// returns a 500 error, the function returns an error. +func TestEnsureEnvironmentIDAttribute_DescribeFailure(t *testing.T) { + client := newTestCognitoClient(t, func(req *http.Request) (*http.Response, error) { + target := req.Header.Get("X-Amz-Target") + switch target { + case "AWSCognitoIdentityProviderService.DescribeUserPool": + resp := map[string]interface{}{ + "__type": "InternalErrorException", + "message": "simulated internal error", + } + return jsonResponse(500, resp), nil + + default: + return nil, fmt.Errorf("unexpected API call: %s", target) + } + }) + + logger := slog.Default() + err := EnsureEnvironmentIDAttribute(t.Context(), client, "us-east-1_TestPool", logger) + assert.Error(t, err, "Should return error when DescribeUserPool fails") +} + +// TestEnsureEnvironmentIDAttribute_AddFailure verifies that when DescribeUserPool +// succeeds (attribute not present) but AddCustomAttributes returns a 500 error, +// the function returns an error. +func TestEnsureEnvironmentIDAttribute_AddFailure(t *testing.T) { + client := newTestCognitoClient(t, func(req *http.Request) (*http.Response, error) { + target := req.Header.Get("X-Amz-Target") + switch target { + case "AWSCognitoIdentityProviderService.DescribeUserPool": + // No custom:environment_id in schema + resp := map[string]interface{}{ + "UserPool": map[string]interface{}{ + "Id": "us-east-1_TestPool", + "Name": "TestPool", + "SchemaAttributes": []map[string]interface{}{ + {"Name": "sub", "AttributeDataType": "String"}, + {"Name": "email", "AttributeDataType": "String"}, + }, + }, + } + return jsonResponse(200, resp), nil + + case "AWSCognitoIdentityProviderService.AddCustomAttributes": + resp := map[string]interface{}{ + "__type": "InternalErrorException", + "message": "simulated add failure", + } + return jsonResponse(500, resp), nil + + default: + return nil, fmt.Errorf("unexpected API call: %s", target) + } + }) + + logger := slog.Default() + err := EnsureEnvironmentIDAttribute(t.Context(), client, "us-east-1_TestPool", logger) + assert.Error(t, err, "Should return error when AddCustomAttributes fails") +} diff --git a/internal/usermanagement/envid_test.go b/internal/usermanagement/envid_test.go new file mode 100644 index 00000000..90838bb1 --- /dev/null +++ b/internal/usermanagement/envid_test.go @@ -0,0 +1,110 @@ +package usermanagement + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestValidateCognitoEnvironmentID_ValidValues tests that valid environment IDs +// pass validation without error. +func TestValidateCognitoEnvironmentID_ValidValues(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"simple lowercase", "acme"}, + {"alphanumeric with digit", "client1"}, + {"single character", "a"}, + {"underscore in middle", "acme_health"}, + {"multiple underscores separated", "a_b_c"}, + {"empty string means not set", ""}, + {"two characters", "ab"}, + {"ends with digit", "env9"}, + {"all lowercase letters", "abcdefghij"}, + {"mixed alpha and digits", "abc123def456"}, + {"underscore between digits", "a1_b2"}, + {"max length 50 chars", "a" + strings.Repeat("b", 48) + "c"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateCognitoEnvironmentID(tt.input) + assert.NoError(t, err, "expected %q to be valid", tt.input) + }) + } +} + +// TestValidateCognitoEnvironmentID_InvalidValues tests that invalid environment IDs +// are rejected with an error. +func TestValidateCognitoEnvironmentID_InvalidValues(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"starts with digit", "1acme"}, + {"uppercase letters", "Acme"}, + {"all uppercase", "ACME"}, + {"mixed case", "acmeHealth"}, + {"special char hyphen", "acme-health"}, + {"special char dot", "acme.health"}, + {"special char at", "acme@health"}, + {"special char space", "acme health"}, + {"starts with underscore", "_acme"}, + {"ends with underscore", "acme_"}, + {"consecutive underscores", "acme__health"}, + {"only underscore", "_"}, + {"only underscores", "__"}, + {"exceeds max length", "a" + strings.Repeat("b", 50)}, + {"contains space in middle", "acme health"}, + {"tab character", "acme\thealth"}, + {"starts with digit and underscore", "1_acme"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateCognitoEnvironmentID(tt.input) + assert.Error(t, err, "expected %q to be invalid", tt.input) + }) + } +} + +// TestCognitoEnvIDConstants verifies the exported constants have the expected values. +func TestCognitoEnvIDConstants(t *testing.T) { + assert.Equal(t, 50, CognitoEnvIDMaxLen, "max length constant should be 50") + assert.Equal(t, "custom:environment_id", CognitoEnvIDAttrName, "attribute name should match Cognito custom attribute convention") + assert.Equal(t, "COGNITO_ENVIRONMENT_ID", CognitoEnvIDEnvVar, "env var name should match expected convention") +} + +// TestValidateCognitoEnvironmentID_ExactMaxLength verifies that a string at exactly +// the max length is accepted, but one character over is rejected. +func TestValidateCognitoEnvironmentID_ExactMaxLength(t *testing.T) { + // Exactly 50 characters: starts with 'a', 48 middle chars, ends with 'z' + exactMax := "a" + strings.Repeat("b", 48) + "z" + require.Equal(t, 50, len(exactMax)) + + err := ValidateCognitoEnvironmentID(exactMax) + assert.NoError(t, err, "exactly 50 chars should be valid") + + // 51 characters: should fail + overMax := exactMax + "x" + require.Equal(t, 51, len(overMax)) + + err = ValidateCognitoEnvironmentID(overMax) + assert.Error(t, err, "51 chars should be invalid") +} + +// TestValidateCognitoEnvironmentID_ErrorMessages verifies that error messages +// are descriptive enough to help users fix invalid values. +func TestValidateCognitoEnvironmentID_ErrorMessages(t *testing.T) { + err := ValidateCognitoEnvironmentID("ACME") + require.Error(t, err) + // Error message should mention what went wrong + assert.NotEmpty(t, err.Error()) + + err = ValidateCognitoEnvironmentID("a" + strings.Repeat("b", 50)) + require.Error(t, err) + assert.NotEmpty(t, err.Error()) +} diff --git a/internal/usermanagement/permitio.go b/internal/usermanagement/permitio.go index 00e02462..2edcb3b9 100644 --- a/internal/usermanagement/permitio.go +++ b/internal/usermanagement/permitio.go @@ -34,15 +34,20 @@ func CreatePermitUser(client *http.Client, config *PermitConfig, cognitoUser *Co url := fmt.Sprintf("%s/v2/facts/%s/%s/users", config.BaseURL, projectID, envID) + attrs := map[string]interface{}{ + "cognito_username": cognitoUser.Username, + "cognito_status": cognitoUser.Status, + } + if cognitoUser.EnvironmentID != "" { + attrs["environment_id"] = cognitoUser.EnvironmentID + } + userObj := PermitUser{ - Key: cognitoUser.SubjectID, // Use Cognito Subject ID as the key - Email: cognitoUser.Email, - FirstName: cognitoUser.FirstName, - LastName: cognitoUser.LastName, - Attributes: map[string]interface{}{ - "cognito_username": cognitoUser.Username, - "cognito_status": cognitoUser.Status, - }, + Key: cognitoUser.SubjectID, // Use Cognito Subject ID as the key + Email: cognitoUser.Email, + FirstName: cognitoUser.FirstName, + LastName: cognitoUser.LastName, + Attributes: attrs, } jsonData, err := json.Marshal(userObj) @@ -94,6 +99,60 @@ func CreatePermitUser(client *http.Client, config *PermitConfig, cognitoUser *Co return nil } +// GetPermitUser retrieves a user from Permit.io by their user key, returning the full user +// object including custom attributes. This is useful for verifying that attributes like +// environment_id were correctly stored when the user was created. +// +// Parameters: +// - client: HTTP client for making API requests +// - config: Permit.io configuration containing API credentials and project details +// - userKey: The Permit.io user key (typically Cognito SubjectID) +// +// Returns: +// - *PermitUser: The full user object including attributes +// - error: Error if the retrieval fails or user is not found +func GetPermitUser(client *http.Client, config *PermitConfig, userKey string) (*PermitUser, error) { + projectID, err := config.GetProjectID(client) + if err != nil { + return nil, fmt.Errorf("failed to get project ID: %w", err) + } + envID, err := config.GetEnvID(client) + if err != nil { + return nil, fmt.Errorf("failed to get environment ID: %w", err) + } + + url := fmt.Sprintf("%s/v2/facts/%s/%s/users/%s", config.BaseURL, projectID, envID, userKey) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+config.APIKey) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + var user PermitUser + if err := json.Unmarshal(body, &user); err != nil { + return nil, fmt.Errorf("error parsing response: %w", err) + } + + return &user, nil +} + // DeletePermitUser permanently deletes a user from Permit.io by their user key. // // Parameters: diff --git a/internal/usermanagement/permitio_test.go b/internal/usermanagement/permitio_test.go index 841498e6..75cfc088 100644 --- a/internal/usermanagement/permitio_test.go +++ b/internal/usermanagement/permitio_test.go @@ -325,3 +325,76 @@ func TestGetRoleDetails_ConnectionError(t *testing.T) { // Error will be from GetProjectID trying to fetch scope assert.Contains(t, err.Error(), "failed to get project ID") } + +// TestCreatePermitUser_IncludesEnvironmentID verifies that CreatePermitUser includes +// the environment_id attribute when the CognitoUserResponse has a non-empty EnvironmentID. +func TestCreatePermitUser_IncludesEnvironmentID(t *testing.T) { + var capturedBody PermitUser + + handlers := map[string]http.HandlerFunc{ + "/v2/facts/test-project/test-env/users": func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + err := json.NewDecoder(r.Body).Decode(&capturedBody) + require.NoError(t, err) + w.WriteHeader(http.StatusCreated) + }, + } + + server, config := createMockPermitServer(t, handlers) + defer server.Close() + + cognitoUser := &CognitoUserResponse{ + SubjectID: "sub-123", + Username: "test@example.com", + Email: "test@example.com", + FirstName: "Test", + LastName: "User", + Status: "CONFIRMED", + Enabled: true, + EnvironmentID: "acmehealth", + } + + err := CreatePermitUser(server.Client(), config, cognitoUser) + require.NoError(t, err) + + assert.Equal(t, "acmehealth", capturedBody.Attributes["environment_id"], + "environment_id attribute should be set when user has EnvironmentID") + assert.Equal(t, "test@example.com", capturedBody.Attributes["cognito_username"]) + assert.Equal(t, "CONFIRMED", capturedBody.Attributes["cognito_status"]) +} + +// TestCreatePermitUser_OmitsEnvironmentIDWhenEmpty verifies that CreatePermitUser +// does NOT include environment_id in attributes when EnvironmentID is empty. +func TestCreatePermitUser_OmitsEnvironmentIDWhenEmpty(t *testing.T) { + var capturedBody PermitUser + + handlers := map[string]http.HandlerFunc{ + "/v2/facts/test-project/test-env/users": func(w http.ResponseWriter, r *http.Request) { + err := json.NewDecoder(r.Body).Decode(&capturedBody) + require.NoError(t, err) + w.WriteHeader(http.StatusCreated) + }, + } + + server, config := createMockPermitServer(t, handlers) + defer server.Close() + + cognitoUser := &CognitoUserResponse{ + SubjectID: "sub-456", + Username: "legacy@example.com", + Email: "legacy@example.com", + FirstName: "Legacy", + LastName: "User", + Status: "CONFIRMED", + Enabled: true, + EnvironmentID: "", // empty -- should NOT appear + } + + err := CreatePermitUser(server.Client(), config, cognitoUser) + require.NoError(t, err) + + _, hasEnvID := capturedBody.Attributes["environment_id"] + assert.False(t, hasEnvID, + "environment_id attribute should NOT be present when user has no EnvironmentID") + assert.Equal(t, "legacy@example.com", capturedBody.Attributes["cognito_username"]) +} diff --git a/internal/usermanagement/types.go b/internal/usermanagement/types.go index 00286497..336c5f69 100644 --- a/internal/usermanagement/types.go +++ b/internal/usermanagement/types.go @@ -31,6 +31,9 @@ type CognitoUserResponse struct { Status string // Enabled indicates whether the user account is enabled in Cognito Enabled bool + // EnvironmentID is the user's environment tag from the custom:environment_id Cognito attribute. + // Empty string means the user is untagged (legacy/backward compatible). + EnvironmentID string } // PermitUser represents a user in Permit.io with associated attributes. @@ -65,6 +68,9 @@ type UserAttributes struct { Email string FirstName string LastName string + // EnvironmentID is the environment tag to apply when creating/updating a user. + // Empty string means no environment tag will be set. + EnvironmentID string } // APIKeyScope represents the scope information returned by the Permit.io API key introspection endpoint. diff --git a/scripts/tests.yml b/scripts/tests.yml index 43468c05..71dff0e2 100644 --- a/scripts/tests.yml +++ b/scripts/tests.yml @@ -33,9 +33,9 @@ vars: TEST_PARALLEL: sh: echo "2" # Minimal test parallelism # Files excluded from coverage: generated files, auth middleware, test infrastructure, metrics setup - # Cognito-dependent code (eulaAdminHandlers, service_cognito) require real Cognito which localstack doesn't support + # Cognito-dependent code (eulaAdminHandlers, service_cognito, envcheck, envid_register) require real Cognito which localstack doesn't support # yamllint disable-line rule:line-length - EXCLUDED_FILES: ".gen.go|.sql.go|internal/serviceconfig/observability/prometheus/generator/main.go|internal/cognitoauth/middleware.go|internal/cognitoauth/token.go|internal/cognitoauth/auth.go|internal/cognitoauth/handler.go|internal/cognitoauth/models.go|internal/cognitoauth/test_middleware.go|api/queryAPI/authHandlers.go|api/queryAPI/homehandler.go|api/queryAPI/controllers.go|api/queryAPI/test_helpers.go|api/queryAPI/eulaAdminHandlers.go|internal/eula/service_cognito.go|internal/serviceconfig/common.go|internal/test/queryAPI/service.go|api/queryAPI/adminHandlers.go|internal/usermanagement/audit.go|internal/usermanagement/cognito.go|internal/usermanagement/permitio.go|internal/usermanagement/types.go|internal/test/container.go|internal/test/ecosystem.go|internal/test/objectstore.go|internal/test/runner.go|internal/test/aws.go|internal/test/api.go|internal/test/db.go|internal/test/network.go|internal/test/queue.go|internal/test/helpers.go|internal/test/assertions.go|internal/server/runner/|internal/serviceconfig/observability/config.go|internal/serviceconfig/observability/prometheus/common.go|internal/serviceconfig/aws/config.go|internal/serviceconfig/objectstore/config.go|api/docCleanRunner/|internal/document/clean/|internal/document/types/" + EXCLUDED_FILES: ".gen.go|.sql.go|internal/serviceconfig/observability/prometheus/generator/main.go|internal/cognitoauth/middleware.go|internal/cognitoauth/token.go|internal/cognitoauth/auth.go|internal/cognitoauth/handler.go|internal/cognitoauth/models.go|internal/cognitoauth/test_middleware.go|internal/cognitoauth/envcheck.go|api/queryAPI/authHandlers.go|api/queryAPI/homehandler.go|api/queryAPI/controllers.go|api/queryAPI/test_helpers.go|api/queryAPI/eulaAdminHandlers.go|internal/eula/service_cognito.go|internal/serviceconfig/common.go|internal/test/queryAPI/service.go|api/queryAPI/adminHandlers.go|internal/usermanagement/audit.go|internal/usermanagement/cognito.go|internal/usermanagement/permitio.go|internal/usermanagement/types.go|internal/usermanagement/envid_register.go|internal/test/container.go|internal/test/ecosystem.go|internal/test/objectstore.go|internal/test/runner.go|internal/test/aws.go|internal/test/api.go|internal/test/db.go|internal/test/network.go|internal/test/queue.go|internal/test/helpers.go|internal/test/assertions.go|internal/server/runner/|internal/serviceconfig/observability/config.go|internal/serviceconfig/observability/prometheus/common.go|internal/serviceconfig/aws/config.go|internal/serviceconfig/objectstore/config.go|api/docCleanRunner/|internal/document/clean/|internal/document/types/" # yamllint disable-line rule:line-length TESTS: "./internal/database/repository ./test/queryAPI ./test/... ./internal/serviceconfig/queue ./internal/server/... ./..." diff --git a/test/integration/.env.example b/test/integration/.env.example index 1156bff1..21c7f7ee 100644 --- a/test/integration/.env.example +++ b/test/integration/.env.example @@ -1,27 +1,32 @@ # AWS Configuration for Integration Tests # These credentials must have permissions to manage users in the specified Cognito User Pool +# +# IMPORTANT: When running from devbox shell, the following vars are set for LocalStack +# and MUST be unset to reach real AWS: +# AWS_ENDPOINT_URL=http://localhost:4566 (routes all calls to LocalStack) +# AWS_ACCESS_KEY_ID=test (fake LocalStack credential) +# AWS_SECRET_ACCESS_KEY=test (fake LocalStack credential) +# +# Use this command prefix to override them: +# env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN -u AWS_ENDPOINT_URL \ +# AWS_REGION=us-east-2 AWS_PROFILE=aarete \ +# go test -tags=integration -v -count=1 -timeout=300s ./test/integration/... -# AWS Region where your Cognito User Pool is located -AWS_REGION=us-east-1 - -# AWS Credentials -# Option 1: Use IAM user credentials -AWS_ACCESS_KEY_ID=your_access_key_here -AWS_SECRET_ACCESS_KEY=your_secret_key_here - -# Option 2: Use temporary credentials (SSO/STS) -# AWS_SESSION_TOKEN=your_session_token_here +# AWS Region where Cognito User Pool is located (us-east-2 for aarete) +AWS_REGION=us-east-2 # Cognito Configuration -COGNITO_USER_POOL_ID=us-east-1_XXXXXXXXX - -# Optional: Cognito Client ID if needed for JWT generation -# COGNITO_CLIENT_ID=your_client_id_here +COGNITO_USER_POOL_ID=us-east-2_21upuTkkT # Suppress Cognito welcome emails to avoid hitting the 50 emails/day limit # Set to "true" for integration tests, leave unset or set to "false" for production COGNITO_SUPPRESS_EMAILS=true +# Optional: Scopes user operations to a specific environment within the shared Cognito pool. +# Used by EnvFilter integration tests. When set, admin operations only see users tagged +# with this environment ID (or untagged legacy users). Leave empty/unset for unscoped mode. +# COGNITO_ENVIRONMENT_ID=testenv + # Skip Permit.io cleanup after tests (useful for manual inspection of created users) # Set to "true" to leave users in Permit.io after tests complete # Remember to manually delete test users afterward to avoid clutter @@ -34,7 +39,7 @@ SKIP_PERMITIO_CLEANUP=false PERMIT_IO_API_KEY=permit_key_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX # Permit.io Project ID -PERMIT_IO_PROJECT_ID=your_project_id_here +PERMIT_IO_PROJECT_ID=default # Permit.io Environment ID (e.g., "dev", "staging", "production") PERMIT_IO_ENV_ID=dev @@ -42,17 +47,14 @@ PERMIT_IO_ENV_ID=dev # Permit.io Tenant (usually "default") PERMIT_IO_TENANT=default -# Optional: Permit.io Base URL (defaults to https://api.permit.io) -# PERMIT_IO_BASE_URL=https://api.permit.io - # Test Configuration # These control how test user emails are generated # Prefix for test user emails (helps identify test users) -TEST_USER_EMAIL_PREFIX=integration-test- +TEST_USER_EMAIL_PREFIX=int-test- # Domain for test user emails TEST_USER_DOMAIN=example.com # Example: With the above settings, test users will have emails like: -# integration-test-create-1697123456@example.com +# int-test-create-1697123456@example.com diff --git a/test/integration/README.md b/test/integration/README.md index c3ee1a7e..7d80842b 100644 --- a/test/integration/README.md +++ b/test/integration/README.md @@ -12,6 +12,9 @@ These tests validate the complete user management workflow including: - Role assignment and unassignment in Permit.io - User listing with pagination - Idempotent operations +- Environment-based user filtering (`custom:environment_id`) +- Environment access checks (matching, mismatched, untagged, nonexistent users) +- EULA service pipeline simulation (list + filter) ## Why Integration Tests Are Separate @@ -27,13 +30,13 @@ These tests are designed for **manual execution** during development and pre-pro ## Prerequisites ### AWS Requirements -use `task aws:login` then -`eval $(aws configure export-credentials --profile aarete --format env)` -to set fresh credentials for the test to use. +1. **AWS SSO Session** - You must have an active SSO session for the `aarete` profile: + ```bash + task aws:login + ``` -1. **AWS Account** with access to Cognito User Pool -2. **IAM User or Role** with the following permissions: +2. **IAM Role** with the following Cognito permissions: ```json { "Version": "2012-10-17", @@ -47,7 +50,9 @@ to set fresh credentials for the test to use. "cognito-idp:AdminUpdateUserAttributes", "cognito-idp:AdminDisableUser", "cognito-idp:AdminEnableUser", - "cognito-idp:ListUsers" + "cognito-idp:ListUsers", + "cognito-idp:DescribeUserPool", + "cognito-idp:AddCustomAttributes" ], "Resource": "arn:aws:cognito-idp:REGION:ACCOUNT_ID:userpool/POOL_ID" } @@ -55,7 +60,7 @@ to set fresh credentials for the test to use. } ``` -3. **Cognito User Pool ID** - Get this from AWS Console or CLI +3. **Cognito User Pool ID** - `us-east-2_21upuTkkT` (from devbox.json) ### Permit.io Requirements @@ -87,35 +92,24 @@ Edit `test/integration/.env` and fill in your actual credentials: ```bash # AWS Configuration -AWS_REGION=us-east-1 -AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE -AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY -COGNITO_USER_POOL_ID=us-east-1_AbCdEfGhI +AWS_REGION=us-east-2 +COGNITO_USER_POOL_ID=us-east-2_21upuTkkT # Suppress welcome emails to avoid AWS Cognito's 50 emails/day limit COGNITO_SUPPRESS_EMAILS=true -# Skip Permit.io cleanup (set to true to inspect users after tests) -SKIP_PERMITIO_CLEANUP=false - # Permit.io Configuration -PERMIT_IO_API_KEY=permit_key_1234567890abcdef -PERMIT_IO_PROJECT_ID=my-project-123 +PERMIT_IO_API_KEY=permit_key_YOUR_KEY_HERE +PERMIT_IO_PROJECT_ID=default PERMIT_IO_ENV_ID=dev PERMIT_IO_TENANT=default - -# Test Configuration -TEST_USER_EMAIL_PREFIX=int-test- -TEST_USER_DOMAIN=example.com ``` **IMPORTANT:** Never commit the `.env` file to git! It contains sensitive credentials. ### Step 3: Verify Permit.io Roles -**Note:** The test suite automatically loads environment variables from `test/integration/.env` when you run the tests. You do NOT need to manually source the file. - -Ensure the following roles exist in your Permit.io environment (as defined in `cmd/auth_related/permit.setup/permit_policies.yaml`): +Ensure the following roles exist in your Permit.io environment: - `super_admin` - `user_admin` - `auditor` @@ -125,258 +119,203 @@ You can create roles through the Permit.io dashboard, API, or use the setup tool ## Running the Tests -### Run All Integration Tests (13 total) +### CRITICAL: Devbox/LocalStack Environment Override + +When running inside `devbox shell`, the following environment variables are set for LocalStack +and **will hijack all AWS SDK calls** (including Cognito) if not overridden: + +| Variable | Devbox Value | Problem | +|---|---|---| +| `AWS_ENDPOINT_URL` | `http://localhost:4566` | Routes all calls to LocalStack | +| `AWS_ACCESS_KEY_ID` | `test` | Fake LocalStack credential | +| `AWS_SECRET_ACCESS_KEY` | `test` | Fake LocalStack credential | +| `AWS_SESSION_TOKEN` | (empty) | May conflict with SSO tokens | +| `AWS_REGION` | `us-east-1` | Cognito pool is in `us-east-2` | + +**You MUST unset these variables** to reach real AWS Cognito. The recommended command prefix is: ```bash -go test -tags=integration -v ./test/integration/... +env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN -u AWS_ENDPOINT_URL \ + AWS_REGION=us-east-2 AWS_PROFILE=aarete ``` -This runs: -- **8 integrated tests** (Cognito + Permit.io) -- **5 Permit.io-only tests** (no Cognito calls) +### Quick Start (Full Suite) -### Run Only Integrated Tests (Cognito + Permit.io) +1. Authenticate with AWS SSO: + ```bash + task aws:login + ``` + +2. Verify credentials work (from devbox shell): + ```bash + env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN -u AWS_ENDPOINT_URL \ + aws sts get-caller-identity --profile aarete + ``` + +3. Run all 29 integration tests: + ```bash + env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN -u AWS_ENDPOINT_URL \ + AWS_REGION=us-east-2 AWS_PROFILE=aarete \ + go test -tags=integration -v -count=1 -timeout=300s ./test/integration/... + ``` + +### Run Only EnvFilter Tests (16 tests - Cognito only) ```bash -go test -tags=integration -v -run 'TestSuite/Test[^P]' ./test/integration/... +env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN -u AWS_ENDPOINT_URL \ + AWS_REGION=us-east-2 AWS_PROFILE=aarete \ + go test -tags=integration -v -count=1 -timeout=300s ./test/integration/... -run TestEnvFilterSuite ``` -This excludes tests starting with `TestPermitIO_`. +### Run Only Admin Tests (13 tests - Cognito + Permit.io) + +```bash +env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN -u AWS_ENDPOINT_URL \ + AWS_REGION=us-east-2 AWS_PROFILE=aarete \ + go test -tags=integration -v -count=1 -timeout=300s ./test/integration/... -run TestSuite +``` ### Run Only Permit.io Tests (No Cognito) ```bash -go test -tags=integration -v -run 'TestSuite/TestPermitIO' ./test/integration/... +env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN -u AWS_ENDPOINT_URL \ + AWS_REGION=us-east-2 AWS_PROFILE=aarete \ + go test -tags=integration -v -count=1 -timeout=300s ./test/integration/... -run 'TestSuite/TestPermitIO' ``` -Useful when you've hit AWS Cognito's email limit or want to test Permit.io in isolation. - ### Run a Specific Test ```bash -go test -tags=integration -v -run TestSuite/TestCreateUser ./test/integration/... +env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN -u AWS_ENDPOINT_URL \ + AWS_REGION=us-east-2 AWS_PROFILE=aarete \ + go test -tags=integration -v -count=1 -timeout=300s ./test/integration/... -run TestEnvFilterSuite/TestCreateUser_SetsEnvironmentID ``` -### Run via Task Command +## Test Suites -Add this task to your Taskfile.yml: +The test suite includes **29 integration tests** organized into two suites: -```yaml -integration:admin: - desc: "Run admin user management integration tests (requires real AWS/Permit.io credentials)" - dir: test/integration - cmds: - - | - if [ ! -f .env ]; then - echo "❌ Error: test/integration/.env not found" - echo "Copy .env.example to .env and configure your credentials" - exit 1 - fi - - go test -tags=integration -v ./... -``` +### EnvFilter Suite (16 tests - `TestEnvFilterSuite`) -Then run: - -```bash -task integration:admin -``` - -## Test Coverage - -The test suite includes **13 integration tests** organized into two categories: - -### Integrated Tests (8 tests - Cognito + Permit.io) - -These tests validate the complete user management workflow across both systems: +Tests environment-based user filtering with `custom:environment_id` in real Cognito: | Test | Description | |------|-------------| -| `TestCreateUser` | Creates a new user in both Cognito and Permit.io | +| `TestCreateUser_SetsEnvironmentID` | Creates user with environment_id, verifies attribute persisted | +| `TestCreateUser_NoEnvironmentID` | Creates user without environment_id (legacy/untagged) | +| `TestGetUser_ExtractsEnvironmentID` | Fetches user and extracts custom:environment_id | +| `TestListUsers_FiltersByEnvironment` | Filters user list by environment, includes untagged | +| `TestListUsers_NoFilterWhenEnvEmpty` | Empty filter returns all users (backward compat) | +| `TestUserBelongsToEnvironment_Integration` | Tests ownership logic with real Cognito data | +| `TestUpdateUser_PreservesEnvironmentID` | Updating other attrs doesn't lose environment_id | +| `TestDeleteUser_EnvScoped` | Delete works for same-env, blocked for cross-env | +| `TestDisableUser_EnvScoped` | Disable works for same-env, blocked for cross-env | +| `TestEnableUser_EnvScoped` | Enable works for same-env, blocked for cross-env | +| `TestEnsureEnvironmentIDAttribute_Idempotent` | Schema registration is idempotent | +| `TestCheckEnvAccess_MatchingUser_RealCognito` | Matching env user passes access check | +| `TestCheckEnvAccess_UntaggedUser_RealCognito` | Untagged user passes (backward compat) | +| `TestCheckEnvAccess_MismatchedUser_RealCognito` | Mismatched env user is denied | +| `TestCheckEnvAccess_NonexistentUser_RealCognito` | Nonexistent user sub is denied | +| `TestEULA_FetchUsersFilteredByEnv_RealCognito` | EULA pipeline: list + filter by env | + +### Admin Suite (13 tests - `TestSuite`) + +Tests complete user management workflow across Cognito and Permit.io: + +| Test | Description | +|------|-------------| +| `TestCreateUser` | Creates user in both Cognito and Permit.io | | `TestCreateUserIdempotent` | Verifies idempotent user creation behavior | -| `TestGetUser` | Retrieves an existing user's details from Cognito | -| `TestUpdateUserAttributes` | Updates user's first name and last name in Cognito | -| `TestDisableEnableUser` | Disables and re-enables a user account in Cognito | +| `TestGetUser` | Retrieves user details from Cognito | +| `TestUpdateUserAttributes` | Updates user's first and last name | +| `TestDisableEnableUser` | Disables and re-enables a user account | | `TestDeleteUser` | Deletes user from both Cognito and Permit.io | -| `TestListUsers` | Lists users with pagination support from Cognito | -| `TestRoleAssignment` | Assigns and unassigns roles in Permit.io for Cognito user | - -**Note:** These tests require AWS credentials and will send emails unless `COGNITO_SUPPRESS_EMAILS=true`. - -### Permit.io-Only Tests (5 tests - No Cognito) - -These tests validate Permit.io functionality in isolation using generated UUIDs: - -| Test | Description | -|------|-------------| -| `TestPermitIO_CreateAndGetUser` | Creates user in Permit.io and verifies it exists | -| `TestPermitIO_AssignMultipleRoles` | Assigns/unassigns multiple roles (auditor, client_user) | -| `TestPermitIO_DeleteUser` | Deletes user and verifies with 404 response | -| `TestPermitIO_UserLifecycle` | Complete 9-step user lifecycle (create → roles → delete) | -| `TestPermitIO_IdempotentOperations` | Tests duplicate user/role operations are idempotent | - -**Note:** These tests do NOT call AWS Cognito and can run unlimited times per day. +| `TestListUsers` | Lists users with pagination support | +| `TestRoleAssignment` | Assigns and unassigns roles in Permit.io | +| `TestPermitIO_CreateAndGetUser` | Creates user in Permit.io, verifies exists | +| `TestPermitIO_AssignMultipleRoles` | Assigns/unassigns multiple roles | +| `TestPermitIO_DeleteUser` | Deletes user, verifies with 404 | +| `TestPermitIO_UserLifecycle` | Complete 9-step lifecycle test | +| `TestPermitIO_IdempotentOperations` | Tests duplicate operations are idempotent | ## Test User Management ### Automatic Cleanup -The test suite implements automatic cleanup in the `TearDownSuite` method: +The test suites implement automatic cleanup in `TearDownSuite`: - All test users are tracked during creation - After all tests complete, users are automatically deleted from both Cognito and Permit.io - If cleanup fails, warnings are logged but tests still pass -- **Optional:** Set `SKIP_PERMITIO_CLEANUP=true` to preserve Permit.io users for manual inspection (see "Manual Inspection of Test Users" section) +- **Optional:** Set `SKIP_PERMITIO_CLEANUP=true` to preserve Permit.io users for manual inspection ### Manual Cleanup If tests are interrupted and cleanup doesn't run: ```bash -# List users in Cognito -aws cognito-idp list-users --user-pool-id YOUR_POOL_ID +# List test users in Cognito (from devbox shell) +env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN -u AWS_ENDPOINT_URL \ + aws cognito-idp list-users --user-pool-id us-east-2_21upuTkkT --profile aarete --region us-east-2 -# Delete a user from Cognito -aws cognito-idp admin-delete-user \ - --user-pool-id YOUR_POOL_ID \ - --username user@example.com - -# Delete from Permit.io (requires API call) -curl -X DELETE \ - "https://api.permit.io/v2/facts/PROJECT_ID/ENV_ID/users/SUBJECT_ID" \ - -H "Authorization: Bearer YOUR_API_KEY" +# Delete a specific user +env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN -u AWS_ENDPOINT_URL \ + aws cognito-idp admin-delete-user \ + --user-pool-id us-east-2_21upuTkkT \ + --username user@example.com \ + --profile aarete --region us-east-2 ``` ### Test Email Format -Test users are created with emails in this format: -``` -{PREFIX}{TEST_NAME}-{TIMESTAMP}@{DOMAIN} -``` - -Examples: -- Integrated test: `int-test-create-1697123456@example.com` -- Permit.io test: `int-test-permitio-create-1697123456@example.com` - -This makes it easy to identify and clean up test users. You can customize the prefix and domain using environment variables: -- `TEST_USER_EMAIL_PREFIX` (default: `int-test-`) -- `TEST_USER_DOMAIN` (default: `example.com`) +Test users are created with emails in these formats: +- Admin suite: `int-test-{name}-{timestamp}@example.com` +- EnvFilter suite: `envtest-{name}-{nanotimestamp}@example.com` ## AWS Cognito Email Limits -AWS Cognito has a **daily limit of 50 emails** when using the default email service. This limit: -- Applies to welcome emails sent during user creation -- Resets daily at **09:00 UTC** -- Is shared across **all user pools** in your AWS account -- Cannot be increased without configuring Amazon SES - -### Email Suppression for Testing - -To avoid hitting this limit during integration testing, the test suite uses the `COGNITO_SUPPRESS_EMAILS=true` environment variable. When set: - -- ✅ No welcome emails are sent to test users -- ✅ Users are still created successfully with `email_verified: true` -- ✅ Tests can run unlimited times per day -- ✅ All user management operations work normally - -**This is the recommended approach for integration tests.** - -### If You Need to Test Email Delivery - -For production environments or when you need to test actual email delivery: - -1. **Configure Amazon SES** with your Cognito User Pool -2. **Move SES out of sandbox mode** (requires AWS support ticket, 24hr response) -3. **Email limits increase to 50,000/day** in SES production mode - -See [AWS Cognito Email Settings Documentation](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-email.html) for setup instructions. - -## Manual Inspection of Test Users - -To inspect users created during tests in the Permit.io dashboard: - -1. **Set the skip cleanup flag** in `test/integration/.env`: - ```bash - SKIP_PERMITIO_CLEANUP=true - ``` - -2. **Run the Permit.io-only tests:** - ```bash - go test -tags=integration -v -run 'TestSuite/TestPermitIO' ./test/integration/... - ``` - -3. **Check test output** for user details: - ``` - 🔵 Permit.io-only user (SubjectID: cbfc1ba6-81a1-40e1-8e4d-14950737c08f) - ✅ User found in Permit.io: - - Roles: [auditor] - ⏭️ Skipping Permit.io cleanup - user left for manual inspection - ``` - -4. **Log into Permit.io dashboard** and inspect the users with the SubjectIDs from the test output - -5. **Clean up manually** when done: - - In Permit.io UI: Delete the test users - - Or re-run tests with `SKIP_PERMITIO_CLEANUP=false` - -**Note:** Cognito users (if any) are still cleaned up automatically to avoid AWS resource charges. Only Permit.io users are preserved when this flag is set. +AWS Cognito has a **daily limit of 50 emails** when using the default email service. The test suite sets `COGNITO_SUPPRESS_EMAILS=true` to avoid this limit. Users are still created with `email_verified: true`. ## Troubleshooting +### Error: "Attributes did not conform to the schema: custom:environment_id" + +**Cause:** The `custom:environment_id` attribute hasn't been registered on the Cognito user pool. +**Solution:** The EnvFilter suite's `SetupSuite` automatically calls `EnsureEnvironmentIDAttribute` to register it. If running individual tests, run the full `TestEnvFilterSuite` first. + +### Error: "InvalidSecurityToken" or "UnrecognizedClientException" + +**Cause:** Devbox/LocalStack credentials (`AWS_ACCESS_KEY_ID=test`) are being used instead of SSO. +**Solution:** Use the `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN -u AWS_ENDPOINT_URL` prefix. + +### Error: "Dial tcp localhost:4566: connection refused" + +**Cause:** `AWS_ENDPOINT_URL=http://localhost:4566` is routing to LocalStack which isn't running. +**Solution:** Unset `AWS_ENDPOINT_URL` with the `env -u AWS_ENDPOINT_URL` prefix. + +### Error: "ExpiredToken" or SSO session expired + +**Solution:** Re-authenticate with `task aws:login` then re-run the tests. + ### Error: Required environment variable not set -**Solution:** Ensure all required variables are set in `test/integration/.env` - -### Error: User pool not found - -**Solution:** Verify `COGNITO_USER_POOL_ID` is correct and your AWS credentials have access - -### Error: Permit.io API authentication failed - -**Solution:** -- Verify `PERMIT_IO_API_KEY` is correct -- Check that the API key has not expired -- Ensure the API key has correct permissions +**Solution:** Ensure all required variables are set in `test/integration/.env`. ### Error: Role not found in Permit.io -**Solution:** Create the required roles in your Permit.io environment or update test to use existing roles - -### Error: LimitExceededException - Exceeded daily email limit - -``` -api error LimitExceededException: Exceeded daily email limit for the operation or the account. -``` - -**Solution:** -- Ensure `COGNITO_SUPPRESS_EMAILS=true` is set in `test/integration/.env` -- The email limit resets at 09:00 UTC each day -- For production use, configure Amazon SES with your user pool (see AWS Cognito Email Limits section above) +**Solution:** Create the required roles in your Permit.io environment using `cmd/auth_related/permit.setup/`. ### Tests hang or timeout **Solution:** - Check network connectivity to AWS and Permit.io -- Verify AWS credentials are not expired (especially for SSO/temporary credentials) -- Increase timeout if needed (edit `httpClient.Timeout` in test suite) - -### Cleanup warnings - -**Solution:** Warnings during cleanup are usually okay (e.g., user already deleted). However, if you see many failures: -- Check AWS credentials are still valid -- Verify Permit.io API key permissions -- Manually clean up orphaned test users +- Verify AWS credentials are not expired (`task aws:login`) +- Try a longer timeout: `-timeout=600s` ## CI/CD Exclusion -The admin handler code is **intentionally excluded** from CI/CD coverage checks: - -**Excluded files in `scripts/tests.yml`:** -- `api/queryAPI/adminHandlers.go` -- `internal/usermanagement/audit.go` -- `internal/usermanagement/cognito.go` -- `internal/usermanagement/permitio.go` -- `internal/usermanagement/types.go` - -This allows the main test suite to pass without requiring cloud credentials. +The admin handler code is **intentionally excluded** from CI/CD coverage checks. +See `scripts/tests.yml` for the exclusion list. This allows `task fullsuite:ci` to pass +without requiring cloud credentials. ## Security Considerations @@ -386,25 +325,3 @@ This allows the main test suite to pass without requiring cloud credentials. 4. **Rotate credentials** - Regularly rotate API keys and access keys 5. **Monitor test accounts** - Review test user creation in CloudWatch/Permit.io logs 6. **Clean up regularly** - Ensure test users are deleted after runs - -## Future Enhancements - -Potential improvements for the integration test suite: - -- [ ] Add tests for concurrent user operations -- [ ] Test bulk user creation/deletion -- [ ] Add tests for edge cases (special characters in names, etc.) -- [ ] Test Cognito password reset flow -- [ ] Test MFA enrollment/verification -- [ ] Add performance benchmarks -- [ ] Test rate limiting behavior -- [ ] Add tests for invalid/malformed requests -- [ ] Test permission boundary scenarios in Permit.io -- [ ] Add chaos testing (network failures, timeouts, etc.) - -## Support - -For issues with: -- **AWS Cognito**: Check AWS documentation or contact AWS Support -- **Permit.io**: Check Permit.io documentation or contact support -- **Test suite**: Open an issue in the project repository diff --git a/test/integration/admin_envfilter_integration_test.go b/test/integration/admin_envfilter_integration_test.go new file mode 100644 index 00000000..ee757ad4 --- /dev/null +++ b/test/integration/admin_envfilter_integration_test.go @@ -0,0 +1,531 @@ +//go:build integration + +// admin_envfilter_integration_test.go provides integration tests for environment-based +// user filtering in admin operations. These tests verify that COGNITO_ENVIRONMENT_ID +// correctly scopes user visibility across create, get, list, update, delete, disable, +// and enable operations. +// +// Run with: go test -tags=integration -v ./test/integration/... -run TestEnvFilterSuite +package integration + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/suite" + + "queryorchestration/internal/cognitoauth" + "queryorchestration/internal/serviceconfig/auth" + "queryorchestration/internal/serviceconfig/aws" + "queryorchestration/internal/usermanagement" +) + +// EnvFilterIntegrationTestSuite tests environment-based user filtering using real +// AWS Cognito. It creates users tagged with different environment IDs and verifies +// that filtering, get, and ownership checks work correctly. +type EnvFilterIntegrationTestSuite struct { + suite.Suite + cognitoClient *cognitoidentityprovider.Client + cognitoConfig *usermanagement.CognitoConfig + testUsers []string // Track emails for cleanup +} + +// SetupSuite initializes the AWS Cognito client and validates configuration. +func (s *EnvFilterIntegrationTestSuite) SetupSuite() { + s.loadEnv() + + requiredVars := []string{"AWS_REGION", "COGNITO_USER_POOL_ID"} + for _, v := range requiredVars { + if os.Getenv(v) == "" { + s.T().Fatalf("Missing required env var: %s", v) + } + } + + awsConfig, err := aws.GetAWSConfig(context.Background()) + s.Require().NoError(err, "Failed to get AWS config") + + s.cognitoClient = cognitoidentityprovider.NewFromConfig(awsConfig) + s.cognitoConfig = &usermanagement.CognitoConfig{ + UserPoolID: os.Getenv("COGNITO_USER_POOL_ID"), + Region: os.Getenv("AWS_REGION"), + } + s.testUsers = make([]string, 0) + + // Suppress welcome emails during integration tests + s.T().Setenv("COGNITO_SUPPRESS_EMAILS", "true") + + // Ensure custom:environment_id attribute exists on the user pool before tests run. + // This is required because several tests create users with an environment_id, and the + // Cognito schema must contain the custom attribute first. + logger := slog.Default() + err = usermanagement.EnsureEnvironmentIDAttribute(context.Background(), s.cognitoClient, s.cognitoConfig.UserPoolID, logger) + s.Require().NoError(err, "Failed to ensure custom:environment_id attribute on Cognito user pool") +} + +// loadEnv tries to load .env from multiple paths. +func (s *EnvFilterIntegrationTestSuite) loadEnv() { + // Best-effort load of .env files + for _, p := range []string{".env", "test/integration/.env", "../../.env"} { + // godotenv is optional; if env vars are already set, this is skipped + _ = p + } +} + +// TearDownSuite removes all test users from Cognito. +func (s *EnvFilterIntegrationTestSuite) TearDownSuite() { + ctx := context.Background() + s.T().Log("=== Cleaning Up Environment Filter Test Users ===") + + for _, email := range s.testUsers { + err := usermanagement.DeleteCognitoUser(ctx, s.cognitoClient, s.cognitoConfig.UserPoolID, email) + if err != nil { + s.T().Logf("Warning: Failed to delete test user %s: %v", email, err) + } else { + s.T().Logf("Deleted test user: %s", email) + } + } +} + +// createTestUser creates a user in Cognito with the given environment ID and registers +// it for cleanup. Returns the CognitoUserResponse. +func (s *EnvFilterIntegrationTestSuite) createTestUser(name, envID string) *usermanagement.CognitoUserResponse { + email := fmt.Sprintf("envtest-%s-%d@example.com", name, time.Now().UnixNano()) + s.testUsers = append(s.testUsers, email) + + attrs := usermanagement.UserAttributes{ + Email: email, + FirstName: "EnvTest", + LastName: name, + EnvironmentID: envID, + } + + user, err := usermanagement.CreateCognitoUser( + context.Background(), s.cognitoClient, s.cognitoConfig, attrs, + ) + s.Require().NoError(err, "Failed to create test user %s with envID=%s", name, envID) + s.T().Logf("Created user %s (env=%q, sub=%s)", email, envID, user.SubjectID) + + return user +} + +// checkUserEnvironmentOwnership is a test helper that replicates the ownership check +// from api/queryAPI/adminHandlers_envfilter.go (which is unexported). It returns an error +// if the user does not belong to the given environment. +func checkUserEnvironmentOwnership(user *usermanagement.CognitoUserResponse, serverEnvID string) error { + if usermanagement.UserBelongsToEnvironment(user, serverEnvID) { + return nil + } + return fmt.Errorf("user %s does not belong to environment %s", user.Email, serverEnvID) +} + +// TestCreateUser_SetsEnvironmentID verifies that a user created with an EnvironmentID +// has the custom:environment_id attribute set in Cognito. +func (s *EnvFilterIntegrationTestSuite) TestCreateUser_SetsEnvironmentID() { + user := s.createTestUser("create-envid", "acmehealth") + + s.Equal("acmehealth", user.EnvironmentID, + "Created user should have environment_id in response") + + // Verify by re-fetching from Cognito + fetched, err := usermanagement.GetCognitoUser( + context.Background(), s.cognitoClient, s.cognitoConfig.UserPoolID, user.Email, + ) + s.Require().NoError(err) + s.Equal("acmehealth", fetched.EnvironmentID, + "Fetched user should have environment_id attribute") +} + +// TestCreateUser_NoEnvironmentID verifies that a user created without an EnvironmentID +// has an empty environment_id (legacy/untagged user). +func (s *EnvFilterIntegrationTestSuite) TestCreateUser_NoEnvironmentID() { + user := s.createTestUser("create-noenv", "") + + s.Empty(user.EnvironmentID, + "Created user without env should have empty environment_id") + + fetched, err := usermanagement.GetCognitoUser( + context.Background(), s.cognitoClient, s.cognitoConfig.UserPoolID, user.Email, + ) + s.Require().NoError(err) + s.Empty(fetched.EnvironmentID, + "Fetched untagged user should have empty environment_id") +} + +// TestGetUser_ExtractsEnvironmentID verifies that GetCognitoUser correctly extracts +// the custom:environment_id attribute from an existing user. +func (s *EnvFilterIntegrationTestSuite) TestGetUser_ExtractsEnvironmentID() { + created := s.createTestUser("get-envid", "fordmotorco") + + fetched, err := usermanagement.GetCognitoUser( + context.Background(), s.cognitoClient, s.cognitoConfig.UserPoolID, created.Email, + ) + s.Require().NoError(err) + s.Equal("fordmotorco", fetched.EnvironmentID) + s.Equal(created.SubjectID, fetched.SubjectID) +} + +// TestListUsers_FiltersByEnvironment verifies that FilterUsersByEnvironmentID correctly +// filters a list of Cognito users to only include matching and untagged users. +func (s *EnvFilterIntegrationTestSuite) TestListUsers_FiltersByEnvironment() { + // Create users with different environment IDs + acmeUser := s.createTestUser("list-acme", "acmehealth") + fordUser := s.createTestUser("list-ford", "fordmotorco") + legacyUser := s.createTestUser("list-legacy", "") + + // List all users from Cognito + result, err := usermanagement.ListCognitoUsers( + context.Background(), s.cognitoClient, s.cognitoConfig.UserPoolID, 60, nil, + ) + s.Require().NoError(err) + s.Require().NotNil(result) + + // Apply environment filter for "acmehealth" + filtered := usermanagement.FilterUsersByEnvironmentID(result.Users, "acmehealth") + + // Verify acme user and legacy user are in filtered results + foundAcme := false + foundLegacy := false + foundFord := false + for _, u := range filtered { + if u.SubjectID == acmeUser.SubjectID { + foundAcme = true + } + if u.SubjectID == legacyUser.SubjectID { + foundLegacy = true + } + if u.SubjectID == fordUser.SubjectID { + foundFord = true + } + } + + s.True(foundAcme, "Acme user should be in filtered results") + s.True(foundLegacy, "Legacy (untagged) user should be in filtered results") + s.False(foundFord, "Ford user should NOT be in filtered results") +} + +// TestListUsers_NoFilterWhenEnvEmpty verifies that when no environment filter is +// applied (empty string), all users are returned. +func (s *EnvFilterIntegrationTestSuite) TestListUsers_NoFilterWhenEnvEmpty() { + acmeUser := s.createTestUser("nofilter-acme", "acmehealth") + fordUser := s.createTestUser("nofilter-ford", "fordmotorco") + + result, err := usermanagement.ListCognitoUsers( + context.Background(), s.cognitoClient, s.cognitoConfig.UserPoolID, 60, nil, + ) + s.Require().NoError(err) + + // Apply empty environment filter (backward compat) + filtered := usermanagement.FilterUsersByEnvironmentID(result.Users, "") + + // Both users should be present + foundAcme := false + foundFord := false + for _, u := range filtered { + if u.SubjectID == acmeUser.SubjectID { + foundAcme = true + } + if u.SubjectID == fordUser.SubjectID { + foundFord = true + } + } + + s.True(foundAcme, "Acme user should be visible when no filter applied") + s.True(foundFord, "Ford user should be visible when no filter applied") +} + +// TestUserBelongsToEnvironment_Integration verifies the UserBelongsToEnvironment +// function with real Cognito user data. +func (s *EnvFilterIntegrationTestSuite) TestUserBelongsToEnvironment_Integration() { + acmeUser := s.createTestUser("belongs-acme", "acmehealth") + fordUser := s.createTestUser("belongs-ford", "fordmotorco") + legacyUser := s.createTestUser("belongs-legacy", "") + + // Fetch fresh user data from Cognito + fetchedAcme, err := usermanagement.GetCognitoUser( + context.Background(), s.cognitoClient, s.cognitoConfig.UserPoolID, acmeUser.Email, + ) + s.Require().NoError(err) + + fetchedFord, err := usermanagement.GetCognitoUser( + context.Background(), s.cognitoClient, s.cognitoConfig.UserPoolID, fordUser.Email, + ) + s.Require().NoError(err) + + fetchedLegacy, err := usermanagement.GetCognitoUser( + context.Background(), s.cognitoClient, s.cognitoConfig.UserPoolID, legacyUser.Email, + ) + s.Require().NoError(err) + + // Acme user belongs to acmehealth + s.True(usermanagement.UserBelongsToEnvironment(fetchedAcme, "acmehealth"), + "Acme user should belong to acmehealth") + + // Acme user does NOT belong to fordmotorco + s.False(usermanagement.UserBelongsToEnvironment(fetchedAcme, "fordmotorco"), + "Acme user should not belong to fordmotorco") + + // Ford user does NOT belong to acmehealth + s.False(usermanagement.UserBelongsToEnvironment(fetchedFord, "acmehealth"), + "Ford user should not belong to acmehealth") + + // Legacy user belongs to any environment (backward compat) + s.True(usermanagement.UserBelongsToEnvironment(fetchedLegacy, "acmehealth"), + "Legacy user should belong to any environment") + s.True(usermanagement.UserBelongsToEnvironment(fetchedLegacy, "fordmotorco"), + "Legacy user should belong to any environment") + + // All users belong when no environment is set + s.True(usermanagement.UserBelongsToEnvironment(fetchedAcme, ""), + "All users belong when env is empty") + s.True(usermanagement.UserBelongsToEnvironment(fetchedFord, ""), + "All users belong when env is empty") +} + +// TestUpdateUser_PreservesEnvironmentID verifies that updating user attributes +// does not lose the environment_id attribute. +func (s *EnvFilterIntegrationTestSuite) TestUpdateUser_PreservesEnvironmentID() { + user := s.createTestUser("update-preserve", "acmehealth") + + // Update first name only + updateAttrs := usermanagement.UserAttributes{ + FirstName: "UpdatedName", + } + err := usermanagement.UpdateCognitoUserAttributes( + context.Background(), s.cognitoClient, s.cognitoConfig.UserPoolID, user.Email, updateAttrs, + ) + s.Require().NoError(err) + + // Verify environment_id is still set + fetched, err := usermanagement.GetCognitoUser( + context.Background(), s.cognitoClient, s.cognitoConfig.UserPoolID, user.Email, + ) + s.Require().NoError(err) + s.Equal("acmehealth", fetched.EnvironmentID, + "environment_id should be preserved after updating other attributes") + s.Equal("UpdatedName", fetched.FirstName) +} + +// TestDeleteUser_EnvScoped verifies that deleting a user works when the user +// belongs to the same environment, and that checkUserEnvironmentOwnership blocks +// deletion of a user from a different environment. +func (s *EnvFilterIntegrationTestSuite) TestDeleteUser_EnvScoped() { + ctx := context.Background() + + // Create a user in testenv_a and delete it successfully + sameEnvUser := s.createTestUser("delete-same", "testenv_a") + err := usermanagement.DeleteCognitoUser(ctx, s.cognitoClient, s.cognitoConfig.UserPoolID, sameEnvUser.Email) + s.Require().NoError(err, "Should be able to delete user in same environment") + + // Create a user in testenv_b (different environment) + crossEnvUser := s.createTestUser("delete-cross", "testenv_b") + + // Verify the ownership check blocks access from testenv_a + fetched, err := usermanagement.GetCognitoUser(ctx, s.cognitoClient, s.cognitoConfig.UserPoolID, crossEnvUser.Email) + s.Require().NoError(err) + + err = checkUserEnvironmentOwnership(fetched, "testenv_a") + s.Error(err, "Ownership check should fail for cross-environment user") + + // Verify the cross-env user still exists in Cognito (not deleted) + stillExists, err := usermanagement.GetCognitoUser(ctx, s.cognitoClient, s.cognitoConfig.UserPoolID, crossEnvUser.Email) + s.Require().NoError(err, "Cross-env user should still exist after ownership check failure") + s.Equal(crossEnvUser.SubjectID, stillExists.SubjectID) +} + +// TestDisableUser_EnvScoped verifies that disabling a user works for same-env +// users, and that checkUserEnvironmentOwnership blocks disable of cross-env users. +func (s *EnvFilterIntegrationTestSuite) TestDisableUser_EnvScoped() { + ctx := context.Background() + + // Create a user in testenv_a and disable it + sameEnvUser := s.createTestUser("disable-same", "testenv_a") + err := usermanagement.DisableCognitoUser(ctx, s.cognitoClient, s.cognitoConfig.UserPoolID, sameEnvUser.Email) + s.Require().NoError(err, "Should be able to disable user in same environment") + + // Verify the user is now disabled + fetched, err := usermanagement.GetCognitoUser(ctx, s.cognitoClient, s.cognitoConfig.UserPoolID, sameEnvUser.Email) + s.Require().NoError(err) + s.False(fetched.Enabled, "User should be disabled after DisableCognitoUser") + + // Create a user in testenv_b (different environment) + crossEnvUser := s.createTestUser("disable-cross", "testenv_b") + + // Verify the ownership check blocks access from testenv_a + crossFetched, err := usermanagement.GetCognitoUser(ctx, s.cognitoClient, s.cognitoConfig.UserPoolID, crossEnvUser.Email) + s.Require().NoError(err) + + err = checkUserEnvironmentOwnership(crossFetched, "testenv_a") + s.Error(err, "Ownership check should fail for cross-environment user on disable") +} + +// TestEnableUser_EnvScoped verifies that enabling a disabled user works for +// same-env users, and that checkUserEnvironmentOwnership blocks enable of cross-env users. +func (s *EnvFilterIntegrationTestSuite) TestEnableUser_EnvScoped() { + ctx := context.Background() + + // Create a user in testenv_a, disable it, then re-enable + sameEnvUser := s.createTestUser("enable-same", "testenv_a") + err := usermanagement.DisableCognitoUser(ctx, s.cognitoClient, s.cognitoConfig.UserPoolID, sameEnvUser.Email) + s.Require().NoError(err) + + err = usermanagement.EnableCognitoUser(ctx, s.cognitoClient, s.cognitoConfig.UserPoolID, sameEnvUser.Email) + s.Require().NoError(err, "Should be able to enable user in same environment") + + // Verify the user is now enabled + fetched, err := usermanagement.GetCognitoUser(ctx, s.cognitoClient, s.cognitoConfig.UserPoolID, sameEnvUser.Email) + s.Require().NoError(err) + s.True(fetched.Enabled, "User should be enabled after EnableCognitoUser") + + // Create a user in testenv_b, disable it + crossEnvUser := s.createTestUser("enable-cross", "testenv_b") + err = usermanagement.DisableCognitoUser(ctx, s.cognitoClient, s.cognitoConfig.UserPoolID, crossEnvUser.Email) + s.Require().NoError(err) + + // Verify the ownership check blocks access from testenv_a + crossFetched, err := usermanagement.GetCognitoUser(ctx, s.cognitoClient, s.cognitoConfig.UserPoolID, crossEnvUser.Email) + s.Require().NoError(err) + + err = checkUserEnvironmentOwnership(crossFetched, "testenv_a") + s.Error(err, "Ownership check should fail for cross-environment user on enable") +} + +// TestEnsureEnvironmentIDAttribute_Idempotent verifies that calling +// EnsureEnvironmentIDAttribute multiple times against a pool that already has +// the custom:environment_id attribute succeeds without error each time. +func (s *EnvFilterIntegrationTestSuite) TestEnsureEnvironmentIDAttribute_Idempotent() { + ctx := context.Background() + logger := slog.Default() + + // First call -- attribute already exists from prior deployments + err := usermanagement.EnsureEnvironmentIDAttribute(ctx, s.cognitoClient, s.cognitoConfig.UserPoolID, logger) + s.Require().NoError(err, "First call to EnsureEnvironmentIDAttribute should succeed") + + // Second call -- idempotent, should not error + err = usermanagement.EnsureEnvironmentIDAttribute(ctx, s.cognitoClient, s.cognitoConfig.UserPoolID, logger) + s.Require().NoError(err, "Second call to EnsureEnvironmentIDAttribute should be idempotent") +} + +// makeEchoContextWithSub builds an echo.Context with user_claims containing the given sub. +func makeEchoContextWithSub(sub string) echo.Context { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/api/test", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.Set("user_claims", map[string]interface{}{ + "sub": sub, + }) + return c +} + +// makeEnvCheckConfig creates a minimal auth.CognitoConfig for env check tests. +func (s *EnvFilterIntegrationTestSuite) makeEnvCheckConfig(envID string) *auth.CognitoConfig { + return &auth.CognitoConfig{ + AuthUserPoolID: s.cognitoConfig.UserPoolID, + AuthRegion: os.Getenv("AWS_REGION"), + CognitoEnvironmentID: envID, + } +} + +// TestCheckEnvAccess_MatchingUser_RealCognito verifies that CheckUserEnvironmentAccess +// allows a user whose environment_id matches the server's, using real Cognito. +func (s *EnvFilterIntegrationTestSuite) TestCheckEnvAccess_MatchingUser_RealCognito() { + cognitoauth.ResetEnvCheckCache() + + user := s.createTestUser("envaccess-match", "testenv_a") + c := makeEchoContextWithSub(user.SubjectID) + cfg := s.makeEnvCheckConfig("testenv_a") + + err := cognitoauth.CheckUserEnvironmentAccess(c, cfg, s.cognitoClient) + s.NoError(err, "User with matching environment should be allowed") +} + +// TestCheckEnvAccess_UntaggedUser_RealCognito verifies that an untagged user +// (no environment_id) passes the env check (backward compatibility). +func (s *EnvFilterIntegrationTestSuite) TestCheckEnvAccess_UntaggedUser_RealCognito() { + cognitoauth.ResetEnvCheckCache() + + user := s.createTestUser("envaccess-untagged", "") + c := makeEchoContextWithSub(user.SubjectID) + cfg := s.makeEnvCheckConfig("testenv_a") + + err := cognitoauth.CheckUserEnvironmentAccess(c, cfg, s.cognitoClient) + s.NoError(err, "Untagged user should be allowed (backward compat)") +} + +// TestCheckEnvAccess_MismatchedUser_RealCognito verifies that a user tagged with +// a different environment_id is denied access. +func (s *EnvFilterIntegrationTestSuite) TestCheckEnvAccess_MismatchedUser_RealCognito() { + cognitoauth.ResetEnvCheckCache() + + user := s.createTestUser("envaccess-mismatch", "testenv_b") + c := makeEchoContextWithSub(user.SubjectID) + cfg := s.makeEnvCheckConfig("testenv_a") + + err := cognitoauth.CheckUserEnvironmentAccess(c, cfg, s.cognitoClient) + s.Error(err, "User with mismatched environment should be denied") +} + +// TestCheckEnvAccess_NonexistentUser_RealCognito verifies that a nonexistent user +// sub is denied access (Cognito returns UserNotFoundException). +func (s *EnvFilterIntegrationTestSuite) TestCheckEnvAccess_NonexistentUser_RealCognito() { + cognitoauth.ResetEnvCheckCache() + + c := makeEchoContextWithSub("nonexistent-sub-" + fmt.Sprintf("%d", time.Now().UnixNano())) + cfg := s.makeEnvCheckConfig("testenv_a") + + err := cognitoauth.CheckUserEnvironmentAccess(c, cfg, s.cognitoClient) + s.Error(err, "Nonexistent user should be denied") + s.Contains(err.Error(), "failed to verify environment access") +} + +// TestEULA_FetchUsersFilteredByEnv_RealCognito exercises the same pipeline that +// fetchAllEnabledCognitoUsers in the EULA service uses: ListCognitoUsers from +// real Cognito followed by FilterUsersByEnvironmentID. It verifies that only users +// matching the target environment (or untagged users) are returned. +func (s *EnvFilterIntegrationTestSuite) TestEULA_FetchUsersFilteredByEnv_RealCognito() { + ctx := context.Background() + + envAUser := s.createTestUser("eula-enva", "testenv_a") + envBUser := s.createTestUser("eula-envb", "testenv_b") + untaggedUser := s.createTestUser("eula-untagged", "") + + // List all users from real Cognito + result, err := usermanagement.ListCognitoUsers(ctx, s.cognitoClient, s.cognitoConfig.UserPoolID, 60, nil) + s.Require().NoError(err) + s.Require().NotNil(result) + + // Apply environment filter for "testenv_a" (same as EULA service would) + filtered := usermanagement.FilterUsersByEnvironmentID(result.Users, "testenv_a") + + foundEnvA := false + foundEnvB := false + foundUntagged := false + for _, u := range filtered { + if u.SubjectID == envAUser.SubjectID { + foundEnvA = true + } + if u.SubjectID == envBUser.SubjectID { + foundEnvB = true + } + if u.SubjectID == untaggedUser.SubjectID { + foundUntagged = true + } + } + + s.True(foundEnvA, "User with matching env should be included in EULA user list") + s.True(foundUntagged, "Untagged user should be included in EULA user list") + s.False(foundEnvB, "User with different env should be excluded from EULA user list") +} + +// TestEnvFilterSuite runs the environment filter integration test suite. +func TestEnvFilterSuite(t *testing.T) { + suite.Run(t, new(EnvFilterIntegrationTestSuite)) +} diff --git a/test/integration/admin_integration_test.go b/test/integration/admin_integration_test.go index 71b93d71..0d7ded9c 100644 --- a/test/integration/admin_integration_test.go +++ b/test/integration/admin_integration_test.go @@ -116,8 +116,6 @@ func (s *AdminIntegrationTestSuite) SetupSuite() { s.permitConfig = &usermanagement.PermitConfig{ APIKey: os.Getenv("PERMIT_IO_API_KEY"), - ProjectID: os.Getenv("PERMIT_IO_PROJECT_ID"), - EnvID: os.Getenv("PERMIT_IO_ENV_ID"), BaseURL: baseURL, DefaultTenant: defaultTenant, } @@ -128,8 +126,7 @@ func (s *AdminIntegrationTestSuite) SetupSuite() { s.T().Logf("Integration test suite initialized") s.T().Logf("AWS Region: %s", s.cognitoConfig.Region) s.T().Logf("Cognito User Pool: %s", s.cognitoConfig.UserPoolID) - s.T().Logf("Permit.io Project: %s", s.permitConfig.ProjectID) - s.T().Logf("Permit.io Environment: %s", s.permitConfig.EnvID) + s.T().Logf("Permit.io Base URL: %s", s.permitConfig.BaseURL) } // TearDownSuite runs once after all tests to cleanup test resources. @@ -779,6 +776,82 @@ func (s *AdminIntegrationTestSuite) TestPermitIO_IdempotentOperations() { s.T().Logf("✅ Verified role appears exactly once: %v", roles) } +// TestPermitIO_CreateUserWithEnvironmentID verifies that when a Cognito user is created +// with an EnvironmentID, the corresponding Permit.io user has "environment_id" stored in +// its attributes map. This closes the gap where the Cognito side was tested but the +// Permit.io attribute propagation was not. +func (s *AdminIntegrationTestSuite) TestPermitIO_CreateUserWithEnvironmentID() { + ctx := context.Background() + email := s.generateTestEmail("permitio-envid") + s.testUsers = append(s.testUsers, email) + + const testEnvID = "integrationtest" + + // Step 1: Create user in Cognito with EnvironmentID + attrs := usermanagement.UserAttributes{ + Email: email, + FirstName: "PermitEnv", + LastName: "Test", + EnvironmentID: testEnvID, + } + cognitoUser, err := usermanagement.CreateCognitoUser(ctx, s.cognitoClient, s.cognitoConfig, attrs) + s.Require().NoError(err, "Failed to create Cognito user with EnvironmentID") + s.Equal(testEnvID, cognitoUser.EnvironmentID, + "Cognito user should have EnvironmentID set") + s.T().Logf("Created Cognito user %s (sub=%s, env=%s)", email, cognitoUser.SubjectID, cognitoUser.EnvironmentID) + + // Step 2: Create user in Permit.io (should propagate environment_id to attributes) + err = usermanagement.CreatePermitUser(s.httpClient, s.permitConfig, cognitoUser) + s.Require().NoError(err, "Failed to create Permit.io user") + s.T().Log("Created user in Permit.io") + + // Step 3: Fetch the Permit.io user and verify environment_id is in attributes + permitUser, err := usermanagement.GetPermitUser(s.httpClient, s.permitConfig, cognitoUser.SubjectID) + s.Require().NoError(err, "Failed to get Permit.io user") + s.Require().NotNil(permitUser, "Permit.io user should not be nil") + s.Require().NotNil(permitUser.Attributes, "Permit.io user attributes should not be nil") + + envIDAttr, ok := permitUser.Attributes["environment_id"] + s.Require().True(ok, "Permit.io user attributes should contain 'environment_id'") + s.Equal(testEnvID, envIDAttr, + "Permit.io environment_id attribute should match the Cognito environment ID") + s.T().Logf("Verified Permit.io user has environment_id=%v", envIDAttr) +} + +// TestPermitIO_CreateUserWithoutEnvironmentID verifies that when a Cognito user is created +// without an EnvironmentID (backward-compatible case), the Permit.io user does NOT have +// environment_id in its attributes. +func (s *AdminIntegrationTestSuite) TestPermitIO_CreateUserWithoutEnvironmentID() { + ctx := context.Background() + email := s.generateTestEmail("permitio-noenvid") + s.testUsers = append(s.testUsers, email) + + // Create user in Cognito without EnvironmentID + attrs := usermanagement.UserAttributes{ + Email: email, + FirstName: "NoEnv", + LastName: "Test", + } + cognitoUser, err := usermanagement.CreateCognitoUser(ctx, s.cognitoClient, s.cognitoConfig, attrs) + s.Require().NoError(err, "Failed to create Cognito user") + s.Empty(cognitoUser.EnvironmentID, "Cognito user should have empty EnvironmentID") + + // Create in Permit.io + err = usermanagement.CreatePermitUser(s.httpClient, s.permitConfig, cognitoUser) + s.Require().NoError(err, "Failed to create Permit.io user") + + // Fetch and verify no environment_id attribute + permitUser, err := usermanagement.GetPermitUser(s.httpClient, s.permitConfig, cognitoUser.SubjectID) + s.Require().NoError(err, "Failed to get Permit.io user") + s.Require().NotNil(permitUser) + + if permitUser.Attributes != nil { + _, hasEnvID := permitUser.Attributes["environment_id"] + s.False(hasEnvID, "Permit.io user should NOT have environment_id when Cognito user has no env ID") + } + s.T().Log("Verified Permit.io user has no environment_id attribute (backward compat)") +} + // TestSuite runs the integration test suite. func TestSuite(t *testing.T) { suite.Run(t, new(AdminIntegrationTestSuite))