Merged in feature/textExtractionsPart4 (pull request #197)

implement GET /identity

* GET /identity
This commit is contained in:
Jay Brown
2025-12-16 04:26:06 +00:00
parent ab7072ac90
commit dae7bd4cc6
19 changed files with 1811 additions and 315 deletions
+20
View File
@@ -58,6 +58,21 @@ func extractToken(c echo.Context, config auth.ConfigProvider) (string, error) {
return tokenCookie.Value, nil
}
// isAuthOnlyPath returns true for paths that require authentication but NOT authorization.
// These endpoints are accessible to all authenticated users regardless of their Permit.io roles.
// This enables users to discover their own roles/permissions before making other API calls.
func isAuthOnlyPath(requestPath string) bool {
authOnlyPaths := []string{
"/identity", // Users need to discover their roles without having any specific role
}
for _, path := range authOnlyPaths {
if requestPath == path {
return true
}
}
return false
}
// performPermitIOAuthorization handles the Permit.io authorization check
func performPermitIOAuthorization(c echo.Context, requestPath string, config auth.ConfigProvider) error {
// Skip authorization for swagger and metrics
@@ -65,6 +80,11 @@ func performPermitIOAuthorization(c echo.Context, requestPath string, config aut
return nil
}
// Skip authorization for auth-only paths (authentication required, but no specific permissions needed)
if isAuthOnlyPath(requestPath) {
return nil
}
// Get user subject from JWT for Permit.io
userSubject, ok := GetUserSubject(c)
if !ok {
+51
View File
@@ -312,3 +312,54 @@ func TestTokenValidationMiddleware(t *testing.T) {
})
}
}
// 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.
func TestIsAuthOnlyPath(t *testing.T) {
tests := []struct {
name string
path string
expected bool
}{
{
name: "identity endpoint should bypass authorization",
path: "/identity",
expected: true,
},
{
name: "api endpoint should require authorization",
path: "/api/clients",
expected: false,
},
{
name: "documents endpoint should require authorization",
path: "/documents",
expected: false,
},
{
name: "health endpoint is not auth-only (it's public)",
path: "/health",
expected: false,
},
{
name: "similar but different path should require authorization",
path: "/identity/something",
expected: false,
},
{
name: "prefix match should not work",
path: "/identityinfo",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isAuthOnlyPath(tt.path)
if result != tt.expected {
t.Errorf("isAuthOnlyPath(%q) = %v, expected %v", tt.path, result, tt.expected)
}
})
}
}
+8 -31
View File
@@ -3,22 +3,18 @@ package cognitoauth
import (
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"time"
)
// ValidatePermitIOConfiguration validates both PDP connectivity and API key at startup
// ValidatePermitIOConfiguration validates PDP connectivity and API key at startup
// This function should be called during application startup to ensure configuration is correct
// before processing any real authorization requests.
//
// It performs two checks:
// 1. PDP Health Check: Tests connectivity to the PDP URL using the /healthy endpoint
// 2. API Key Validation: Makes a dummy permission check to verify the API key is valid
// It makes a dummy permission check to verify the API key is valid and PDP is reachable.
//
// If DISABLE_AUTH=true, validation is skipped and function returns success.
// Returns an error if either check fails, which should cause the application to halt startup.
// Returns an error if the check fails, which should cause the application to halt startup.
func ValidatePermitIOConfiguration() error {
// Skip validation if auth is disabled
disableAuth := os.Getenv("DISABLE_AUTH")
@@ -27,10 +23,10 @@ func ValidatePermitIOConfiguration() error {
return nil
}
pdpURL := os.Getenv("PERMIT_IO_PDP_URL")
apiKey := os.Getenv("PERMIT_IO_API_KEY")
pdpURL := strings.TrimSpace(os.Getenv("PERMIT_IO_PDP_URL"))
apiKey := strings.TrimSpace(os.Getenv("PERMIT_IO_API_KEY"))
// Apply default for PDP URL if not set
// Apply default for PDP URL if not set or whitespace-only
if pdpURL == "" {
pdpURL = "http://localhost:7766"
}
@@ -39,26 +35,7 @@ func ValidatePermitIOConfiguration() error {
return fmt.Errorf("PERMIT_IO_API_KEY environment variable must be set")
}
// 1. Test PDP connectivity with /healthy endpoint
client := &http.Client{Timeout: 10 * time.Second}
healthURL := strings.TrimSuffix(pdpURL, "/") + "/healthy"
req, err := http.NewRequest("GET", healthURL, nil)
if err != nil {
return fmt.Errorf("failed to create health check request: %v", err)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("Permit.io PDP connectivity failed with PDP_URL=%s: %v", pdpURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("Permit.io PDP health check failed with PDP_URL=%s: HTTP %d", pdpURL, resp.StatusCode)
}
// 2. Test API key validity with a dummy permission check
// Test API key validity and PDP connectivity with a dummy permission check
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
permitClient := NewPermitIOClient(apiKey, pdpURL, logger)
@@ -67,7 +44,7 @@ func ValidatePermitIOConfiguration() error {
testAction := "read"
testResource := "startup-validation-resource"
_, err = permitClient.CheckPermission(testUser, testAction, testResource)
_, err := permitClient.CheckPermission(testUser, testAction, testResource)
if err != nil {
// Check for specific error patterns to distinguish error types
errStr := err.Error()
+38 -30
View File
@@ -75,7 +75,7 @@ func TestValidatePermitIOConfiguration_Unit(t *testing.T) {
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
// Should fail on connectivity, not API key validation
assert.Contains(t, err.Error(), "PDP connectivity failed")
assert.Contains(t, err.Error(), "Permit.io PDP connectivity failed")
})
t.Run("AuthEnabledWithDefaultURL", func(t *testing.T) {
@@ -91,7 +91,7 @@ func TestValidatePermitIOConfiguration_Unit(t *testing.T) {
})
t.Run("HealthCheckConnectivityError", func(t *testing.T) {
// Test health check connectivity failure
// Test connectivity failure (health check was removed, now uses CheckPermission)
t.Setenv("DISABLE_AUTH", "false")
t.Setenv("PERMIT_IO_API_KEY", "test-api-key-12345")
// Use an invalid host that will fail to connect
@@ -100,25 +100,25 @@ func TestValidatePermitIOConfiguration_Unit(t *testing.T) {
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
// Should fail on connectivity
assert.Contains(t, err.Error(), "PDP connectivity failed")
assert.Contains(t, err.Error(), "Permit.io PDP connectivity failed")
})
t.Run("HealthCheckNon200Response", func(t *testing.T) {
// Test health check with non-200 response (hard to simulate without server)
// This tests the path where connectivity works but PDP returns non-200
// Test with external service that returns non-200 response
// Health check was removed, now uses CheckPermission which will fail on invalid responses
t.Setenv("DISABLE_AUTH", "false")
t.Setenv("PERMIT_IO_API_KEY", "test-api-key-12345")
// Use localhost with a port that might return different status
// Use httpbin which will return 500 but won't speak Permit protocol
t.Setenv("PERMIT_IO_PDP_URL", "http://httpbin.org/status/500")
err := ValidatePermitIOConfiguration()
// This will likely fail on connectivity, but if it connects, should test non-200 path
// Should fail with some error (connectivity or validation)
if err != nil {
// Accept either connectivity failure or health check failure
// Accept various failure modes since external service may behave differently
assert.True(t,
strings.Contains(err.Error(), "PDP connectivity failed") ||
strings.Contains(err.Error(), "health check failed"),
"Should fail on either connectivity or health check")
strings.Contains(err.Error(), "Permit.io") ||
strings.Contains(err.Error(), "validation failed"),
"Should fail on either connectivity or validation, got: %v", err)
}
})
@@ -155,7 +155,7 @@ func TestValidatePermitIOConfiguration_Unit(t *testing.T) {
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
// Should fail on connectivity
assert.Contains(t, err.Error(), "PDP connectivity failed")
assert.Contains(t, err.Error(), "Permit.io PDP connectivity failed")
})
}
})
@@ -178,20 +178,21 @@ func TestValidatePermitIOConfiguration_Unit(t *testing.T) {
})
t.Run("TestHTTPStatusPath", func(t *testing.T) {
// Test that health check properly handles HTTP status codes
// Test validation with external service returning non-200 status
// Health check was removed, now uses CheckPermission for validation
t.Setenv("DISABLE_AUTH", "false")
t.Setenv("PERMIT_IO_API_KEY", "test-api-key-12345")
// Use a URL that will return a specific HTTP status for health checks
// Use a URL that will return 404 but won't speak Permit protocol
t.Setenv("PERMIT_IO_PDP_URL", "https://httpbin.org/status/404")
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
// Should fail, but external service may be unreliable
// Accept either health check failure or connectivity failure
// Should fail with some validation error
// Accept various failure modes since external service may behave differently
assert.True(t,
strings.Contains(err.Error(), "health check failed") ||
strings.Contains(err.Error(), "PDP connectivity failed"),
"Should fail on either health check or connectivity, got: %v", err)
strings.Contains(err.Error(), "Permit.io") ||
strings.Contains(err.Error(), "validation failed"),
"Should fail on validation, got: %v", err)
})
t.Run("EmptyAPIKeyAfterDefault", func(t *testing.T) {
@@ -283,7 +284,7 @@ func TestValidatePermitIOConfiguration_Unit(t *testing.T) {
name: "UnexpectedErrorPattern",
apiKey: "test-unexpected-error-key",
pdpURL: "http://invalid-host-that-will-refuse:9999",
expectError: "PDP connectivity failed",
expectError: "Permit.io PDP connectivity failed",
},
{
name: "UnauthorizedPattern",
@@ -310,8 +311,9 @@ func TestValidatePermitIOConfiguration_Unit(t *testing.T) {
t.Run("SpecificErrorBranchCoverage", func(t *testing.T) {
// Test specific error patterns that should trigger different error handling branches
// These tests focus on exercising the error pattern matching logic
// Note: Health check was removed, validation now uses CheckPermission directly
// Test malformed URL that causes http.NewRequest to fail
// Test malformed URL that causes request creation to fail
t.Run("MalformedHealthURL", func(t *testing.T) {
t.Setenv("DISABLE_AUTH", "false")
t.Setenv("PERMIT_IO_API_KEY", "test-key-12345")
@@ -319,11 +321,11 @@ func TestValidatePermitIOConfiguration_Unit(t *testing.T) {
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
// Should fail on request creation
assert.Contains(t, err.Error(), "failed to create health check request")
// Should fail on request creation - permit client will fail to parse URL
assert.Contains(t, err.Error(), "Permit.io validation failed")
})
// Test URL with invalid scheme that fails before health check
// Test URL with invalid scheme that fails before validation
t.Run("InvalidScheme", func(t *testing.T) {
t.Setenv("DISABLE_AUTH", "false")
t.Setenv("PERMIT_IO_API_KEY", "test-key-12345")
@@ -344,8 +346,12 @@ func TestValidatePermitIOConfiguration_Unit(t *testing.T) {
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
// Should fail on connectivity
assert.Contains(t, err.Error(), "PDP connectivity failed")
// Should fail - may be connectivity or timeout error
// The error message format changed after removing health check
assert.True(t,
strings.Contains(err.Error(), "Permit.io") ||
strings.Contains(err.Error(), "timeout"),
"Should fail on connectivity or timeout, got: %v", err)
})
})
@@ -589,14 +595,16 @@ func TestValidatePermitIOConfiguration_Unit(t *testing.T) {
})
t.Run("WhitespaceHandling", func(t *testing.T) {
// Test that whitespace-only values are properly trimmed
// This prevents a panic in the permit-golang library
t.Setenv("DISABLE_AUTH", "false")
t.Setenv("PERMIT_IO_API_KEY", " ")
t.Setenv("PERMIT_IO_PDP_URL", " ")
t.Setenv("PERMIT_IO_API_KEY", " ") // Whitespace only - should be trimmed to empty
t.Setenv("PERMIT_IO_PDP_URL", " ") // Whitespace only - should use default after trim
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
// Should fail on connectivity since whitespace URL is invalid
assert.NotEmpty(t, err.Error())
// After trimming, API key becomes empty so should fail with missing API key error
assert.Contains(t, err.Error(), "PERMIT_IO_API_KEY environment variable must be set")
})
t.Run("LocalhostDefaultBehavior", func(t *testing.T) {
+190 -7
View File
@@ -23,7 +23,16 @@ import (
// Returns:
// - error: Error if the user creation fails. Returns nil if user is created or already exists.
func CreatePermitUser(client *http.Client, config *PermitConfig, cognitoUser *CognitoUserResponse) error {
url := fmt.Sprintf("%s/v2/facts/%s/%s/users", config.BaseURL, config.ProjectID, config.EnvID)
projectID, err := config.GetProjectID(client)
if err != nil {
return fmt.Errorf("failed to get project ID: %w", err)
}
envID, err := config.GetEnvID(client)
if err != nil {
return fmt.Errorf("failed to get environment ID: %w", err)
}
url := fmt.Sprintf("%s/v2/facts/%s/%s/users", config.BaseURL, projectID, envID)
userObj := PermitUser{
Key: cognitoUser.SubjectID, // Use Cognito Subject ID as the key
@@ -95,7 +104,16 @@ func CreatePermitUser(client *http.Client, config *PermitConfig, cognitoUser *Co
// Returns:
// - error: Error if the deletion fails
func DeletePermitUser(client *http.Client, config *PermitConfig, userKey string) error {
url := fmt.Sprintf("%s/v2/facts/%s/%s/users/%s", config.BaseURL, config.ProjectID, config.EnvID, userKey)
projectID, err := config.GetProjectID(client)
if err != nil {
return fmt.Errorf("failed to get project ID: %w", err)
}
envID, err := config.GetEnvID(client)
if err != nil {
return 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("DELETE", url, nil)
if err != nil {
@@ -139,8 +157,17 @@ func DeletePermitUser(client *http.Client, config *PermitConfig, userKey string)
// - string: The Permit.io user key if found, empty string if not found
// - error: Error if the search fails
func FindPermitUserByEmail(client *http.Client, config *PermitConfig, email string) (string, error) {
projectID, err := config.GetProjectID(client)
if err != nil {
return "", fmt.Errorf("failed to get project ID: %w", err)
}
envID, err := config.GetEnvID(client)
if err != nil {
return "", fmt.Errorf("failed to get environment ID: %w", err)
}
// Search for user by email
url := fmt.Sprintf("%s/v2/facts/%s/%s/users?search=%s", config.BaseURL, config.ProjectID, config.EnvID, email)
url := fmt.Sprintf("%s/v2/facts/%s/%s/users?search=%s", config.BaseURL, projectID, envID, email)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
@@ -233,7 +260,16 @@ func UserExistsInPermit(client *http.Client, config *PermitConfig, email string)
// Returns:
// - error: Error if the role assignment fails. Returns nil if role is assigned or already assigned.
func AssignRole(client *http.Client, config *PermitConfig, userID, role string) error {
url := fmt.Sprintf("%s/v2/facts/%s/%s/role_assignments", config.BaseURL, config.ProjectID, config.EnvID)
projectID, err := config.GetProjectID(client)
if err != nil {
return fmt.Errorf("failed to get project ID: %w", err)
}
envID, err := config.GetEnvID(client)
if err != nil {
return fmt.Errorf("failed to get environment ID: %w", err)
}
url := fmt.Sprintf("%s/v2/facts/%s/%s/role_assignments", config.BaseURL, projectID, envID)
tenant := config.DefaultTenant
if tenant == "" {
@@ -298,7 +334,16 @@ func AssignRole(client *http.Client, config *PermitConfig, userID, role string)
// Returns:
// - error: Error if the role unassignment fails. Returns nil if role is unassigned or was already not assigned.
func UnassignRole(client *http.Client, config *PermitConfig, userID, role string) error {
url := fmt.Sprintf("%s/v2/facts/%s/%s/role_assignments", config.BaseURL, config.ProjectID, config.EnvID)
projectID, err := config.GetProjectID(client)
if err != nil {
return fmt.Errorf("failed to get project ID: %w", err)
}
envID, err := config.GetEnvID(client)
if err != nil {
return fmt.Errorf("failed to get environment ID: %w", err)
}
url := fmt.Sprintf("%s/v2/facts/%s/%s/role_assignments", config.BaseURL, projectID, envID)
tenant := config.DefaultTenant
if tenant == "" {
@@ -363,8 +408,17 @@ func UnassignRole(client *http.Client, config *PermitConfig, userID, role string
// - []string: List of role keys assigned to the user
// - error: Error if the retrieval fails
func GetPermitUserRoles(client *http.Client, config *PermitConfig, userKey string) ([]string, 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/role_assignments?user=%s",
config.BaseURL, config.ProjectID, config.EnvID, userKey)
config.BaseURL, projectID, envID, userKey)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
@@ -436,8 +490,17 @@ func GetPermitUserRoles(client *http.Client, config *PermitConfig, userKey strin
// - []string: List of role keys that exist in the Permit.io environment
// - error: Error if the retrieval fails
func GetExistingRoles(client *http.Client, config *PermitConfig) ([]string, 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)
}
// Construct API URL for roles endpoint
url := fmt.Sprintf("%s/v2/schema/%s/%s/roles", config.BaseURL, config.ProjectID, config.EnvID)
url := fmt.Sprintf("%s/v2/schema/%s/%s/roles", config.BaseURL, projectID, envID)
// Create HTTP request
req, err := http.NewRequest("GET", url, nil)
@@ -569,3 +632,123 @@ func ValidateRoles(client *http.Client, config *PermitConfig, requestedRoles []s
return []string{}, nil
}
// RoleDetails contains the details of a role including its permissions.
type RoleDetails struct {
Key string // The role key/identifier
Name string // Human-readable role name
Description string // Optional description of the role
Permissions []string // List of permissions granted by this role
}
// GetAPIKeyScope retrieves the scope information for a Permit.io API key.
// This allows deriving the project and environment IDs from the API key itself,
// eliminating the need for separate PERMIT_IO_PROJECT_ID and PERMIT_IO_ENV_ID
// environment variables.
//
// Parameters:
// - client: HTTP client for making API requests
// - apiKey: The Permit.io API key
// - baseURL: The Permit.io API base URL (e.g., https://api.permit.io)
//
// Returns:
// - *APIKeyScope: The scope containing organization, project, and environment IDs
// - error: Error if the API call fails or response cannot be parsed
func GetAPIKeyScope(client *http.Client, apiKey, baseURL string) (*APIKeyScope, error) {
url := fmt.Sprintf("%s/v2/api-key/scope", baseURL)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+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 scope APIKeyScope
if err := json.Unmarshal(body, &scope); err != nil {
return nil, fmt.Errorf("error parsing response: %w", err)
}
return &scope, nil
}
// GetRoleDetails retrieves the details of a role from Permit.io, including its permissions.
// Uses the schema API endpoint to get role definitions.
//
// Parameters:
// - client: HTTP client for making API requests
// - config: Permit.io configuration containing API credentials and project details
// - roleKey: The role key to retrieve details for
//
// Returns:
// - *RoleDetails: The role details including permissions
// - error: Error if the retrieval fails
func GetRoleDetails(client *http.Client, config *PermitConfig, roleKey string) (*RoleDetails, 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/schema/%s/%s/roles/%s",
config.BaseURL, projectID, envID, roleKey)
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)
req.Header.Set("Content-Type", "application/json")
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 roleData struct {
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
}
if err := json.Unmarshal(body, &roleData); err != nil {
return nil, fmt.Errorf("error parsing response: %w", err)
}
return &RoleDetails{
Key: roleData.Key,
Name: roleData.Name,
Description: roleData.Description,
Permissions: roleData.Permissions,
}, nil
}
+327
View File
@@ -0,0 +1,327 @@
package usermanagement
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// createMockPermitServer creates a mock server with scope endpoint and optional additional handlers.
// It returns the server and a PermitConfig configured to use it.
func createMockPermitServer(t *testing.T, additionalHandlers map[string]http.HandlerFunc) (*httptest.Server, *PermitConfig) {
mux := http.NewServeMux()
// Always add the scope handler
mux.HandleFunc("/v2/api-key/scope", func(w http.ResponseWriter, r *http.Request) {
response := 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)
err := json.NewEncoder(w).Encode(response)
require.NoError(t, err)
})
// Add any additional handlers
for path, handler := range additionalHandlers {
mux.HandleFunc(path, handler)
}
server := httptest.NewServer(mux)
config := &PermitConfig{
APIKey: "test-api-key",
BaseURL: server.URL,
}
return server, config
}
// TestGetAPIKeyScope_Success tests successful API key scope retrieval
func TestGetAPIKeyScope_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "GET", r.Method)
assert.Equal(t, "/v2/api-key/scope", r.URL.Path)
assert.Equal(t, "Bearer test-api-key", r.Header.Get("Authorization"))
response := map[string]string{
"organization_id": "org-123",
"project_id": "proj-456",
"environment_id": "env-789",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(response)
require.NoError(t, err)
}))
defer server.Close()
scope, err := GetAPIKeyScope(server.Client(), "test-api-key", server.URL)
require.NoError(t, err)
assert.NotNil(t, scope)
assert.Equal(t, "org-123", scope.OrganizationID)
assert.Equal(t, "proj-456", scope.ProjectID)
assert.Equal(t, "env-789", scope.EnvironmentID)
}
// TestGetAPIKeyScope_OrgLevelKey tests API key with only organization scope
func TestGetAPIKeyScope_OrgLevelKey(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response := map[string]string{
"organization_id": "org-123",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(response)
require.NoError(t, err)
}))
defer server.Close()
scope, err := GetAPIKeyScope(server.Client(), "test-api-key", server.URL)
require.NoError(t, err)
assert.NotNil(t, scope)
assert.Equal(t, "org-123", scope.OrganizationID)
assert.Empty(t, scope.ProjectID)
assert.Empty(t, scope.EnvironmentID)
}
// TestGetAPIKeyScope_Unauthorized tests unauthorized API key
func TestGetAPIKeyScope_Unauthorized(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, err := w.Write([]byte(`{"error": "Invalid API key"}`))
require.NoError(t, err)
}))
defer server.Close()
scope, err := GetAPIKeyScope(server.Client(), "invalid-key", server.URL)
assert.Error(t, err)
assert.Nil(t, scope)
assert.Contains(t, err.Error(), "API error (status 401)")
}
// TestPermitConfig_GetProjectID tests the GetProjectID method
func TestPermitConfig_GetProjectID(t *testing.T) {
server, config := createMockPermitServer(t, nil)
defer server.Close()
projectID, err := config.GetProjectID(server.Client())
require.NoError(t, err)
assert.Equal(t, "test-project", projectID)
}
// TestPermitConfig_GetEnvID tests the GetEnvID method
func TestPermitConfig_GetEnvID(t *testing.T) {
server, config := createMockPermitServer(t, nil)
defer server.Close()
envID, err := config.GetEnvID(server.Client())
require.NoError(t, err)
assert.Equal(t, "test-env", envID)
}
// TestPermitConfig_GetProjectID_OrgLevelKey tests GetProjectID with org-level key
func TestPermitConfig_GetProjectID_OrgLevelKey(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response := map[string]string{
"organization_id": "org-123",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(response)
require.NoError(t, err)
}))
defer server.Close()
config := &PermitConfig{
APIKey: "test-api-key",
BaseURL: server.URL,
}
projectID, err := config.GetProjectID(server.Client())
assert.Error(t, err)
assert.Empty(t, projectID)
assert.ErrorIs(t, err, ErrOrgLevelKey)
}
// TestGetRoleDetails_Success tests successful role details retrieval
func TestGetRoleDetails_Success(t *testing.T) {
handlers := map[string]http.HandlerFunc{
"/v2/schema/test-project/test-env/roles/auditor": func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "GET", r.Method)
assert.Equal(t, "Bearer test-api-key", r.Header.Get("Authorization"))
response := map[string]interface{}{
"key": "auditor",
"name": "Auditor",
"description": "Read-only access to view documents",
"permissions": []string{"document:read", "document:list", "export:read"},
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(response)
require.NoError(t, err)
},
}
server, config := createMockPermitServer(t, handlers)
defer server.Close()
roleDetails, err := GetRoleDetails(server.Client(), config, "auditor")
require.NoError(t, err)
assert.NotNil(t, roleDetails)
assert.Equal(t, "auditor", roleDetails.Key)
assert.Equal(t, "Auditor", roleDetails.Name)
assert.Equal(t, "Read-only access to view documents", roleDetails.Description)
assert.Equal(t, []string{"document:read", "document:list", "export:read"}, roleDetails.Permissions)
}
// TestGetRoleDetails_EmptyPermissions tests role with no permissions
func TestGetRoleDetails_EmptyPermissions(t *testing.T) {
handlers := map[string]http.HandlerFunc{
"/v2/schema/test-project/test-env/roles/viewer": func(w http.ResponseWriter, r *http.Request) {
response := map[string]interface{}{
"key": "viewer",
"name": "Viewer",
"description": "",
"permissions": []string{},
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(response)
require.NoError(t, err)
},
}
server, config := createMockPermitServer(t, handlers)
defer server.Close()
roleDetails, err := GetRoleDetails(server.Client(), config, "viewer")
require.NoError(t, err)
assert.NotNil(t, roleDetails)
assert.Equal(t, "viewer", roleDetails.Key)
assert.Equal(t, "Viewer", roleDetails.Name)
assert.Empty(t, roleDetails.Description)
assert.Empty(t, roleDetails.Permissions)
}
// TestGetRoleDetails_NilPermissions tests role with nil permissions field
func TestGetRoleDetails_NilPermissions(t *testing.T) {
handlers := map[string]http.HandlerFunc{
"/v2/schema/test-project/test-env/roles/basic": func(w http.ResponseWriter, r *http.Request) {
response := map[string]interface{}{
"key": "basic",
"name": "Basic User",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(response)
require.NoError(t, err)
},
}
server, config := createMockPermitServer(t, handlers)
defer server.Close()
roleDetails, err := GetRoleDetails(server.Client(), config, "basic")
require.NoError(t, err)
assert.NotNil(t, roleDetails)
assert.Equal(t, "basic", roleDetails.Key)
assert.Equal(t, "Basic User", roleDetails.Name)
assert.Nil(t, roleDetails.Permissions)
}
// TestGetRoleDetails_NotFound tests role not found error
func TestGetRoleDetails_NotFound(t *testing.T) {
handlers := map[string]http.HandlerFunc{
"/v2/schema/test-project/test-env/roles/nonexistent": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_, err := w.Write([]byte(`{"error": "Role not found"}`))
require.NoError(t, err)
},
}
server, config := createMockPermitServer(t, handlers)
defer server.Close()
roleDetails, err := GetRoleDetails(server.Client(), config, "nonexistent")
assert.Error(t, err)
assert.Nil(t, roleDetails)
assert.Contains(t, err.Error(), "API error (status 404)")
}
// TestGetRoleDetails_Unauthorized tests unauthorized error
func TestGetRoleDetails_Unauthorized(t *testing.T) {
handlers := map[string]http.HandlerFunc{
"/v2/schema/test-project/test-env/roles/auditor": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, err := w.Write([]byte(`{"error": "Invalid API key"}`))
require.NoError(t, err)
},
}
server, config := createMockPermitServer(t, handlers)
defer server.Close()
roleDetails, err := GetRoleDetails(server.Client(), config, "auditor")
assert.Error(t, err)
assert.Nil(t, roleDetails)
assert.Contains(t, err.Error(), "API error (status 401)")
}
// TestGetRoleDetails_InvalidJSON tests invalid JSON response handling
func TestGetRoleDetails_InvalidJSON(t *testing.T) {
handlers := map[string]http.HandlerFunc{
"/v2/schema/test-project/test-env/roles/auditor": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte(`invalid json`))
require.NoError(t, err)
},
}
server, config := createMockPermitServer(t, handlers)
defer server.Close()
roleDetails, err := GetRoleDetails(server.Client(), config, "auditor")
assert.Error(t, err)
assert.Nil(t, roleDetails)
assert.Contains(t, err.Error(), "error parsing response")
}
// TestGetRoleDetails_ConnectionError tests connection error handling
func TestGetRoleDetails_ConnectionError(t *testing.T) {
config := &PermitConfig{
APIKey: "test-api-key",
BaseURL: "http://nonexistent-host:9999",
}
client := &http.Client{}
roleDetails, err := GetRoleDetails(client, config, "auditor")
assert.Error(t, err)
assert.Nil(t, roleDetails)
// Error will be from GetProjectID trying to fetch scope
assert.Contains(t, err.Error(), "failed to get project ID")
}
+82 -4
View File
@@ -3,6 +3,17 @@
// for user creation, deletion, role management, and audit logging.
package usermanagement
import (
"errors"
"net/http"
"sync"
)
// ErrOrgLevelKey is returned when an organization-level API key is used.
// Organization-level keys do not have project or environment scope and cannot be used
// for operations that require project/environment context.
var ErrOrgLevelKey = errors.New("API key must be project-level or environment-level; organization-level keys are not supported")
// CognitoUserResponse represents a user in AWS Cognito with all relevant attributes.
// This type is returned when creating or retrieving users from Cognito.
type CognitoUserResponse struct {
@@ -56,18 +67,85 @@ type UserAttributes struct {
LastName string
}
// APIKeyScope represents the scope information returned by the Permit.io API key introspection endpoint.
// It contains the organization, project, and environment IDs that the API key has access to.
// Organization-level keys will have only OrganizationID set.
// Project-level keys will have OrganizationID and ProjectID set.
// Environment-level keys will have all three fields set.
type APIKeyScope struct {
OrganizationID string `json:"organization_id"`
ProjectID string `json:"project_id,omitempty"`
EnvironmentID string `json:"environment_id,omitempty"`
}
// PermitConfig holds configuration for connecting to Permit.io.
// ProjectID and EnvID are automatically derived from the API key scope on first use.
type PermitConfig struct {
// APIKey is the Permit.io API key
APIKey string
// ProjectID is the Permit.io project identifier
ProjectID string
// EnvID is the Permit.io environment identifier
EnvID string
// BaseURL is the Permit.io API base URL (default: https://api.permit.io)
BaseURL string
// DefaultTenant is the default tenant for role assignments (default: "default")
DefaultTenant string
// Internal fields for caching the API key scope
scope *APIKeyScope
scopeOnce sync.Once
scopeErr error
}
// ensureScope fetches and caches the API key scope from Permit.io.
// This is called internally by GetProjectID and GetEnvID.
// Uses sync.Once to ensure the scope is only fetched once per config instance.
//
// Parameters:
// - client: HTTP client for making API requests
//
// Returns:
// - error: Error if the scope could not be fetched
func (c *PermitConfig) ensureScope(client *http.Client) error {
c.scopeOnce.Do(func() {
c.scope, c.scopeErr = GetAPIKeyScope(client, c.APIKey, c.BaseURL)
})
return c.scopeErr
}
// GetProjectID returns the project ID from the API key scope.
// The scope is fetched and cached on first call.
//
// Parameters:
// - client: HTTP client for making API requests
//
// Returns:
// - string: The project ID
// - error: Error if the scope could not be fetched or project ID is empty
func (c *PermitConfig) GetProjectID(client *http.Client) (string, error) {
if err := c.ensureScope(client); err != nil {
return "", err
}
if c.scope.ProjectID == "" {
return "", ErrOrgLevelKey
}
return c.scope.ProjectID, nil
}
// GetEnvID returns the environment ID from the API key scope.
// The scope is fetched and cached on first call.
//
// Parameters:
// - client: HTTP client for making API requests
//
// Returns:
// - string: The environment ID
// - error: Error if the scope could not be fetched or environment ID is empty
func (c *PermitConfig) GetEnvID(client *http.Client) (string, error) {
if err := c.ensureScope(client); err != nil {
return "", err
}
if c.scope.EnvironmentID == "" {
return "", ErrOrgLevelKey
}
return c.scope.EnvironmentID, nil
}
// CognitoConfig holds configuration for connecting to AWS Cognito.