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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user