Merged in feature/cognito-shared-environments (pull request #211)

cognito and permit shared prod environment support

* code complete

* user creattion tool

test harness
This commit is contained in:
Jay Brown
2026-02-26 12:33:35 +00:00
parent 6dccf494f8
commit c668485e6f
55 changed files with 3721 additions and 381 deletions
+8 -5
View File
@@ -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.
@@ -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 {
@@ -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 {
+184
View File
@@ -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)
}
+422
View File
@@ -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")
}
+10 -3
View File
@@ -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
+49 -1
View File
@@ -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.
+108 -2
View File
@@ -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
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.
+5 -3
View File
@@ -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
}
+5 -1
View File
@@ -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
}
+20 -3
View File
@@ -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
+80
View File
@@ -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,
+30 -1
View File
@@ -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
}
@@ -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
+51 -27
View File
@@ -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,
})
}
@@ -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")
}
}
+51
View File
@@ -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
}
+159
View File
@@ -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")
}
+59
View File
@@ -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
}
+69
View File
@@ -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
}
@@ -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")
}
+110
View File
@@ -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())
}
+67 -8
View File
@@ -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:
+73
View File
@@ -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"])
}
+6
View File
@@ -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.