Files
Jay Brown 451da3d26d Merged in feature/service-accounts-1 (pull request #227)
Support for service accounts with static credentials

* feature complete

* tests and docs

* lint fix
2026-05-21 19:40:04 +00:00

498 lines
17 KiB
Go

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")
}
// TestCheckUserEnvironmentAccess_ServiceAuthSource_UsesServicePool verifies
// that when the request was tagged with auth_source="service", the
// env check looks the sub up in pool B (the service-accounts pool)
// rather than pool A. We assert this by inspecting the AdminGetUser
// request body, which carries the pool id.
func TestCheckUserEnvironmentAccess_ServiceAuthSource_UsesServicePool(t *testing.T) {
ResetEnvCheckCache()
config := &envCheckMockConfig{
mockConfigProvider: mockConfigProvider{
region: "us-east-1",
userPoolID: "us-east-1_PoolA",
svcUserPoolID: "us-east-1_PoolB",
},
envID: "acmehealth",
}
var seenPoolID string
cognitoClient := newTestCognitoClientForEnvCheck(t, func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
var parsed map[string]string
_ = json.Unmarshal(body, &parsed)
seenPoolID = parsed["UserPoolId"]
resp := map[string]interface{}{
"Username": "svc-1",
"Enabled": true,
"UserStatus": "CONFIRMED",
"UserAttributes": []map[string]string{
{"Name": "sub", "Value": "sub-svc"},
{"Name": "email", "Value": "svc-1@svcacct.local"},
{"Name": usermanagement.CognitoEnvIDAttrName, "Value": "acmehealth"},
},
}
return jsonResp(200, resp), nil
})
c := createEnvCheckEchoContext("sub-svc")
c.Set("auth_source", AuthSourceService)
err := CheckUserEnvironmentAccess(c, config, cognitoClient)
assert.NoError(t, err, "matching-env service token should pass")
assert.Equal(t, "us-east-1_PoolB", seenPoolID,
"service auth_source must look up against pool B, not pool A")
}
// TestCheckUserEnvironmentAccess_ServiceAuthSource_PoolBUnconfigured
// verifies that a service-tagged request fails closed (403) when
// COGNITO_SVC_USER_POOL_ID is empty — we never silently fall back to
// looking up the sub in pool A, because the sub does not exist there.
func TestCheckUserEnvironmentAccess_ServiceAuthSource_PoolBUnconfigured(t *testing.T) {
ResetEnvCheckCache()
config := &envCheckMockConfig{
mockConfigProvider: mockConfigProvider{
region: "us-east-1",
userPoolID: "us-east-1_PoolA",
// svcUserPoolID intentionally empty
},
envID: "acmehealth",
}
cognitoCalled := false
cognitoClient := newTestCognitoClientForEnvCheck(t, func(req *http.Request) (*http.Response, error) {
cognitoCalled = true
return jsonResp(200, map[string]interface{}{}), nil
})
c := createEnvCheckEchoContext("sub-svc")
c.Set("auth_source", AuthSourceService)
err := CheckUserEnvironmentAccess(c, config, cognitoClient)
assert.Error(t, err, "service token without pool B configured must be rejected")
assert.False(t, cognitoCalled, "must not call Cognito when pool B is unset")
}