From dae7bd4cc68caa46e4331795d98dcee3b8ecfe45 Mon Sep 17 00:00:00 2001 From: Jay Brown Date: Tue, 16 Dec 2025 04:26:06 +0000 Subject: [PATCH] Merged in feature/textExtractionsPart4 (pull request #197) implement GET /identity * GET /identity --- README.md | 9 +- api/queryAPI/adminHandlers.go | 21 +- api/queryAPI/api.gen.go | 482 ++++++++++-------- api/queryAPI/homehandler.go | 6 + api/queryAPI/identityHandlers.go | 89 ++++ api/queryAPI/identityHandlers_test.go | 308 +++++++++++ deployments/compose.local.yaml | 14 +- .../07-configuration-management.md | 13 + internal/cognitoauth/middleware.go | 20 + internal/cognitoauth/middleware_test.go | 51 ++ internal/cognitoauth/validation.go | 39 +- internal/cognitoauth/validation_unit_test.go | 68 +-- internal/usermanagement/permitio.go | 197 ++++++- internal/usermanagement/permitio_test.go | 327 ++++++++++++ internal/usermanagement/types.go | 86 +++- mocks/queryapi/mock_ClientInterface.go | 73 +++ .../mock_ClientWithResponsesInterface.go | 73 +++ pkg/queryAPI/api.gen.go | 163 ++++++ serviceAPIs/queryAPI.yaml | 87 ++++ 19 files changed, 1811 insertions(+), 315 deletions(-) create mode 100644 api/queryAPI/identityHandlers.go create mode 100644 api/queryAPI/identityHandlers_test.go create mode 100644 internal/usermanagement/permitio_test.go diff --git a/README.md b/README.md index 56620cdd..18f736ce 100644 --- a/README.md +++ b/README.md @@ -200,13 +200,14 @@ COGNITO_DOMAIN=your-cognito-domain # AWS Cognito domain name #### Authorization (Required for RBAC) ```bash -PERMIT_IO_API_KEY=permit_key_xxxxxxx # Permit.io API key for RBAC -PERMIT_IO_PROJECT_ID=default # Permit.io project ID (default: "default") -PERMIT_IO_ENV_ID=dev # Permit.io environment ID (e.g., dev, staging, production) +PERMIT_IO_API_KEY=permit_key_xxxxxxx # Permit.io API key for RBAC (project/env derived from key) PERMIT_IO_TENANT=default # Permit.io tenant identifier (default: "default") PERMIT_IO_PDP_URL=http://localhost:7766 # Permit.io Policy Decision Point URL - can use cloud version optionally ``` +Note: The Permit.io project and environment IDs are automatically derived from the API key +using the `/v2/api-key/scope` endpoint. Use a project-level or environment-level API key. + ### Optional Environment Variables #### Development & Testing @@ -277,8 +278,6 @@ QUERY_VERSION_SYNC_URL=http://localhost:4566/queue/us-east-1/000000000000/query_ # Development auth (optional) PERMIT_IO_API_KEY=test-key -PERMIT_IO_PROJECT_ID=default -PERMIT_IO_ENV_ID=dev PERMIT_IO_TENANT=default PERMIT_IO_PDP_URL=http://localhost:7766 ``` diff --git a/api/queryAPI/adminHandlers.go b/api/queryAPI/adminHandlers.go index 8b6d9587..16789df2 100644 --- a/api/queryAPI/adminHandlers.go +++ b/api/queryAPI/adminHandlers.go @@ -50,6 +50,8 @@ func getAdminUserForAudit(ctx echo.Context) (cognitoauth.UserInfo, error) { // getPermitConfig creates a PermitConfig by reading environment variables. // This centralizes Permit.io configuration to avoid code duplication. +// Note: ProjectID and EnvID are automatically derived from the API key scope +// when first needed, so they do not need to be set here. // // Parameters: // - ctrl: The Controllers instance containing the API key from config @@ -57,26 +59,19 @@ func getAdminUserForAudit(ctx echo.Context) (cognitoauth.UserInfo, error) { // Returns: // - *usermanagement.PermitConfig: The Permit.io configuration func (ctrl *Controllers) getPermitConfig() *usermanagement.PermitConfig { - projectID := os.Getenv("PERMIT_IO_PROJECT_ID") - if projectID == "" { - projectID = "default" - } - - envID := os.Getenv("PERMIT_IO_ENV_ID") - if envID == "" { - envID = "dev" - } - tenant := os.Getenv("PERMIT_IO_TENANT") if tenant == "" { tenant = "default" } + baseURL := os.Getenv("PERMIT_IO_BASE_URL") + if baseURL == "" { + baseURL = "https://api.permit.io" + } + return &usermanagement.PermitConfig{ APIKey: ctrl.cfg.GetPermitIOAPIKey(), - ProjectID: projectID, - EnvID: envID, - BaseURL: "https://api.permit.io", + BaseURL: baseURL, DefaultTenant: tenant, } } diff --git a/api/queryAPI/api.gen.go b/api/queryAPI/api.gen.go index 52baf979..a5c7fd17 100644 --- a/api/queryAPI/api.gen.go +++ b/api/queryAPI/api.gen.go @@ -843,6 +843,27 @@ type IdMessage struct { Id openapi_types.UUID `json:"id"` } +// IdentityResponse Response containing the user's roles and their permissions +type IdentityResponse struct { + // Roles List of roles assigned to the user with their permissions + Roles []IdentityRole `json:"roles"` +} + +// IdentityRole A role assigned to the user with its permissions +type IdentityRole struct { + // Description Optional description of the role + Description *string `json:"description,omitempty"` + + // Key The role key/identifier + Key string `json:"key"` + + // Name Human-readable role name + Name string `json:"name"` + + // Permissions List of permissions granted by this role + Permissions []string `json:"permissions"` +} + // LabelApplication Request to apply a workflow label to a document type LabelApplication struct { // AppliedBy Email of user applying the label @@ -1308,6 +1329,9 @@ type ServerInterface interface { // Get the home page menu // (GET /home) GetHomePage(ctx echo.Context) error + // Get current user identity and roles + // (GET /identity) + GetIdentity(ctx echo.Context) error // Get all documents with a specific label // (GET /labels/{labelName}/documents) GetDocumentsByLabel(ctx echo.Context, labelName string, params GetDocumentsByLabelParams) error @@ -2066,6 +2090,17 @@ func (w *ServerInterfaceWrapper) GetHomePage(ctx echo.Context) error { return err } +// GetIdentity converts echo context to params. +func (w *ServerInterfaceWrapper) GetIdentity(ctx echo.Context) error { + var err error + + ctx.Set(JwtAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetIdentity(ctx) + return err +} + // GetDocumentsByLabel converts echo context to params. func (w *ServerInterfaceWrapper) GetDocumentsByLabel(ctx echo.Context) error { var err error @@ -2280,6 +2315,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/folders/:folderId/documents", wrapper.GetFolderDocuments) router.GET(baseURL+"/folders/:folderId/metrics", wrapper.GetFolderMetrics) router.GET(baseURL+"/home", wrapper.GetHomePage) + router.GET(baseURL+"/identity", wrapper.GetIdentity) router.GET(baseURL+"/labels/:labelName/documents", wrapper.GetDocumentsByLabel) router.GET(baseURL+"/login", wrapper.Login) router.GET(baseURL+"/login-callback", wrapper.LoginCallback) @@ -2295,226 +2331,232 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "lDIqlSiPr3/PJnj/5f8EAAD//+2UaeGdXAEA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/queryAPI/homehandler.go b/api/queryAPI/homehandler.go index 0a94cecf..39c4d8dd 100644 --- a/api/queryAPI/homehandler.go +++ b/api/queryAPI/homehandler.go @@ -35,6 +35,12 @@ var endpointsList = []string{ // Export Service Endpoints "/export", + + // Identity Endpoint + "/identity", + + // Swagger UI + "/swagger/index.html", } // homeHandler renders a simple HTML page with links to all endpoints diff --git a/api/queryAPI/identityHandlers.go b/api/queryAPI/identityHandlers.go new file mode 100644 index 00000000..60b13165 --- /dev/null +++ b/api/queryAPI/identityHandlers.go @@ -0,0 +1,89 @@ +// identityHandlers.go implements the identity API handler for user role discovery. +// This handler allows authenticated users to discover their assigned roles and +// the permissions granted by those roles from Permit.io. +package queryapi + +import ( + "net/http" + "time" + + "queryorchestration/internal/cognitoauth" + "queryorchestration/internal/usermanagement" + + "github.com/labstack/echo/v4" +) + +// GetIdentity returns the currently authenticated user's roles and permissions from Permit.io. +// GET /identity +// +// This endpoint requires authentication but no authorization - any authenticated +// user can retrieve their own roles and the permissions granted by those roles. +// +// Returns: +// - 200: IdentityResponse with roles array including permissions +// - 401: Unauthorized if user is not authenticated +// - 502: Bad gateway if Permit.io call fails +func (ctrl *Controllers) GetIdentity(ctx echo.Context) error { + // Extract user subject ID from JWT token + userSubject, ok := cognitoauth.GetUserSubject(ctx) + if !ok { + return ctx.JSON(http.StatusUnauthorized, ErrorMessage{ + Message: "Could not identify user from token", + }) + } + + // Get role keys from Permit.io + httpClient := &http.Client{Timeout: 10 * time.Second} + permitConfig := ctrl.getPermitConfig() + + roleKeys, err := usermanagement.GetPermitUserRoles(httpClient, permitConfig, userSubject) + if err != nil { + ctrl.cfg.GetAuthLogger().Error("Failed to get user roles from Permit.io", + "error", err, + "user_subject", userSubject) + return ctx.JSON(http.StatusBadGateway, ErrorMessage{ + Message: "Failed to retrieve roles from authorization service", + }) + } + + // Build response with role details + roles := make([]IdentityRole, 0, len(roleKeys)) + + for _, roleKey := range roleKeys { + // Get role details including permissions + roleDetails, err := usermanagement.GetRoleDetails(httpClient, permitConfig, roleKey) + if err != nil { + ctrl.cfg.GetAuthLogger().Warn("Failed to get role details from Permit.io", + "error", err, + "role_key", roleKey) + // Include role with empty permissions if details fetch fails + roles = append(roles, IdentityRole{ + Key: roleKey, + Name: roleKey, + Permissions: []string{}, + }) + continue + } + + permissions := roleDetails.Permissions + if permissions == nil { + permissions = []string{} + } + + var description *string + if roleDetails.Description != "" { + description = &roleDetails.Description + } + + roles = append(roles, IdentityRole{ + Key: roleDetails.Key, + Name: roleDetails.Name, + Description: description, + Permissions: permissions, + }) + } + + return ctx.JSON(http.StatusOK, IdentityResponse{ + Roles: roles, + }) +} diff --git a/api/queryAPI/identityHandlers_test.go b/api/queryAPI/identityHandlers_test.go new file mode 100644 index 00000000..9e1d749c --- /dev/null +++ b/api/queryAPI/identityHandlers_test.go @@ -0,0 +1,308 @@ +package queryapi_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + queryapi "queryorchestration/api/queryAPI" + "queryorchestration/internal/test" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockScopeHandler returns an HTTP handler that mocks the Permit.io /v2/api-key/scope endpoint +func mockScopeHandler(t *testing.T) http.HandlerFunc { + return 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) + } +} + +// TestGetIdentity_Success tests successful retrieval of user roles and permissions +func TestGetIdentity_Success(t *testing.T) { + // Create mock Permit.io server + roleAssignmentsHandler := func(w http.ResponseWriter, r *http.Request) { + // Return role assignments for the user + response := []map[string]interface{}{ + {"role": "auditor", "user": "test-subject-id", "tenant": "default"}, + {"role": "viewer", "user": "test-subject-id", "tenant": "default"}, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + err := json.NewEncoder(w).Encode(response) + require.NoError(t, err) + } + + auditorRoleHandler := func(w http.ResponseWriter, r *http.Request) { + 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) + } + + viewerRoleHandler := func(w http.ResponseWriter, r *http.Request) { + response := map[string]interface{}{ + "key": "viewer", + "name": "Viewer", + "description": "Basic viewing permissions", + "permissions": []string{"document:read"}, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + err := json.NewEncoder(w).Encode(response) + require.NoError(t, err) + } + + // Create mock server with routing + mux := http.NewServeMux() + mux.HandleFunc("/v2/api-key/scope", mockScopeHandler(t)) + mux.HandleFunc("/v2/facts/test-project/test-env/role_assignments", roleAssignmentsHandler) + mux.HandleFunc("/v2/schema/test-project/test-env/roles/auditor", auditorRoleHandler) + mux.HandleFunc("/v2/schema/test-project/test-env/roles/viewer", viewerRoleHandler) + + server := httptest.NewServer(mux) + defer server.Close() + + // Set environment variable for Permit.io base URL (project/env derived from API key scope) + t.Setenv("PERMIT_IO_BASE_URL", server.URL) + + // Create controller config + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + cfg.SetPermitIOAPIKey("test-api-key") + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + // Create echo context with user claims + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/identity", nil) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + + // Set user claims in context (simulating authenticated user) + ctx.Set("user_claims", map[string]interface{}{ + "sub": "test-subject-id", + }) + + err := cons.GetIdentity(ctx) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + + var response queryapi.IdentityResponse + err = json.Unmarshal(rec.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Len(t, response.Roles, 2) + + // Find auditor role + var auditorRole *queryapi.IdentityRole + var viewerRole *queryapi.IdentityRole + for i := range response.Roles { + if response.Roles[i].Key == "auditor" { + auditorRole = &response.Roles[i] + } + if response.Roles[i].Key == "viewer" { + viewerRole = &response.Roles[i] + } + } + + require.NotNil(t, auditorRole) + assert.Equal(t, "Auditor", auditorRole.Name) + assert.NotNil(t, auditorRole.Description) + assert.Equal(t, "Read-only access to view documents", *auditorRole.Description) + assert.Equal(t, []string{"document:read", "document:list", "export:read"}, auditorRole.Permissions) + + require.NotNil(t, viewerRole) + assert.Equal(t, "Viewer", viewerRole.Name) + assert.Equal(t, []string{"document:read"}, viewerRole.Permissions) +} + +// TestGetIdentity_NoRoles tests user with no roles assigned +func TestGetIdentity_NoRoles(t *testing.T) { + // Create mock Permit.io server that returns empty roles + mux := http.NewServeMux() + mux.HandleFunc("/v2/api-key/scope", mockScopeHandler(t)) + mux.HandleFunc("/v2/facts/test-project/test-env/role_assignments", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`[]`)) + require.NoError(t, err) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + t.Setenv("PERMIT_IO_BASE_URL", server.URL) + + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + cfg.SetPermitIOAPIKey("test-api-key") + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/identity", nil) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + ctx.Set("user_claims", map[string]interface{}{ + "sub": "test-subject-id", + }) + + err := cons.GetIdentity(ctx) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + + var response queryapi.IdentityResponse + err = json.Unmarshal(rec.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Empty(t, response.Roles) +} + +// TestGetIdentity_Unauthorized tests request without authentication +func TestGetIdentity_Unauthorized(t *testing.T) { + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + cfg.SetPermitIOAPIKey("test-api-key") + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/identity", nil) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + // No user_claims set - simulating unauthenticated request + + err := cons.GetIdentity(ctx) + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + + var response queryapi.ErrorMessage + err = json.Unmarshal(rec.Body.Bytes(), &response) + require.NoError(t, err) + assert.Contains(t, response.Message, "Could not identify user") +} + +// TestGetIdentity_PermitAPIError tests handling of Permit.io API errors +func TestGetIdentity_PermitAPIError(t *testing.T) { + // Create mock server that returns error + mux := http.NewServeMux() + mux.HandleFunc("/v2/api-key/scope", mockScopeHandler(t)) + mux.HandleFunc("/v2/facts/test-project/test-env/role_assignments", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write([]byte(`{"error": "Internal server error"}`)) + require.NoError(t, err) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + t.Setenv("PERMIT_IO_BASE_URL", server.URL) + + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + cfg.SetPermitIOAPIKey("test-api-key") + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/identity", nil) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + ctx.Set("user_claims", map[string]interface{}{ + "sub": "test-subject-id", + }) + + err := cons.GetIdentity(ctx) + require.NoError(t, err) + assert.Equal(t, http.StatusBadGateway, rec.Code) + + var response queryapi.ErrorMessage + err = json.Unmarshal(rec.Body.Bytes(), &response) + require.NoError(t, err) + assert.Contains(t, response.Message, "Failed to retrieve roles") +} + +// TestGetIdentity_RoleDetailsFallback tests graceful degradation when role details cannot be fetched +func TestGetIdentity_RoleDetailsFallback(t *testing.T) { + // Create mock server where role assignments work but role details fail + mux := http.NewServeMux() + mux.HandleFunc("/v2/api-key/scope", mockScopeHandler(t)) + mux.HandleFunc("/v2/facts/test-project/test-env/role_assignments", func(w http.ResponseWriter, r *http.Request) { + response := []map[string]interface{}{ + {"role": "auditor", "user": "test-subject-id", "tenant": "default"}, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + err := json.NewEncoder(w).Encode(response) + require.NoError(t, err) + }) + mux.HandleFunc("/v2/schema/test-project/test-env/roles/auditor", func(w http.ResponseWriter, r *http.Request) { + // Role details endpoint returns error + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, err := w.Write([]byte(`{"error": "Role not found"}`)) + require.NoError(t, err) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + t.Setenv("PERMIT_IO_BASE_URL", server.URL) + + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + cfg.SetPermitIOAPIKey("test-api-key") + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/identity", nil) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + ctx.Set("user_claims", map[string]interface{}{ + "sub": "test-subject-id", + }) + + err := cons.GetIdentity(ctx) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + + var response queryapi.IdentityResponse + err = json.Unmarshal(rec.Body.Bytes(), &response) + require.NoError(t, err) + + // Should still return the role, but with fallback values + require.Len(t, response.Roles, 1) + assert.Equal(t, "auditor", response.Roles[0].Key) + assert.Equal(t, "auditor", response.Roles[0].Name) // Fallback to key + assert.Empty(t, response.Roles[0].Permissions) // Empty permissions on failure +} diff --git a/deployments/compose.local.yaml b/deployments/compose.local.yaml index ff2f51c6..92636eff 100644 --- a/deployments/compose.local.yaml +++ b/deployments/compose.local.yaml @@ -285,11 +285,15 @@ services: DB_NOSSL: ${DB_NOSSL} AWS_ENDPOINT_URL: "http://localstack:4566" AWS_S3_USE_PATH_STYLE: true - DISABLE_AUTH: true - COGNITO_USER_POOL_ID: "fillin" - COGNITO_CLIENT_SECRET: "fillin" - COGNITO_DOMAIN: "fillin" - COGNITO_CLIENT_ID: "fillin" + DISABLE_AUTH: false + COGNITO_USER_POOL_ID: ${COGNITO_USER_POOL_ID} + COGNITO_CLIENT_SECRET: ${COGNITO_CLIENT_SECRET} + COGNITO_DOMAIN: ${COGNITO_DOMAIN} + COGNITO_CLIENT_ID: ${COGNITO_CLIENT_ID} + COGNITO_REGION: ${COGNITO_REGION} + PERMIT_IO_API_KEY: ${PERMIT_IO_API_KEY} + PERMIT_IO_PDP_URL: ${PERMIT_IO_PDP_URL} + PERMIT_IO_TENANT: ${PERMIT_IO_TENANT} BUCKET: ${BUCKET_IN} networks: - server-network diff --git a/docs/ai.generated/07-configuration-management.md b/docs/ai.generated/07-configuration-management.md index d7452484..ee73b7bc 100644 --- a/docs/ai.generated/07-configuration-management.md +++ b/docs/ai.generated/07-configuration-management.md @@ -299,6 +299,19 @@ JWKS_CACHE_EXPIRY=1h # JWKS cache duration TOKEN_EXPIRY=24h # JWT token expiry ``` +### Authorization Variables (Permit.io RBAC) +```bash +# Permit.io API Configuration +PERMIT_IO_API_KEY=permit_key_xxxxxxx # Permit.io API key (project/env derived from key) +PERMIT_IO_TENANT=default # Permit.io tenant identifier (default: "default") +PERMIT_IO_PDP_URL=http://localhost:7766 # Policy Decision Point URL (optional, uses cloud if not set) +PERMIT_IO_BASE_URL=https://api.permit.io # Permit.io API base URL (optional, for testing) +``` + +**Note:** The Permit.io project ID and environment ID are automatically derived from the API key +using the `/v2/api-key/scope` endpoint. Use a project-level or environment-level API key; +organization-level API keys are not supported. + ## Configuration Validation ### Required Field Validation diff --git a/internal/cognitoauth/middleware.go b/internal/cognitoauth/middleware.go index b627386e..f072f27f 100644 --- a/internal/cognitoauth/middleware.go +++ b/internal/cognitoauth/middleware.go @@ -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 { diff --git a/internal/cognitoauth/middleware_test.go b/internal/cognitoauth/middleware_test.go index 8be88be0..0dcaa70f 100644 --- a/internal/cognitoauth/middleware_test.go +++ b/internal/cognitoauth/middleware_test.go @@ -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) + } + }) + } +} diff --git a/internal/cognitoauth/validation.go b/internal/cognitoauth/validation.go index 6cd7026b..8ed3f86f 100644 --- a/internal/cognitoauth/validation.go +++ b/internal/cognitoauth/validation.go @@ -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() diff --git a/internal/cognitoauth/validation_unit_test.go b/internal/cognitoauth/validation_unit_test.go index d1111edb..7476028a 100644 --- a/internal/cognitoauth/validation_unit_test.go +++ b/internal/cognitoauth/validation_unit_test.go @@ -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) { diff --git a/internal/usermanagement/permitio.go b/internal/usermanagement/permitio.go index de69c8c0..00e02462 100644 --- a/internal/usermanagement/permitio.go +++ b/internal/usermanagement/permitio.go @@ -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 +} diff --git a/internal/usermanagement/permitio_test.go b/internal/usermanagement/permitio_test.go new file mode 100644 index 00000000..841498e6 --- /dev/null +++ b/internal/usermanagement/permitio_test.go @@ -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") +} diff --git a/internal/usermanagement/types.go b/internal/usermanagement/types.go index 80310d9d..00286497 100644 --- a/internal/usermanagement/types.go +++ b/internal/usermanagement/types.go @@ -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. diff --git a/mocks/queryapi/mock_ClientInterface.go b/mocks/queryapi/mock_ClientInterface.go index 8e730880..8d7608cb 100644 --- a/mocks/queryapi/mock_ClientInterface.go +++ b/mocks/queryapi/mock_ClientInterface.go @@ -2335,6 +2335,79 @@ func (_c *MockClientInterface_GetHomePage_Call) RunAndReturn(run func(context.Co return _c } +// GetIdentity provides a mock function with given fields: ctx, reqEditors +func (_m *MockClientInterface) GetIdentity(ctx context.Context, reqEditors ...queryapi.RequestEditorFn) (*http.Response, error) { + _va := make([]interface{}, len(reqEditors)) + for _i := range reqEditors { + _va[_i] = reqEditors[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for GetIdentity") + } + + var r0 *http.Response + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, ...queryapi.RequestEditorFn) (*http.Response, error)); ok { + return rf(ctx, reqEditors...) + } + if rf, ok := ret.Get(0).(func(context.Context, ...queryapi.RequestEditorFn) *http.Response); ok { + r0 = rf(ctx, reqEditors...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*http.Response) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, ...queryapi.RequestEditorFn) error); ok { + r1 = rf(ctx, reqEditors...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClientInterface_GetIdentity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetIdentity' +type MockClientInterface_GetIdentity_Call struct { + *mock.Call +} + +// GetIdentity is a helper method to define mock.On call +// - ctx context.Context +// - reqEditors ...queryapi.RequestEditorFn +func (_e *MockClientInterface_Expecter) GetIdentity(ctx interface{}, reqEditors ...interface{}) *MockClientInterface_GetIdentity_Call { + return &MockClientInterface_GetIdentity_Call{Call: _e.mock.On("GetIdentity", + append([]interface{}{ctx}, reqEditors...)...)} +} + +func (_c *MockClientInterface_GetIdentity_Call) Run(run func(ctx context.Context, reqEditors ...queryapi.RequestEditorFn)) *MockClientInterface_GetIdentity_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]queryapi.RequestEditorFn, len(args)-1) + for i, a := range args[1:] { + if a != nil { + variadicArgs[i] = a.(queryapi.RequestEditorFn) + } + } + run(args[0].(context.Context), variadicArgs...) + }) + return _c +} + +func (_c *MockClientInterface_GetIdentity_Call) Return(_a0 *http.Response, _a1 error) *MockClientInterface_GetIdentity_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClientInterface_GetIdentity_Call) RunAndReturn(run func(context.Context, ...queryapi.RequestEditorFn) (*http.Response, error)) *MockClientInterface_GetIdentity_Call { + _c.Call.Return(run) + return _c +} + // GetQuery provides a mock function with given fields: ctx, id, reqEditors func (_m *MockClientInterface) GetQuery(ctx context.Context, id queryapi.QueryID, reqEditors ...queryapi.RequestEditorFn) (*http.Response, error) { _va := make([]interface{}, len(reqEditors)) diff --git a/mocks/queryapi/mock_ClientWithResponsesInterface.go b/mocks/queryapi/mock_ClientWithResponsesInterface.go index dba3f292..af80dc30 100644 --- a/mocks/queryapi/mock_ClientWithResponsesInterface.go +++ b/mocks/queryapi/mock_ClientWithResponsesInterface.go @@ -2333,6 +2333,79 @@ func (_c *MockClientWithResponsesInterface_GetHomePageWithResponse_Call) RunAndR return _c } +// GetIdentityWithResponse provides a mock function with given fields: ctx, reqEditors +func (_m *MockClientWithResponsesInterface) GetIdentityWithResponse(ctx context.Context, reqEditors ...queryapi.RequestEditorFn) (*queryapi.GetIdentityResponse, error) { + _va := make([]interface{}, len(reqEditors)) + for _i := range reqEditors { + _va[_i] = reqEditors[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for GetIdentityWithResponse") + } + + var r0 *queryapi.GetIdentityResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, ...queryapi.RequestEditorFn) (*queryapi.GetIdentityResponse, error)); ok { + return rf(ctx, reqEditors...) + } + if rf, ok := ret.Get(0).(func(context.Context, ...queryapi.RequestEditorFn) *queryapi.GetIdentityResponse); ok { + r0 = rf(ctx, reqEditors...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*queryapi.GetIdentityResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, ...queryapi.RequestEditorFn) error); ok { + r1 = rf(ctx, reqEditors...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClientWithResponsesInterface_GetIdentityWithResponse_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetIdentityWithResponse' +type MockClientWithResponsesInterface_GetIdentityWithResponse_Call struct { + *mock.Call +} + +// GetIdentityWithResponse is a helper method to define mock.On call +// - ctx context.Context +// - reqEditors ...queryapi.RequestEditorFn +func (_e *MockClientWithResponsesInterface_Expecter) GetIdentityWithResponse(ctx interface{}, reqEditors ...interface{}) *MockClientWithResponsesInterface_GetIdentityWithResponse_Call { + return &MockClientWithResponsesInterface_GetIdentityWithResponse_Call{Call: _e.mock.On("GetIdentityWithResponse", + append([]interface{}{ctx}, reqEditors...)...)} +} + +func (_c *MockClientWithResponsesInterface_GetIdentityWithResponse_Call) Run(run func(ctx context.Context, reqEditors ...queryapi.RequestEditorFn)) *MockClientWithResponsesInterface_GetIdentityWithResponse_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]queryapi.RequestEditorFn, len(args)-1) + for i, a := range args[1:] { + if a != nil { + variadicArgs[i] = a.(queryapi.RequestEditorFn) + } + } + run(args[0].(context.Context), variadicArgs...) + }) + return _c +} + +func (_c *MockClientWithResponsesInterface_GetIdentityWithResponse_Call) Return(_a0 *queryapi.GetIdentityResponse, _a1 error) *MockClientWithResponsesInterface_GetIdentityWithResponse_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClientWithResponsesInterface_GetIdentityWithResponse_Call) RunAndReturn(run func(context.Context, ...queryapi.RequestEditorFn) (*queryapi.GetIdentityResponse, error)) *MockClientWithResponsesInterface_GetIdentityWithResponse_Call { + _c.Call.Return(run) + return _c +} + // GetQueryWithResponse provides a mock function with given fields: ctx, id, reqEditors func (_m *MockClientWithResponsesInterface) GetQueryWithResponse(ctx context.Context, id queryapi.QueryID, reqEditors ...queryapi.RequestEditorFn) (*queryapi.GetQueryResponse, error) { _va := make([]interface{}, len(reqEditors)) diff --git a/pkg/queryAPI/api.gen.go b/pkg/queryAPI/api.gen.go index 8f9857ce..d9b896ac 100644 --- a/pkg/queryAPI/api.gen.go +++ b/pkg/queryAPI/api.gen.go @@ -841,6 +841,27 @@ type IdMessage struct { Id openapi_types.UUID `json:"id"` } +// IdentityResponse Response containing the user's roles and their permissions +type IdentityResponse struct { + // Roles List of roles assigned to the user with their permissions + Roles []IdentityRole `json:"roles"` +} + +// IdentityRole A role assigned to the user with its permissions +type IdentityRole struct { + // Description Optional description of the role + Description *string `json:"description,omitempty"` + + // Key The role key/identifier + Key string `json:"key"` + + // Name Human-readable role name + Name string `json:"name"` + + // Permissions List of permissions granted by this role + Permissions []string `json:"permissions"` +} + // LabelApplication Request to apply a workflow label to a document type LabelApplication struct { // AppliedBy Email of user applying the label @@ -1397,6 +1418,9 @@ type ClientInterface interface { // GetHomePage request GetHomePage(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetIdentity request + GetIdentity(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetDocumentsByLabel request GetDocumentsByLabel(ctx context.Context, labelName string, params *GetDocumentsByLabelParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -1983,6 +2007,18 @@ func (c *Client) GetHomePage(ctx context.Context, reqEditors ...RequestEditorFn) return c.Client.Do(req) } +func (c *Client) GetIdentity(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetIdentityRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetDocumentsByLabel(ctx context.Context, labelName string, params *GetDocumentsByLabelParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetDocumentsByLabelRequest(c.Server, labelName, params) if err != nil { @@ -3719,6 +3755,33 @@ func NewGetHomePageRequest(server string) (*http.Request, error) { return req, nil } +// NewGetIdentityRequest generates requests for GetIdentity +func NewGetIdentityRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/identity") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewGetDocumentsByLabelRequest generates requests for GetDocumentsByLabel func NewGetDocumentsByLabelRequest(server string, labelName string, params *GetDocumentsByLabelParams) (*http.Request, error) { var err error @@ -4248,6 +4311,9 @@ type ClientWithResponsesInterface interface { // GetHomePageWithResponse request GetHomePageWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetHomePageResponse, error) + // GetIdentityWithResponse request + GetIdentityWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetIdentityResponse, error) + // GetDocumentsByLabelWithResponse request GetDocumentsByLabelWithResponse(ctx context.Context, labelName string, params *GetDocumentsByLabelParams, reqEditors ...RequestEditorFn) (*GetDocumentsByLabelResponse, error) @@ -5248,6 +5314,33 @@ func (r GetHomePageResponse) StatusCode() int { return 0 } +type GetIdentityResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *IdentityResponse + JSON400 *InvalidRequest + JSON401 *Unauthorized + JSON429 *TooManyRequests + JSON500 *InternalError + JSON502 *ErrorMessage +} + +// Status returns HTTPResponse.Status +func (r GetIdentityResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetIdentityResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type GetDocumentsByLabelResponse struct { Body []byte HTTPResponse *http.Response @@ -5884,6 +5977,15 @@ func (c *ClientWithResponses) GetHomePageWithResponse(ctx context.Context, reqEd return ParseGetHomePageResponse(rsp) } +// GetIdentityWithResponse request returning *GetIdentityResponse +func (c *ClientWithResponses) GetIdentityWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetIdentityResponse, error) { + rsp, err := c.GetIdentity(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetIdentityResponse(rsp) +} + // GetDocumentsByLabelWithResponse request returning *GetDocumentsByLabelResponse func (c *ClientWithResponses) GetDocumentsByLabelWithResponse(ctx context.Context, labelName string, params *GetDocumentsByLabelParams, reqEditors ...RequestEditorFn) (*GetDocumentsByLabelResponse, error) { rsp, err := c.GetDocumentsByLabel(ctx, labelName, params, reqEditors...) @@ -8107,6 +8209,67 @@ func ParseGetHomePageResponse(rsp *http.Response) (*GetHomePageResponse, error) return response, nil } +// ParseGetIdentityResponse parses an HTTP response from a GetIdentityWithResponse call +func ParseGetIdentityResponse(rsp *http.Response) (*GetIdentityResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetIdentityResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest IdentityResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest InvalidRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest TooManyRequests + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: + var dest ErrorMessage + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON502 = &dest + + } + + return response, nil +} + // ParseGetDocumentsByLabelResponse parses an HTTP response from a GetDocumentsByLabelWithResponse call func ParseGetDocumentsByLabelResponse(rsp *http.Response) (*GetDocumentsByLabelResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/serviceAPIs/queryAPI.yaml b/serviceAPIs/queryAPI.yaml index 6f66be07..4f7205f4 100644 --- a/serviceAPIs/queryAPI.yaml +++ b/serviceAPIs/queryAPI.yaml @@ -1377,6 +1377,44 @@ paths: $ref: "#/components/responses/TooManyRequests" "500": $ref: "#/components/responses/InternalError" + + /identity: + get: + operationId: getIdentity + tags: + - AuthService + summary: Get current user identity and roles + description: >- + Returns the authenticated user's assigned roles from Permit.io, + including the permissions granted by each role. + Any authenticated user can call this endpoint to discover their own roles. + security: + - jwtAuth: [] + responses: + "200": + description: User roles and permissions retrieved successfully. + headers: + RateLimit: + $ref: "#/components/headers/RateLimit" + content: + application/json: + schema: + $ref: "#/components/schemas/IdentityResponse" + "400": + $ref: "#/components/responses/InvalidRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/TooManyRequests" + "500": + $ref: "#/components/responses/InternalError" + "502": + description: Failed to retrieve roles from Permit.io + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorMessage" + /home: get: operationId: getHomePage @@ -2702,6 +2740,55 @@ components: example: message: you must include a message + # Identity API schemas + IdentityRole: + type: object + description: A role assigned to the user with its permissions + required: + - key + - name + - permissions + properties: + key: + type: string + description: The role key/identifier + maxLength: 128 + pattern: "^[a-z0-9_-]+$" + example: "auditor" + name: + type: string + description: Human-readable role name + maxLength: 256 + pattern: "^.+$" + example: "Auditor" + description: + type: string + description: Optional description of the role + maxLength: 1024 + example: "Read-only access to view documents and exports" + permissions: + type: array + description: List of permissions granted by this role + maxItems: 500 + items: + type: string + maxLength: 128 + pattern: "^[a-z0-9_-]+:[a-z0-9_-]+$" + example: ["document:read", "document:list", "export:read"] + + IdentityResponse: + type: object + description: Response containing the user's roles and their permissions + required: + - roles + properties: + roles: + type: array + description: List of roles assigned to the user with their permissions + maxItems: 100 + items: + $ref: "#/components/schemas/IdentityRole" + # Admin API schemas AdminUserCreate: description: Request body for creating a new user with email, name, and role assignments