From e29197a85bd921be992516180efa8d97738ed860 Mon Sep 17 00:00:00 2001 From: Yathusiga27 Date: Mon, 31 Aug 2026 23:38:37 +0530 Subject: [PATCH] Integrate external AuthZEN PDP authorization Signed-off-by: Yathusiga27 --- api/connections.yaml | 276 +++- api/resource.yaml | 29 + backend/cmd/server/config/default.json | 4 + backend/cmd/server/servicemanager.go | 4 +- backend/dbscripts/configdb/postgres.sql | 16 + backend/dbscripts/configdb/sqlite.sql | 16 + backend/internal/authz/engine/engine.go | 2 + .../authz/engine/external_authzen_pdp.go | 607 ++++++++ .../authz/engine/external_authzen_pdp_test.go | 503 +++++++ backend/internal/authz/error_constants.go | 22 + backend/internal/authz/init.go | 15 +- backend/internal/authz/service.go | 218 ++- backend/internal/authz/service_test.go | 366 ++++- backend/internal/authzen/model.go | 2 +- backend/internal/authzen/service.go | 123 +- backend/internal/authzen/service_test.go | 138 +- .../internal/connection/authzenpdp/mapping.go | 181 +++ .../internal/connection/authzenpdp/model.go | 161 ++ .../connection/authzenpdp/model_test.go | 190 +++ .../internal/connection/authzenpdp/service.go | 108 ++ .../connection/authzenpdp/service_test.go | 104 ++ .../internal/connection/authzenpdp/store.go | 194 +++ .../connection/authzenpdp/store_constants.go | 58 + .../connection/authzenpdp/store_test.go | 57 + .../connection_declarative_model.go | 22 +- .../connection/declarative_resource.go | 143 +- .../connection/declarative_resource_test.go | 75 + .../internal/connection/error_constants.go | 65 +- backend/internal/connection/handler.go | 104 ++ backend/internal/connection/init.go | 48 +- backend/internal/connection/init_test.go | 42 +- backend/internal/connection/mapping.go | 6 +- backend/internal/connection/mapping_test.go | 1 + backend/internal/connection/models.go | 3 +- backend/internal/connection/service.go | 193 ++- backend/internal/connection/service_test.go | 223 ++- .../internal/flow/executor/authz_executor.go | 13 +- .../flow/executor/authz_executor_test.go | 1 - .../granthandlers/client_credentials.go | 23 +- .../granthandlers/client_credentials_test.go | 18 + .../oauth2/granthandlers/refresh_token.go | 3 +- .../internal/resource/composite_store_test.go | 15 +- .../internal/resource/declarative_resource.go | 31 +- .../resource/declarative_resource_test.go | 46 +- backend/internal/resource/handler.go | 42 +- backend/internal/resource/handler_test.go | 32 + backend/internal/resource/model.go | 26 +- backend/internal/resource/service.go | 58 +- backend/internal/resource/service_test.go | 12 + backend/internal/resource/store.go | 14 +- backend/internal/system/config/config.go | 18 + backend/internal/system/config/config_test.go | 13 + backend/internal/system/i18n/core/defaults.go | 14 +- backend/internal/system/importer/init.go | 2 + backend/internal/system/importer/service.go | 72 + .../pkg/thunderidengine/providers/model.go | 44 +- tests/integration/authzen/authzen_api_test.go | 6 +- .../authzen/external_authzen_pdp_test.go | 1298 +++++++++++++++++ tests/integration/authzen/model.go | 20 +- 59 files changed, 5933 insertions(+), 207 deletions(-) create mode 100644 backend/internal/authz/engine/external_authzen_pdp.go create mode 100644 backend/internal/authz/engine/external_authzen_pdp_test.go create mode 100644 backend/internal/authz/error_constants.go create mode 100644 backend/internal/connection/authzenpdp/mapping.go create mode 100644 backend/internal/connection/authzenpdp/model.go create mode 100644 backend/internal/connection/authzenpdp/model_test.go create mode 100644 backend/internal/connection/authzenpdp/service.go create mode 100644 backend/internal/connection/authzenpdp/service_test.go create mode 100644 backend/internal/connection/authzenpdp/store.go create mode 100644 backend/internal/connection/authzenpdp/store_constants.go create mode 100644 backend/internal/connection/authzenpdp/store_test.go create mode 100644 tests/integration/authzen/external_authzen_pdp_test.go diff --git a/api/connections.yaml b/api/connections.yaml index 0664daf146..3f077f75ad 100644 --- a/api/connections.yaml +++ b/api/connections.yaml @@ -35,7 +35,7 @@ paths: summary: List configured connection instances description: >- Returns a paginated list of the configured connection instances across the - identity-provider and notification-sender backed connection types, optionally + identity-provider, notification-sender, and authorization PDP backed connection types, optionally filtered by functional category. Omit the category parameter to return all instances. parameters: - name: category @@ -44,7 +44,7 @@ paths: description: Filter instances by functional category. Omit to return all instances. schema: type: string - enum: [identity-provider, sms-provider] + enum: [identity-provider, sms-provider, authorization-pdp] - $ref: '#/components/parameters/limitQueryParam' - $ref: '#/components/parameters/offsetQueryParam' responses: @@ -78,6 +78,94 @@ paths: "403": { $ref: '#/components/responses/Forbidden' } "500": { $ref: '#/components/responses/InternalServerError' } + /connections/authzen-pdp: + get: + tags: [Connections] + summary: List configured external AuthZEN PDP connections + responses: + "200": { $ref: '#/components/responses/ExternalAuthZENPDPInstanceList' } + "401": { $ref: '#/components/responses/Unauthorized' } + "403": { $ref: '#/components/responses/Forbidden' } + "500": { $ref: '#/components/responses/InternalServerError' } + post: + tags: [Connections] + summary: Create an external AuthZEN PDP connection + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ExternalAuthZENPDPConnectionCreateRequest' } + responses: + "201": + description: Connection created + content: + application/json: + schema: { $ref: '#/components/schemas/ExternalAuthZENPDPConnectionResponse' } + "400": { $ref: '#/components/responses/ExternalAuthZENPDPBadRequest' } + "409": { $ref: '#/components/responses/ExternalAuthZENPDPConflict' } + "401": { $ref: '#/components/responses/Unauthorized' } + "403": { $ref: '#/components/responses/Forbidden' } + "500": { $ref: '#/components/responses/InternalServerError' } + /connections/authzen-pdp/{id}: + parameters: + - { $ref: '#/components/parameters/ConnectionID' } + get: + tags: [Connections] + summary: Get an external AuthZEN PDP connection + responses: + "200": + description: Connection details + content: + application/json: + schema: { $ref: '#/components/schemas/ExternalAuthZENPDPConnectionResponse' } + "404": { $ref: '#/components/responses/NotFound' } + "401": { $ref: '#/components/responses/Unauthorized' } + "403": { $ref: '#/components/responses/Forbidden' } + "500": { $ref: '#/components/responses/InternalServerError' } + put: + tags: [Connections] + summary: Update an external AuthZEN PDP connection + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ExternalAuthZENPDPConnectionUpdateRequest' } + responses: + "200": + description: Connection updated + content: + application/json: + schema: { $ref: '#/components/schemas/ExternalAuthZENPDPConnectionResponse' } + "400": { $ref: '#/components/responses/ExternalAuthZENPDPBadRequest' } + "404": { $ref: '#/components/responses/NotFound' } + "409": { $ref: '#/components/responses/ExternalAuthZENPDPConflict' } + "401": { $ref: '#/components/responses/Unauthorized' } + "403": { $ref: '#/components/responses/Forbidden' } + "500": { $ref: '#/components/responses/InternalServerError' } + delete: + tags: [Connections] + summary: Delete an external AuthZEN PDP connection + responses: + "204": { description: Connection deleted } + "404": { $ref: '#/components/responses/NotFound' } + "409": { $ref: '#/components/responses/ConnectionDependencyConflict' } + "401": { $ref: '#/components/responses/Unauthorized' } + "403": { $ref: '#/components/responses/Forbidden' } + "500": { $ref: '#/components/responses/InternalServerError' } + /connections/authzen-pdp/{id}/usages: + parameters: + - { $ref: '#/components/parameters/ConnectionID' } + get: + tags: [Connections] + summary: Get external AuthZEN PDP connection usages + description: Returns the resource servers that reference this external AuthZEN PDP connection. + responses: + "200": { $ref: '#/components/responses/ConnectionUsages' } + "404": { $ref: '#/components/responses/NotFound' } + "401": { $ref: '#/components/responses/Unauthorized' } + "403": { $ref: '#/components/responses/Forbidden' } + "500": { $ref: '#/components/responses/InternalServerError' } + /connections/google: get: tags: [Connections] @@ -845,6 +933,18 @@ components: type: array items: $ref: '#/components/schemas/ConnectionInstanceSummary' + ExternalAuthZENPDPInstanceList: + description: Configured external AuthZEN PDP connections + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ConnectionInstanceSummary' + example: + - id: "05d6e254-a3ca-49dd-89b7-2cf78fee6b23" + name: "Cerbos" + description: "External Cerbos AuthZEN PDP" ConnectionUsages: description: Resources that reference this connection content: @@ -860,16 +960,87 @@ components: id: "f1b2c3d4-0000-0000-0000-000000000001" displayName: "Login Flow" behaviorOnDelete: restrict + ExternalAuthZENPDPBadRequest: + description: Invalid external AuthZEN PDP connection request + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + examples: + invalid-request-format: + summary: Invalid request format + value: + code: "CON-1007" + message: + key: "error.connectionservice.invalid_request_format" + defaultValue: "Invalid request format" + description: + key: "error.connectionservice.invalid_request_format_description" + defaultValue: "The request body is malformed or contains invalid data" + invalid-authzen-pdp-endpoint: + summary: Invalid AuthZEN PDP endpoint + value: + code: "CON-1006" + message: + key: "error.connectionservice.invalid_authzen_pdp_endpoint" + defaultValue: "Invalid AuthZEN PDP endpoint" + description: + key: "error.connectionservice.invalid_authzen_pdp_endpoint_description" + defaultValue: "The single and batch evaluation endpoints must be absolute URLs." + ExternalAuthZENPDPConflict: + description: An external AuthZEN PDP connection with the same name already exists + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + example: + code: "CON-1008" + message: + key: "error.connectionservice.authzen_pdp_already_exists" + defaultValue: "An AuthZEN PDP connection with the same name already exists" + description: + key: "error.connectionservice.authzen_pdp_already_exists_description" + defaultValue: "Choose a different name for the AuthZEN PDP connection" + ConnectionDependencyConflict: + description: The connection cannot be deleted because it is still in use + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + example: + code: "CON-1005" + message: + key: "error.connectionservice.connection_has_blocking_dependencies" + defaultValue: "Connection cannot be deleted" + description: + key: "error.connectionservice.connection_has_blocking_dependencies_description" + defaultValue: "The connection cannot be deleted because other resources depend on it. Remove or reassign them first." BadRequest: description: Bad request content: application/json: schema: { $ref: '#/components/schemas/Error' } + examples: + invalid-request-format: + summary: Invalid request format + value: + code: "IDP-1001" + message: + key: "error.idpservice.invalid_request_format" + defaultValue: "Invalid request format" + description: + key: "error.idpservice.invalid_request_format_description" + defaultValue: "The request body is malformed or contains invalid data" NotFound: description: Connection not found content: application/json: schema: { $ref: '#/components/schemas/Error' } + example: + code: "CON-1004" + message: + key: "error.connectionservice.connection_not_found" + defaultValue: "Connection not found" + description: + key: "error.connectionservice.connection_not_found_description" + defaultValue: "No connection exists for the supplied identifier" Conflict: description: A connection with the same name already exists content: @@ -880,13 +1051,40 @@ components: content: application/json: schema: { $ref: '#/components/schemas/Error' } + example: + code: "AUTH-4010" + message: + key: "error.unauthorized" + defaultValue: "Unauthorized" + description: + key: "error.unauthorized_description" + defaultValue: "Authentication is required to access this resource" Forbidden: description: Insufficient permissions to perform this operation content: application/json: schema: { $ref: '#/components/schemas/Error' } + example: + code: "AUTH-4030" + message: + key: "error.forbidden" + defaultValue: "Forbidden" + description: + key: "error.forbidden_description" + defaultValue: "You do not have sufficient permissions to access this resource" InternalServerError: description: Internal server error + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + example: + code: "SSE-5000" + message: + key: "error.internal_server_error" + defaultValue: "Internal server error" + description: + key: "error.internal_server_error_description" + defaultValue: "An unexpected error occurred while processing the request" schemas: ConnectionListResponse: @@ -929,14 +1127,14 @@ components: type: string description: >- Lowercase vendor identifier. `sms-gateway` denotes a generic HTTP webhook SMS sender. - enum: [google, github, oidc, oauth, twilio, vonage, sms-gateway] + enum: [google, github, oidc, oauth, twilio, vonage, sms-gateway, authzen-pdp] example: "google" categories: type: array minItems: 1 items: type: string - enum: [identity-provider, sms-provider] + enum: [identity-provider, sms-provider, authorization-pdp] example: ["identity-provider"] ConnectionInstanceSummary: @@ -1172,6 +1370,76 @@ components: prompt: { type: string } attributeConfiguration: { $ref: '#/components/schemas/AttributeConfiguration' } + ExternalAuthZENPDPConnectionUpdateRequest: + type: object + required: [name, endpoint, batchEndpoint] + properties: + name: { type: string, example: "Travel Booking PDP" } + description: { type: string } + endpoint: + type: string + format: uri + pattern: '^https?://' + description: HTTP endpoint for a single AuthZEN access evaluation. + example: "https://pdp.example.com/access/v1/evaluation" + batchEndpoint: + type: string + format: uri + pattern: '^https?://' + description: HTTP endpoint for batched AuthZEN access evaluations. + example: "https://pdp.example.com/access/v1/evaluations" + timeoutMs: + type: integer + minimum: 1 + default: 500 + description: Timeout for each PDP request in milliseconds. + retryCount: + type: integer + minimum: 0 + default: 1 + description: Number of retries for transient PDP or network failures. + subjectProperties: + type: string + description: Space-, comma-, or newline-separated ThunderID subject properties allowed to reach the PDP. + example: "username email groups" + subjectPropertyMappings: + type: string + description: Comma-separated source-to-PDP subject property mappings. + example: "username: preferred_username" + subjectAttributeMappings: + type: array + items: { $ref: '#/components/schemas/ExternalAuthZENPDPSubjectAttributeMapping' } + ExternalAuthZENPDPConnectionCreateRequest: + $ref: '#/components/schemas/ExternalAuthZENPDPConnectionUpdateRequest' + ExternalAuthZENPDPConnectionResponse: + allOf: + - $ref: '#/components/schemas/ExternalAuthZENPDPConnectionUpdateRequest' + - type: object + required: [id, type] + properties: + id: { type: string, format: uuid } + type: { type: string, enum: [authzen-pdp] } + + ExternalAuthZENPDPSubjectAttributeMapping: + type: object + required: [userType, attributes] + properties: + userType: + type: string + description: ThunderID user type to which this mapping applies. + attributes: + type: array + items: + type: object + required: [attribute] + properties: + attribute: + type: string + description: ThunderID user attribute name. + pdpAttribute: + type: string + description: AuthZEN PDP subject property name. Defaults to attribute when omitted. + TwilioConnectionUpdateRequest: type: object required: [name, accountSid, senderId] diff --git a/api/resource.yaml b/api/resource.yaml index ee96a7b90c..ca145e94eb 100644 --- a/api/resource.yaml +++ b/api/resource.yaml @@ -327,6 +327,10 @@ paths: description: "Updated description for booking operations" identifier: "https://api.example.com/booking/v2" ouId: "a839f4bd-39dc-4eaa-b5cc-210d8ecaee87" + authorizationEngine: + type: authzen_pdp + properties: + pdpConnectionId: "05d6e254-a3ca-49dd-89b7-2cf78fee6b23" responses: "200": description: Resource server updated @@ -342,6 +346,10 @@ paths: type: "API" ouId: "a839f4bd-39dc-4eaa-b5cc-210d8ecaee87" delimiter: ":" + authorizationEngine: + type: authzen_pdp + properties: + pdpConnectionId: "05d6e254-a3ca-49dd-89b7-2cf78fee6b23" "400": description: Bad request content: @@ -2290,6 +2298,8 @@ components: delimiter: type: string description: Character used to separate permission hierarchy levels (immutable after creation) + authorizationEngine: + $ref: '#/components/schemas/AuthorizationEngineConfig' isReadOnly: type: boolean description: Whether the resource server is read-only (system-managed) @@ -2336,6 +2346,25 @@ components: type: string format: uuid description: ID of the organization unit this resource server belongs to + authorizationEngine: + $ref: '#/components/schemas/AuthorizationEngineConfig' + + AuthorizationEngineConfig: + type: object + description: Authorization engine configuration for the resource server. + properties: + type: + type: string + enum: [rbac, authzen_pdp] + default: rbac + description: Use local RBAC by default, or select an external AuthZEN PDP for authorization decisions. + properties: + type: object + properties: + pdpConnectionId: + type: string + format: uuid + description: ID of the external AuthZEN PDP connection to use. Resource: type: object diff --git a/backend/cmd/server/config/default.json b/backend/cmd/server/config/default.json index c6f9435231..17b1f93231 100644 --- a/backend/cmd/server/config/default.json +++ b/backend/cmd/server/config/default.json @@ -320,6 +320,10 @@ } } }, + "authzen_pdp": { + "timeout_ms": 500, + "retry_count": 1 + }, "consent": { "enabled": false, "base_url": "", diff --git a/backend/cmd/server/servicemanager.go b/backend/cmd/server/servicemanager.go index 83594f85fd..8662c82cec 100644 --- a/backend/cmd/server/servicemanager.go +++ b/backend/cmd/server/servicemanager.go @@ -224,7 +224,7 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa ouAuthzService.SetPermissionResolver( role.NewEffectivePermissionResolver(roleService, groupService, entityService)) - authZService := authz.Initialize(roleService) + authZService := authz.Initialize(roleService, resourceService, userService) idpService, err := idp.Initialize(cacheManager, entityTypeService) fatalOnError(ctx, logger, err, "Failed to initialize IDPService") @@ -237,7 +237,7 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa // Register the /connections API as a thin layer over the identity-provider and // notification-sender services. - connectionExporter, err := connection.Initialize(mux, idpService, notifSenderMgtSvc) + connectionExporter, err := connection.Initialize(mux, idpService, notifSenderMgtSvc, resourceService) fatalOnError(ctx, logger, err, "Failed to initialize connection declarative resources") exporters = append(exporters, connectionExporter) diff --git a/backend/dbscripts/configdb/postgres.sql b/backend/dbscripts/configdb/postgres.sql index 78391aa03d..f63f4b120f 100644 --- a/backend/dbscripts/configdb/postgres.sql +++ b/backend/dbscripts/configdb/postgres.sql @@ -159,6 +159,22 @@ CREATE TABLE "NOTIFICATION_SENDER" ( -- Composite index for name-based notification sender lookups CREATE INDEX idx_notification_sender_name_deployment ON "NOTIFICATION_SENDER" (DEPLOYMENT_ID, NAME); +-- Table to store external AuthZEN PDP connections. +CREATE TABLE "AUTHZEN_PDP_CONNECTION" ( + DEPLOYMENT_ID VARCHAR(255) NOT NULL, + ID VARCHAR(36) NOT NULL, + NAME VARCHAR(255) NOT NULL, + DESCRIPTION VARCHAR(500), + PROPERTIES TEXT NOT NULL, + CREATED_AT TIMESTAMPTZ DEFAULT NOW(), + UPDATED_AT TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (DEPLOYMENT_ID, ID), + UNIQUE (DEPLOYMENT_ID, NAME) +); + +CREATE INDEX idx_authzen_pdp_connection_deployment + ON "AUTHZEN_PDP_CONNECTION" (DEPLOYMENT_ID); + -- Table to store certificates associated with various entities. CREATE TABLE "CERTIFICATE" ( DEPLOYMENT_ID VARCHAR(255) NOT NULL, diff --git a/backend/dbscripts/configdb/sqlite.sql b/backend/dbscripts/configdb/sqlite.sql index 169c5ed549..4e485fc01b 100644 --- a/backend/dbscripts/configdb/sqlite.sql +++ b/backend/dbscripts/configdb/sqlite.sql @@ -159,6 +159,22 @@ CREATE TABLE "NOTIFICATION_SENDER" ( -- Composite index for name-based notification sender lookups CREATE INDEX idx_notification_sender_name_deployment ON "NOTIFICATION_SENDER" (DEPLOYMENT_ID, NAME); +-- Table to store external AuthZEN PDP connections. +CREATE TABLE "AUTHZEN_PDP_CONNECTION" ( + DEPLOYMENT_ID VARCHAR(255) NOT NULL, + ID VARCHAR(36) NOT NULL, + NAME VARCHAR(255) NOT NULL, + DESCRIPTION VARCHAR(500), + PROPERTIES TEXT NOT NULL, + CREATED_AT TEXT DEFAULT (datetime('now')), + UPDATED_AT TEXT DEFAULT (datetime('now')), + PRIMARY KEY (DEPLOYMENT_ID, ID), + UNIQUE (DEPLOYMENT_ID, NAME) +); + +CREATE INDEX idx_authzen_pdp_connection_deployment + ON "AUTHZEN_PDP_CONNECTION" (DEPLOYMENT_ID); + -- Table to store certificates associated with various entities. CREATE TABLE "CERTIFICATE" ( DEPLOYMENT_ID VARCHAR(255) NOT NULL, diff --git a/backend/internal/authz/engine/engine.go b/backend/internal/authz/engine/engine.go index cbf0185afc..cba60bac5a 100644 --- a/backend/internal/authz/engine/engine.go +++ b/backend/internal/authz/engine/engine.go @@ -33,6 +33,8 @@ type Subject struct { // ResourceServer identifies the resource server for an access evaluation. type ResourceServer struct { ID string + Identifier string + ResourceID string Properties map[string]interface{} } diff --git a/backend/internal/authz/engine/external_authzen_pdp.go b/backend/internal/authz/engine/external_authzen_pdp.go new file mode 100644 index 0000000000..76d8725399 --- /dev/null +++ b/backend/internal/authz/engine/external_authzen_pdp.go @@ -0,0 +1,607 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package engine + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/thunder-id/thunderid/internal/authzen" + "github.com/thunder-id/thunderid/internal/connection/authzenpdp" + httpservice "github.com/thunder-id/thunderid/internal/system/http" + "github.com/thunder-id/thunderid/internal/system/log" +) + +// AuthZENPDPConfig configures an external AuthZEN PDP access-evaluation endpoint. +type AuthZENPDPConfig struct { + Endpoint string + BatchEndpoint string + Timeout time.Duration + RetryCount int + SubjectProperties []string + SubjectPropertyMappings map[string]string + SubjectAttributeMappings []SubjectAttributeMapping +} + +// NewAuthZENPDP creates an external AuthZEN PDP engine from a saved connection. +func NewAuthZENPDP( + ctx context.Context, + connectionID string, + client httpservice.HTTPClientInterface, +) (AuthorizationEngine, bool, error) { + config, err := authzenpdp.GetAuthZENPDPRuntimeConfig(ctx, strings.TrimSpace(connectionID)) + if err != nil { + return nil, false, err + } + if config == nil { + return nil, false, nil + } + + pdpEngine, err := newAuthZENPDP(AuthZENPDPConfig{ + Endpoint: config.Endpoint, + BatchEndpoint: config.BatchEndpoint, + Timeout: time.Duration(config.TimeoutMS) * time.Millisecond, + RetryCount: config.RetryCount, + SubjectProperties: config.SubjectProperties, + SubjectPropertyMappings: config.SubjectPropertyMappings, + SubjectAttributeMappings: toSubjectAttributeMappings(config.SubjectAttributeMappings), + }, client) + return pdpEngine, true, err +} + +func toSubjectAttributeMappings( + groups []authzenpdp.SubjectAttributeMapping, +) []SubjectAttributeMapping { + if len(groups) == 0 { + return nil + } + result := make([]SubjectAttributeMapping, 0, len(groups)) + for _, group := range groups { + attributes := make([]SubjectAttributeRow, 0, len(group.Attributes)) + for _, attribute := range group.Attributes { + attributes = append(attributes, SubjectAttributeRow{ + Attribute: attribute.Attribute, + PDPAttribute: attribute.PDPAttribute, + }) + } + result = append(result, SubjectAttributeMapping{ + UserType: group.UserType, + Attributes: attributes, + }) + } + return result +} + +// SubjectAttributeMapping maps a subject type to PDP subject attribute names. +type SubjectAttributeMapping struct { + UserType string + Attributes []SubjectAttributeRow +} + +// SubjectAttributeRow identifies one PDP subject attribute mapping. +type SubjectAttributeRow struct { + Attribute string + PDPAttribute string +} + +type authZENPDP struct { + endpoint string + batchEndpoint string + retryCount int + subjectProperties map[string]struct{} + subjectPropertyMappings map[string]string + subjectAttributeMappings []SubjectAttributeMapping + timeout time.Duration + client httpservice.HTTPClientInterface + logger *log.Logger +} + +const ( + subjectGroupsProperty = "groups" +) + +type authZENEvaluationRequest = authzen.AccessEvaluationRequest +type authZENSubject = authzen.Subject +type authZENResource = authzen.Resource +type authZENAction = authzen.Action +type authZENEvaluationResponse = authzen.AccessEvaluationResponse +type authZENBatchEvaluationRequest = authzen.AccessEvaluationsRequest +type authZENBatchEvaluation = authzen.AccessEvaluationRequest +type authZENBatchEvaluationResponse = authzen.AccessEvaluationsResponse + +// newAuthZENPDP creates an AuthorizationEngine from resolved external AuthZEN PDP settings. +func newAuthZENPDP( + config AuthZENPDPConfig, + client httpservice.HTTPClientInterface, +) (AuthorizationEngine, error) { + configuredEndpoint := strings.TrimSpace(config.Endpoint) + if err := validateAuthZENEndpoint(configuredEndpoint); err != nil { + return nil, fmt.Errorf("invalid AuthZEN access evaluation endpoint: %w", err) + } + + batchEndpoint := strings.TrimSpace(config.BatchEndpoint) + if batchEndpoint == "" { + return nil, fmt.Errorf("AuthZEN access evaluations endpoint is required") + } + if err := validateAuthZENEndpoint(batchEndpoint); err != nil { + return nil, fmt.Errorf("invalid AuthZEN access evaluations endpoint: %w", err) + } + if client == nil { + return nil, fmt.Errorf("HTTP client is required") + } + timeout := config.Timeout + if timeout <= 0 { + timeout = time.Duration(authzenpdp.DefaultTimeoutMS()) * time.Millisecond + } + + subjectProperties := make(map[string]struct{}, len(config.SubjectProperties)) + for _, property := range config.SubjectProperties { + property = strings.TrimSpace(property) + if property != "" { + subjectProperties[property] = struct{}{} + } + } + + subjectPropertyMappings := make(map[string]string, len(config.SubjectPropertyMappings)) + for source, target := range config.SubjectPropertyMappings { + source = strings.TrimSpace(source) + target = strings.TrimSpace(target) + if source == "" || target == "" { + return nil, fmt.Errorf("subject property mappings must have non-empty names") + } + if _, allowed := subjectProperties[source]; !allowed { + return nil, fmt.Errorf("subject property %q has a mapping but is not allowed", source) + } + subjectPropertyMappings[source] = target + } + subjectAttributeMappings := normalizeSubjectAttributeMappings(config.SubjectAttributeMappings) + + return &authZENPDP{ + endpoint: configuredEndpoint, + batchEndpoint: batchEndpoint, + retryCount: max(config.RetryCount, 0), + subjectProperties: subjectProperties, + subjectPropertyMappings: subjectPropertyMappings, + subjectAttributeMappings: subjectAttributeMappings, + timeout: timeout, + client: client, + logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, "ExternalAuthZENPDP")), + }, nil +} + +// EvaluateAccess evaluates a single authorization request with the external AuthZEN PDP. +func (p *authZENPDP) EvaluateAccess( + ctx context.Context, + request AccessEvaluationRequest, +) (*AccessEvaluationResponse, error) { + response, err := p.evaluateSingle(ctx, request) + if err != nil { + return nil, err + } + return response, nil +} + +// EvaluateAccessBatch evaluates multiple authorization requests using AuthZEN's batch endpoint. +func (p *authZENPDP) EvaluateAccessBatch( + ctx context.Context, + request AccessEvaluationsRequest, +) (*AccessEvaluationsResponse, error) { + if len(request.Evaluations) == 0 { + return &AccessEvaluationsResponse{Evaluations: []AccessEvaluationResponse{}}, nil + } + return p.evaluateBatch(ctx, request) +} + +// evaluateBatch converts ThunderID evaluations into AuthZEN batch payloads and maps responses back in order. +func (p *authZENPDP) evaluateBatch( + ctx context.Context, + request AccessEvaluationsRequest, +) (*AccessEvaluationsResponse, error) { + started := time.Now() + batchEndpoint := p.batchEndpoint + payload := authZENBatchEvaluationRequest{ + Evaluations: make([]authZENBatchEvaluation, 0, len(request.Evaluations)), + } + for _, evaluation := range request.Evaluations { + converted := toAuthZENEvaluationRequest( + evaluation, + p.subjectProperties, + p.subjectPropertyMappings, + p.subjectAttributeMappings, + ) + payload.Evaluations = append(payload.Evaluations, authZENBatchEvaluation{ + Subject: converted.Subject, + Resource: converted.Resource, + Action: converted.Action, + Context: evaluation.Context, + }) + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to encode AuthZEN batch request: %w", err) + } + + var response authZENBatchEvaluationResponse + if err := p.post(ctx, batchEndpoint, body, &response); err != nil { + p.logger.Error(ctx, "External AuthZEN batch evaluation failed", + log.String("pdp_endpoint", batchEndpoint), + log.Int("evaluation_count", len(request.Evaluations)), + log.Any("evaluation_request", auditAuthZENEvaluations(payload.Evaluations)), + log.Int("latency_ms", int(time.Since(started).Milliseconds())), + log.Error(err), + ) + return nil, err + } + if len(response.Evaluations) != len(request.Evaluations) { + return nil, fmt.Errorf("AuthZEN PDP returned %d evaluations for %d requests", + len(response.Evaluations), len(request.Evaluations)) + } + + for index, evaluation := range request.Evaluations { + converted := payload.Evaluations[index] + fields := authZENEvaluationLogFields(converted) + fields = append(fields, + log.MaskedMap("response_context", response.Evaluations[index].Context), + log.Int("latency_ms", int(time.Since(started).Milliseconds())), + ) + p.logger.Info(ctx, "External AuthZEN evaluation completed", + append([]log.Field{ + log.String("pdp_endpoint", batchEndpoint), + log.String("resource_server_id", evaluation.ResourceServer.ID), + log.String("action", evaluation.Permission.Name), + log.Bool("decision", response.Evaluations[index].Decision), + }, fields...)..., + ) + } + + results := make([]AccessEvaluationResponse, 0, len(response.Evaluations)) + for _, evaluation := range response.Evaluations { + results = append(results, AccessEvaluationResponse(evaluation)) + } + return &AccessEvaluationsResponse{Evaluations: results}, nil +} + +// evaluateSingle sends one ThunderID authorization request to the configured AuthZEN PDP. +func (p *authZENPDP) evaluateSingle( + ctx context.Context, + evaluation AccessEvaluationRequest, +) (response *AccessEvaluationResponse, err error) { + started := time.Now() + evaluationEndpoint := p.endpoint + decision := false + var auditRequest authZENEvaluationRequest + var responseContext map[string]interface{} + defer func() { + fields := []log.Field{ + log.String("pdp_endpoint", evaluationEndpoint), + log.String("resource_server_id", evaluation.ResourceServer.ID), + log.String("action", evaluation.Permission.Name), + log.Bool("decision", decision), + log.Int("latency_ms", int(time.Since(started).Milliseconds())), + log.MaskedMap("response_context", responseContext), + } + if auditRequest.Subject.ID != "" { + fields = append(fields, authZENEvaluationLogFields(auditRequest)...) + } else { + fields = append(fields, log.MaskedString("subject_id", evaluation.Subject.ID)) + } + if err != nil { + p.logger.Error(ctx, "External AuthZEN evaluation failed", append(fields, log.Error(err))...) + return + } + p.logger.Info(ctx, "External AuthZEN evaluation completed", fields...) + }() + + auditRequest = toAuthZENEvaluationRequest( + evaluation, + p.subjectProperties, + p.subjectPropertyMappings, + p.subjectAttributeMappings, + ) + payload, err := json.Marshal(auditRequest) + if err != nil { + return nil, fmt.Errorf("failed to encode AuthZEN request: %w", err) + } + + var responseDecision authZENEvaluationResponse + if err := p.post(ctx, evaluationEndpoint, payload, &responseDecision); err != nil { + return nil, err + } + + decision = responseDecision.Decision + responseContext = responseDecision.Context + return &AccessEvaluationResponse{ + Decision: responseDecision.Decision, + Context: responseDecision.Context, + }, nil +} + +// authZENEvaluationLogFields returns request fields safe for audit logs. Subject, resource, action, +// and context attributes are masked where their values may contain sensitive data. +func authZENEvaluationLogFields(request authZENEvaluationRequest) []log.Field { + fields := []log.Field{ + log.MaskedString("subject_id", request.Subject.ID), + log.String("subject_type", request.Subject.Type), + log.String("resource_type", request.Resource.Type), + log.MaskedString("resource_id", request.Resource.ID), + log.String("action_name", request.Action.Name), + } + if request.Subject.Properties != nil { + fields = append(fields, log.MaskedMap("subject_properties", request.Subject.Properties)) + } + if request.Resource.Properties != nil { + fields = append(fields, log.MaskedMap("resource_properties", request.Resource.Properties)) + } + if request.Action.Properties != nil { + fields = append(fields, log.MaskedMap("action_properties", request.Action.Properties)) + } + if request.Context != nil { + fields = append(fields, log.MaskedMap("evaluation_context", request.Context)) + } + return fields +} + +func auditAuthZENEvaluations(evaluations []authZENBatchEvaluation) []map[string]interface{} { + result := make([]map[string]interface{}, 0, len(evaluations)) + for _, evaluation := range evaluations { + result = append(result, map[string]interface{}{ + "subject": map[string]interface{}{ + "type": evaluation.Subject.Type, + "id": maskedAuditString(evaluation.Subject.ID), + "properties": maskedAuditMap(evaluation.Subject.Properties), + }, + "resource": map[string]interface{}{ + "type": evaluation.Resource.Type, + "id": maskedAuditString(evaluation.Resource.ID), + "properties": maskedAuditMap(evaluation.Resource.Properties), + }, + "action": map[string]interface{}{ + "name": evaluation.Action.Name, + "properties": maskedAuditMap(evaluation.Action.Properties), + }, + "context": maskedAuditMap(evaluation.Context), + }) + } + return result +} + +func maskedAuditString(value string) string { + return log.MaskedString("value", value).Value.(string) +} + +func maskedAuditMap(values map[string]interface{}) map[string]interface{} { + if values == nil { + return nil + } + return log.MaskedMap("values", values).Value.(map[string]interface{}) +} + +// post sends an AuthZEN JSON request and retries transient PDP or network failures. +func (p *authZENPDP) post( + ctx context.Context, + endpoint string, + payload []byte, + result interface{}, +) error { + attempts := p.retryCount + 1 + for attempt := 0; attempt < attempts; attempt++ { + requestCtx, cancel := context.WithTimeout(ctx, p.timeout) + req, err := http.NewRequestWithContext(requestCtx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + cancel() + return fmt.Errorf("failed to create AuthZEN request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := p.client.Do(req) + if err != nil { + cancel() + if ctx.Err() != nil || attempt == attempts-1 { + return fmt.Errorf("AuthZEN PDP request failed: %w", err) + } + continue + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + statusCode := resp.StatusCode + _ = resp.Body.Close() + cancel() + if statusCode >= http.StatusInternalServerError && attempt < attempts-1 { + continue + } + return fmt.Errorf("AuthZEN PDP returned HTTP %d", statusCode) + } + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + _ = resp.Body.Close() + cancel() + return fmt.Errorf("failed to decode AuthZEN response: %w", err) + } + _ = resp.Body.Close() + cancel() + return nil + } + return fmt.Errorf("AuthZEN PDP request failed after retries") +} + +func validateAuthZENEndpoint(endpoint string) error { + parsedEndpoint, err := url.Parse(endpoint) + if err != nil || parsedEndpoint.Host == "" || + (parsedEndpoint.Scheme != "http" && parsedEndpoint.Scheme != "https") { + return fmt.Errorf("endpoint must be an absolute URL") + } + return nil +} + +// toAuthZENEvaluationRequest maps ThunderID's internal authorization request to AuthZEN's wire format. +func toAuthZENEvaluationRequest( + evaluation AccessEvaluationRequest, + allowedSubjectProperties map[string]struct{}, + subjectPropertyMappings map[string]string, + subjectAttributeMappings []SubjectAttributeMapping, +) authZENEvaluationRequest { + resourceType := evaluation.ResourceServer.Identifier + resourceID := evaluation.ResourceServer.ResourceID + if strings.TrimSpace(resourceType) == "" { + resourceType = evaluation.ResourceServer.ID + } + if strings.TrimSpace(resourceID) == "" { + resourceID = evaluation.ResourceServer.ID + } + + resourceProperties := make(map[string]interface{}, len(evaluation.ResourceServer.Properties)) + for key, value := range evaluation.ResourceServer.Properties { + resourceProperties[key] = value + } + + return authZENEvaluationRequest{ + Subject: authZENSubject{ + Type: evaluation.Subject.Type, + ID: evaluation.Subject.ID, + Properties: authZENSubjectProperties( + evaluation.Subject, + allowedSubjectProperties, + subjectPropertyMappings, + subjectAttributeMappings, + ), + }, + Resource: authZENResource{ + Type: resourceType, + ID: resourceID, + Properties: resourceProperties, + }, + Action: authZENAction{ + Name: evaluation.Permission.Name, + Properties: evaluation.Permission.Properties, + }, + Context: evaluation.Context, + } +} + +// authZENSubjectProperties filters and optionally renames subject attributes before sending them to the PDP. +func authZENSubjectProperties( + subject Subject, + allowedSubjectProperties map[string]struct{}, + subjectPropertyMappings map[string]string, + subjectAttributeMappings []SubjectAttributeMapping, +) map[string]interface{} { + allowedSubjectProperties, subjectPropertyMappings = subjectMappingForSubject( + subject.Type, + allowedSubjectProperties, + subjectPropertyMappings, + subjectAttributeMappings, + ) + properties := make(map[string]interface{}, len(allowedSubjectProperties)+1) + for key, value := range subject.Properties { + if _, allowed := allowedSubjectProperties[key]; !allowed { + continue + } + propertyName := key + if mappedName, mapped := subjectPropertyMappings[key]; mapped { + propertyName = mappedName + } + properties[propertyName] = value + } + if len(subject.GroupIDs) > 0 { + if _, allowed := allowedSubjectProperties[subjectGroupsProperty]; allowed { + propertyName := subjectGroupsProperty + if mappedName, mapped := subjectPropertyMappings[subjectGroupsProperty]; mapped { + propertyName = mappedName + } + if _, exists := properties[propertyName]; !exists { + properties[propertyName] = append([]string(nil), subject.GroupIDs...) + } + } + } + if len(properties) == 0 { + return nil + } + return properties +} + +func subjectMappingForSubject( + subjectType string, + allowedSubjectProperties map[string]struct{}, + subjectPropertyMappings map[string]string, + subjectAttributeMappings []SubjectAttributeMapping, +) (map[string]struct{}, map[string]string) { + if len(subjectAttributeMappings) == 0 { + return allowedSubjectProperties, subjectPropertyMappings + } + resultProperties := cloneSubjectPropertySet(allowedSubjectProperties) + resultMappings := cloneSubjectPropertyMapping(subjectPropertyMappings) + trimmedSubjectType := strings.TrimSpace(subjectType) + for _, group := range subjectAttributeMappings { + if strings.TrimSpace(group.UserType) != trimmedSubjectType { + continue + } + for _, row := range group.Attributes { + attribute := strings.TrimSpace(row.Attribute) + if attribute == "" { + continue + } + resultProperties[attribute] = struct{}{} + if pdpAttribute := strings.TrimSpace(row.PDPAttribute); pdpAttribute != "" { + resultMappings[attribute] = pdpAttribute + } + } + break + } + return resultProperties, resultMappings +} + +func normalizeSubjectAttributeMappings(groups []SubjectAttributeMapping) []SubjectAttributeMapping { + if len(groups) == 0 { + return nil + } + normalized := make([]SubjectAttributeMapping, 0, len(groups)) + for _, group := range groups { + attributes := make([]SubjectAttributeRow, 0, len(group.Attributes)) + for _, row := range group.Attributes { + attribute := strings.TrimSpace(row.Attribute) + pdpAttribute := strings.TrimSpace(row.PDPAttribute) + if attribute == "" { + continue + } + if pdpAttribute == "" { + pdpAttribute = attribute + } + attributes = append(attributes, SubjectAttributeRow{ + Attribute: attribute, + PDPAttribute: pdpAttribute, + }) + } + if len(attributes) == 0 { + continue + } + normalized = append(normalized, SubjectAttributeMapping{ + UserType: strings.TrimSpace(group.UserType), + Attributes: attributes, + }) + } + if len(normalized) == 0 { + return nil + } + return normalized +} + +func cloneSubjectPropertySet(values map[string]struct{}) map[string]struct{} { + clone := make(map[string]struct{}, len(values)) + for key := range values { + clone[key] = struct{}{} + } + return clone +} + +func cloneSubjectPropertyMapping(values map[string]string) map[string]string { + clone := make(map[string]string, len(values)) + for key, value := range values { + clone[key] = value + } + return clone +} diff --git a/backend/internal/authz/engine/external_authzen_pdp_test.go b/backend/internal/authz/engine/external_authzen_pdp_test.go new file mode 100644 index 0000000000..5056325c60 --- /dev/null +++ b/backend/internal/authz/engine/external_authzen_pdp_test.go @@ -0,0 +1,503 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package engine + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "os" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/thunder-id/thunderid/internal/system/config" +) + +func TestMain(m *testing.M) { + data, err := os.ReadFile("../../../cmd/server/config/default.json") + if err != nil { + panic(err) + } + var cfg config.Config + if err := json.Unmarshal(data, &cfg); err != nil { + panic(err) + } + if err := config.InitializeServerRuntime("", &cfg); err != nil { + panic(err) + } + os.Exit(m.Run()) +} + +func TestAuthZENPDPEvaluateAccess(t *testing.T) { + server := newAuthZENTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/access/v1/evaluation", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "application/json", r.Header.Get("Content-Type")) + + var request authZENEvaluationRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + require.Equal(t, "user", request.Subject.Type) + require.Equal(t, "user-1", request.Subject.ID) + require.Equal(t, "finance", request.Subject.Properties["department_name"]) + require.Equal(t, []interface{}{"travel-agent"}, request.Subject.Properties["group_ids"]) + require.NotContains(t, request.Subject.Properties, "department") + require.NotContains(t, request.Subject.Properties, "email") + require.NotContains(t, request.Subject.Properties, subjectGroupsProperty) + require.Equal(t, "external-resource", request.Resource.Type) + require.Equal(t, "read", request.Action.Name) + require.Equal(t, "authorization_code", request.Context["grant_type"]) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"decision":true,"context":{"policy":"allow-read"}}`)) + })) + defer server.Close() + + engine, err := newAuthZENPDP(AuthZENPDPConfig{ + Endpoint: evaluationEndpoint(server), + BatchEndpoint: batchEndpoint(server), + SubjectProperties: []string{"department", subjectGroupsProperty}, + SubjectPropertyMappings: map[string]string{"department": "department_name", subjectGroupsProperty: "group_ids"}, + }, server.Client()) + require.NoError(t, err) + + response, err := engine.EvaluateAccess(context.Background(), AccessEvaluationRequest{ + Subject: Subject{ + Type: "user", + ID: "user-1", + GroupIDs: []string{"travel-agent"}, + Properties: map[string]interface{}{ + "department": "finance", + "email": "user@example.com", + }, + }, + ResourceServer: ResourceServer{ + ID: "resource-server-1", + Identifier: "external-resource", + }, + Permission: Permission{Name: "read"}, + Context: map[string]interface{}{"grant_type": "authorization_code"}, + }) + require.NoError(t, err) + require.True(t, response.Decision) + require.Equal(t, "allow-read", response.Context["policy"]) +} + +func TestAuthZENPDPEvaluateAccessDeny(t *testing.T) { + server := newAuthZENTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/access/v1/evaluation", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"decision":false,"context":{"reason":"denied"}}`)) + })) + defer server.Close() + + engine, err := newAuthZENPDP(authZENPDPConfig(server), server.Client()) + require.NoError(t, err) + + response, err := engine.EvaluateAccess(context.Background(), AccessEvaluationRequest{ + Subject: Subject{ID: "user-1"}, + ResourceServer: ResourceServer{ID: "resource-server-1"}, + Permission: Permission{Name: "delete"}, + }) + require.NoError(t, err) + require.False(t, response.Decision) + require.Equal(t, "denied", response.Context["reason"]) +} + +func TestAuthZENPDPDoesNotSendUnconfiguredSubjectProperties(t *testing.T) { + server := newAuthZENTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/access/v1/evaluation", r.URL.Path) + var request authZENEvaluationRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + require.Nil(t, request.Subject.Properties) + require.NotContains(t, request.Subject.Properties, "department") + require.NotContains(t, request.Subject.Properties, "email") + require.NotContains(t, request.Subject.Properties, subjectGroupsProperty) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"decision":true}`)) + })) + defer server.Close() + + engine, err := newAuthZENPDP(authZENPDPConfig(server), server.Client()) + require.NoError(t, err) + + response, err := engine.EvaluateAccess(context.Background(), AccessEvaluationRequest{ + Subject: Subject{ + Type: "user", + ID: "user-1", + GroupIDs: []string{"travel-agent"}, + Properties: map[string]interface{}{ + "department": "finance", + "email": "user@example.com", + }, + }, + ResourceServer: ResourceServer{ID: "resource-server-1"}, + Permission: Permission{Name: "read"}, + }) + require.NoError(t, err) + require.True(t, response.Decision) +} + +func TestAuthZENPDPEvaluateAccessUsesSubjectTypeAttributeMapping(t *testing.T) { + server := newAuthZENTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/access/v1/evaluation", r.URL.Path) + var request authZENEvaluationRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + require.Equal(t, "Customer", request.Subject.Type) + require.Equal(t, "active", request.Subject.Properties["customer_status"]) + require.NotContains(t, request.Subject.Properties, "agent_status") + require.NotContains(t, request.Subject.Properties, "status") + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"decision":true}`)) + })) + defer server.Close() + + engine, err := newAuthZENPDP(AuthZENPDPConfig{ + Endpoint: evaluationEndpoint(server), + BatchEndpoint: batchEndpoint(server), + SubjectAttributeMappings: []SubjectAttributeMapping{ + { + UserType: "Agent", + Attributes: []SubjectAttributeRow{{Attribute: "status", PDPAttribute: "agent_status"}}, + }, + { + UserType: "Customer", + Attributes: []SubjectAttributeRow{{Attribute: "status", PDPAttribute: "customer_status"}}, + }, + }, + }, server.Client()) + require.NoError(t, err) + + response, err := engine.EvaluateAccess(context.Background(), AccessEvaluationRequest{ + Subject: Subject{ + Type: "Customer", + ID: "customer-1", + Properties: map[string]interface{}{"status": "active"}, + }, + ResourceServer: ResourceServer{ID: "resource-server-1"}, + Permission: Permission{Name: "read"}, + }) + require.NoError(t, err) + require.True(t, response.Decision) +} + +func TestAuthZENPDPEvaluateAccessBatch(t *testing.T) { + server := newAuthZENTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/access/v1/evaluations", r.URL.Path) + + var request authZENBatchEvaluationRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + require.Len(t, request.Evaluations, 2) + require.Equal(t, "read", request.Evaluations[0].Action.Name) + require.Equal(t, "cancel", request.Evaluations[1].Action.Name) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"evaluations":[{"decision":true},{"decision":false}]}`)) + })) + defer server.Close() + + pdp, err := newAuthZENPDP(authZENPDPConfig(server), server.Client()) + require.NoError(t, err) + + response, err := pdp.EvaluateAccessBatch(context.Background(), AccessEvaluationsRequest{ + Evaluations: []AccessEvaluationRequest{ + {Subject: Subject{ID: "user-1"}, ResourceServer: ResourceServer{ID: "resource-1"}, + Permission: Permission{Name: "read"}}, + {Subject: Subject{ID: "user-1"}, ResourceServer: ResourceServer{ID: "resource-1"}, + Permission: Permission{Name: "cancel"}}, + }, + }) + require.NoError(t, err) + require.Len(t, response.Evaluations, 2) + require.True(t, response.Evaluations[0].Decision) + require.False(t, response.Evaluations[1].Decision) +} + +func TestAuthZENPDPEvaluateAccessBatchEmpty(t *testing.T) { + pdp, err := newAuthZENPDP(AuthZENPDPConfig{ + Endpoint: "http://localhost:9000/access/v1/evaluation", + BatchEndpoint: "http://localhost:9000/access/v1/evaluations", + }, http.DefaultClient) + require.NoError(t, err) + + response, err := pdp.EvaluateAccessBatch(context.Background(), AccessEvaluationsRequest{}) + require.NoError(t, err) + require.Empty(t, response.Evaluations) +} + +func TestAuthZENPDPEvaluateAccessBatchRejectsMismatchedResponse(t *testing.T) { + server := newAuthZENTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"evaluations":[]}`)) + })) + defer server.Close() + + pdp, err := newAuthZENPDP(authZENPDPConfig(server), server.Client()) + require.NoError(t, err) + + _, err = pdp.EvaluateAccessBatch(context.Background(), AccessEvaluationsRequest{ + Evaluations: []AccessEvaluationRequest{{ + Subject: Subject{ID: "user-1"}, + ResourceServer: ResourceServer{ID: "resource-1"}, + Permission: Permission{Name: "read"}, + }}, + }) + require.EqualError(t, err, "AuthZEN PDP returned 0 evaluations for 1 requests") +} + +func TestAuthZENPDPEvaluateAccessBatchUsesConfiguredBatchEndpoint(t *testing.T) { + server := newAuthZENTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/authzen/custom-batch", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + var request authZENBatchEvaluationRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + require.Len(t, request.Evaluations, 2) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"evaluations":[{"decision":true},{"decision":false}]}`)) + })) + defer server.Close() + + pdp, err := newAuthZENPDP(AuthZENPDPConfig{ + Endpoint: evaluationEndpoint(server), + BatchEndpoint: server.URL + "/authzen/custom-batch", + }, server.Client()) + require.NoError(t, err) + + response, err := pdp.EvaluateAccessBatch(context.Background(), AccessEvaluationsRequest{ + Evaluations: []AccessEvaluationRequest{ + {Subject: Subject{ID: "user-1"}, ResourceServer: ResourceServer{ID: "resource-1"}, + Permission: Permission{Name: "read"}}, + {Subject: Subject{ID: "user-1"}, ResourceServer: ResourceServer{ID: "resource-1"}, + Permission: Permission{Name: "cancel"}}, + }, + }) + + require.NoError(t, err) + require.Len(t, response.Evaluations, 2) + require.True(t, response.Evaluations[0].Decision) + require.False(t, response.Evaluations[1].Decision) +} + +func TestAuthZENEvaluationAuditFieldsMaskOptionalValues(t *testing.T) { + request := authZENEvaluationRequest{ + Subject: authZENSubject{ + Type: "user", ID: "user-123", + Properties: map[string]interface{}{"email": "user@example.com"}, + }, + Resource: authZENResource{ + Type: "booking", ID: "booking-123", + Properties: map[string]interface{}{"owner": "user-123"}, + }, + Action: authZENAction{Name: "read", Properties: map[string]interface{}{"source": "api"}}, + Context: map[string]interface{}{"ip": "127.0.0.1"}, + } + + fields := authZENEvaluationLogFields(request) + require.Len(t, fields, 9) + require.Equal(t, "subject_id", fields[0].Key) + require.NotEqual(t, request.Subject.ID, fields[0].Value) + require.Equal(t, "subject_type", fields[1].Key) + require.Equal(t, request.Subject.Type, fields[1].Value) +} + +func TestAuditAuthZENEvaluationsMasksSensitiveValues(t *testing.T) { + evaluations := auditAuthZENEvaluations([]authZENBatchEvaluation{{ + Subject: authZENSubject{ + Type: "user", ID: "user-123", + Properties: map[string]interface{}{"email": "user@example.com"}, + }, + Resource: authZENResource{ + Type: "booking", ID: "booking-123", + Properties: map[string]interface{}{"owner": "user-123"}, + }, + Action: authZENAction{Name: "read", Properties: map[string]interface{}{"source": "api"}}, + Context: map[string]interface{}{"ip": "127.0.0.1"}, + }}) + + require.Len(t, evaluations, 1) + require.Equal(t, "user", evaluations[0]["subject"].(map[string]interface{})["type"]) + require.NotEqual(t, "user-123", evaluations[0]["subject"].(map[string]interface{})["id"]) + subjectProperties := evaluations[0]["subject"].(map[string]interface{})["properties"].(map[string]interface{}) + require.NotEqual(t, "user@example.com", subjectProperties["email"]) +} + +func TestNormalizeSubjectAttributeMappings(t *testing.T) { + result := normalizeSubjectAttributeMappings([]SubjectAttributeMapping{ + {UserType: " Customer ", Attributes: []SubjectAttributeRow{ + {Attribute: " email ", PDPAttribute: " mail "}, + {Attribute: " groups "}, + {Attribute: " "}, + }}, + {UserType: "Empty", Attributes: []SubjectAttributeRow{{Attribute: " "}}}, + }) + + require.Equal(t, []SubjectAttributeMapping{{ + UserType: "Customer", + Attributes: []SubjectAttributeRow{ + {Attribute: "email", PDPAttribute: "mail"}, + {Attribute: "groups", PDPAttribute: "groups"}, + }, + }}, result) +} + +func TestAuthZENPDPRequestRetries(t *testing.T) { + var attemptsMu sync.Mutex + attempts := 0 + server := newAuthZENTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/access/v1/evaluation", r.URL.Path) + attemptsMu.Lock() + attempts++ + currentAttempt := attempts + attemptsMu.Unlock() + if currentAttempt < 3 { + http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"decision":true}`)) + })) + defer server.Close() + + pdp, err := newAuthZENPDP(AuthZENPDPConfig{ + Endpoint: evaluationEndpoint(server), + BatchEndpoint: batchEndpoint(server), + RetryCount: 2, + }, server.Client()) + require.NoError(t, err) + + response, err := pdp.EvaluateAccess(context.Background(), AccessEvaluationRequest{ + Subject: Subject{ID: "user-1"}, + ResourceServer: ResourceServer{ID: "resource-1"}, + Permission: Permission{Name: "read"}, + }) + require.NoError(t, err) + require.True(t, response.Decision) + attemptsMu.Lock() + totalAttempts := attempts + attemptsMu.Unlock() + require.Equal(t, 3, totalAttempts) +} + +func TestAuthZENPDPRejectsPDPError(t *testing.T) { + server := newAuthZENTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/access/v1/evaluation", r.URL.Path) + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer server.Close() + + engine, err := newAuthZENPDP(authZENPDPConfig(server), server.Client()) + require.NoError(t, err) + + _, err = engine.EvaluateAccess(context.Background(), AccessEvaluationRequest{}) + require.ErrorContains(t, err, "HTTP 503") +} + +func TestNewAuthZENPDPRejectsInvalidEndpoint(t *testing.T) { + _, err := newAuthZENPDP(AuthZENPDPConfig{Endpoint: "not-a-url"}, http.DefaultClient) + require.Error(t, err) +} + +func TestNewAuthZENPDPAppliesDefaultTimeout(t *testing.T) { + authorizationEngine, err := newAuthZENPDP(AuthZENPDPConfig{ + Endpoint: "http://localhost:9000/access/v1/evaluation", + BatchEndpoint: "http://localhost:9000/access/v1/evaluations", + }, http.DefaultClient) + require.NoError(t, err) + + pdp, ok := authorizationEngine.(*authZENPDP) + require.True(t, ok) + require.Equal(t, time.Duration(config.GetServerRuntime().Config.AuthZENPDP.TimeoutMS)*time.Millisecond, pdp.timeout) +} + +func TestNewAuthZENPDPRequiresHTTPClient(t *testing.T) { + _, err := newAuthZENPDP(AuthZENPDPConfig{ + Endpoint: "http://localhost:9000/access/v1/evaluation", + BatchEndpoint: "http://localhost:9000/access/v1/evaluations", + }, nil) + require.EqualError(t, err, "HTTP client is required") +} + +func TestNewAuthZENPDPRejectsInvalidBatchEndpoint(t *testing.T) { + _, err := newAuthZENPDP(AuthZENPDPConfig{ + Endpoint: "http://localhost:9000/access/v1/evaluation", + BatchEndpoint: "not-a-url", + }, http.DefaultClient) + require.EqualError(t, err, "invalid AuthZEN access evaluations endpoint: endpoint must be an absolute URL") +} + +func TestNewAuthZENPDPRequiresBatchEndpoint(t *testing.T) { + _, err := newAuthZENPDP(AuthZENPDPConfig{ + Endpoint: "http://localhost:9000/access/v1/evaluation", + }, http.DefaultClient) + require.EqualError(t, err, "AuthZEN access evaluations endpoint is required") +} + +func TestNewAuthZENPDPRejectsMappingForDisallowedProperty(t *testing.T) { + _, err := newAuthZENPDP(AuthZENPDPConfig{ + Endpoint: "http://localhost:9000/access/v1/evaluation", + BatchEndpoint: "http://localhost:9000/access/v1/evaluations", + SubjectPropertyMappings: map[string]string{"department": "department_name"}, + }, http.DefaultClient) + require.EqualError(t, err, "subject property \"department\" has a mapping but is not allowed") +} + +func TestToAuthZENEvaluationRequestPreservesProxyResource(t *testing.T) { + request := toAuthZENEvaluationRequest(AccessEvaluationRequest{ + Subject: Subject{Type: "user", ID: "user-1"}, + ResourceServer: ResourceServer{ + ID: "resource-server-1", + Identifier: "travel-booking-api", + ResourceID: "booking-123", + Properties: map[string]interface{}{"status": "confirmed"}, + }, + Permission: Permission{Name: "booking:cancel"}, + }, nil, nil, nil) + + require.Equal(t, "travel-booking-api", request.Resource.Type) + require.Equal(t, "booking-123", request.Resource.ID) + require.Equal(t, "confirmed", request.Resource.Properties["status"]) +} + +func TestToAuthZENEvaluationRequestUsesResourceServerIDWhenResourceFieldsAreMissing(t *testing.T) { + request := toAuthZENEvaluationRequest(AccessEvaluationRequest{ + Subject: Subject{Type: "user", ID: "user-1"}, + ResourceServer: ResourceServer{ID: "resource-server-1"}, + Permission: Permission{Name: "read"}, + }, nil, nil, nil) + + require.Equal(t, "resource-server-1", request.Resource.Type) + require.Equal(t, "resource-server-1", request.Resource.ID) +} + +func evaluationEndpoint(server *httptest.Server) string { + return server.URL + "/access/v1/evaluation" +} + +func batchEndpoint(server *httptest.Server) string { + return server.URL + "/access/v1/evaluations" +} + +func authZENPDPConfig(server *httptest.Server) AuthZENPDPConfig { + return AuthZENPDPConfig{ + Endpoint: evaluationEndpoint(server), + BatchEndpoint: batchEndpoint(server), + } +} + +func newAuthZENTestServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + listener, err := net.Listen("tcp4", "127.0.0.1:0") + require.NoError(t, err) + + server := httptest.NewUnstartedServer(handler) + server.Listener = listener + server.Start() + t.Cleanup(server.Close) + return server +} diff --git a/backend/internal/authz/error_constants.go b/backend/internal/authz/error_constants.go new file mode 100644 index 0000000000..13a7ac529e --- /dev/null +++ b/backend/internal/authz/error_constants.go @@ -0,0 +1,22 @@ +// Copyright 2025-2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package authz + +import tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + +var ( + // ErrorInvalidAuthorizationRequest indicates that an authorization engine rejected the request shape. + ErrorInvalidAuthorizationRequest = tidcommon.ServiceError{ + Type: tidcommon.ClientErrorType, + Code: "AUTHZ-1001", + Error: tidcommon.I18nMessage{ + Key: "error.authorization.invalid_request", + DefaultValue: "Invalid authorization request", + }, + ErrorDescription: tidcommon.I18nMessage{ + Key: "error.authorization.invalid_request_description", + DefaultValue: "The authorization request is missing required policy evaluation data", + }, + } +) diff --git a/backend/internal/authz/init.go b/backend/internal/authz/init.go index 72ca6f2722..82a0bcb294 100644 --- a/backend/internal/authz/init.go +++ b/backend/internal/authz/init.go @@ -5,12 +5,19 @@ package authz import ( "github.com/thunder-id/thunderid/internal/authz/engine" + "github.com/thunder-id/thunderid/internal/resource" "github.com/thunder-id/thunderid/internal/role" + httpservice "github.com/thunder-id/thunderid/internal/system/http" + userpkg "github.com/thunder-id/thunderid/internal/user" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ) -// Initialize creates and initializes the authorization service with the RBAC engine. -func Initialize(roleService role.RoleServiceInterface) providers.AuthorizationProvider { - rbacEngine := engine.NewRBACEngine(roleService) - return newAuthorizationService(rbacEngine) +// Initialize creates and initializes the authorization service. +func Initialize( + roleService role.RoleServiceInterface, + resourceService resource.ResourceServiceInterface, + userService userpkg.UserServiceInterface, +) providers.AuthorizationProvider { + return newAuthorizationService( + engine.NewRBACEngine(roleService), resourceService, userService, httpservice.NewHTTPClientWithTimeout(0)) } diff --git a/backend/internal/authz/service.go b/backend/internal/authz/service.go index 63b8b0bead..b5eace20de 100644 --- a/backend/internal/authz/service.go +++ b/backend/internal/authz/service.go @@ -6,25 +6,42 @@ package authz import ( "context" + "encoding/json" + "fmt" + "strings" tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" "github.com/thunder-id/thunderid/internal/authz/engine" + "github.com/thunder-id/thunderid/internal/resource" + httpservice "github.com/thunder-id/thunderid/internal/system/http" "github.com/thunder-id/thunderid/internal/system/log" + userpkg "github.com/thunder-id/thunderid/internal/user" ) const loggerComponentName = "AuthorizationService" // authorizationService is the default implementation of providers.AuthorizationProvider. type authorizationService struct { - engine engine.AuthorizationEngine + engine engine.AuthorizationEngine + userService userpkg.UserServiceInterface + resourceService resource.ResourceServiceInterface + httpClient httpservice.HTTPClientInterface } // newAuthorizationService creates a new instance of authorizationService. -func newAuthorizationService(engine engine.AuthorizationEngine) providers.AuthorizationProvider { +func newAuthorizationService( + engine engine.AuthorizationEngine, + resourceService resource.ResourceServiceInterface, + userService userpkg.UserServiceInterface, + httpClient httpservice.HTTPClientInterface, +) providers.AuthorizationProvider { return &authorizationService{ - engine: engine, + engine: engine, + userService: userService, + resourceService: resourceService, + httpClient: httpClient, } } @@ -59,9 +76,12 @@ func (s *authorizationService) EvaluateAccessBatch( Evaluations: []providers.AccessEvaluationResponse{}, }, nil } + enrichedRequest, svcErr := s.enrichRequest(ctx, request) + if svcErr != nil { + return nil, svcErr + } - // Delegate to engine (engine/underlying service handles validation) - evaluationResp, err := s.engine.EvaluateAccessBatch(ctx, toEngineAccessEvaluationsRequest(request)) + evaluationResp, err := s.evaluateWithResolvedEngines(ctx, toEngineAccessEvaluationsRequest(enrichedRequest)) if err != nil { logger.Error(ctx, "Authorization evaluation failed", log.Int("evaluationCount", len(request.Evaluations)), @@ -75,6 +95,192 @@ func (s *authorizationService) EvaluateAccessBatch( return fromEngineAccessEvaluationsResponse(evaluationResp), nil } +// evaluateWithResolvedEngines routes evaluations to external or default engines and preserves order. +func (s *authorizationService) evaluateWithResolvedEngines( + ctx context.Context, + request engine.AccessEvaluationsRequest, +) (*engine.AccessEvaluationsResponse, error) { + if s.resourceService == nil { + return s.engine.EvaluateAccessBatch(ctx, request) + } + responses := make([]engine.AccessEvaluationResponse, len(request.Evaluations)) + defaultRequest := engine.AccessEvaluationsRequest{} + defaultIndexes := make([]int, 0, len(request.Evaluations)) + externalRequests := map[engine.AuthorizationEngine]engine.AccessEvaluationsRequest{} + externalIndexes := map[engine.AuthorizationEngine][]int{} + resolvedExternalEngines := map[string]engine.AuthorizationEngine{} + resolvedExternalIdentifiers := map[string]string{} + resolvedRouteKeys := map[string]bool{} + + for index, evaluation := range request.Evaluations { + resourceServerKey := resourceServerRouteKey(evaluation.ResourceServer) + externalEngine := resolvedExternalEngines[resourceServerKey] + resourceServerIdentifier := resolvedExternalIdentifiers[resourceServerKey] + ok := externalEngine != nil + if !resolvedRouteKeys[resourceServerKey] { + var err error + externalEngine, resourceServerIdentifier, ok, err = + s.resolveEngine(ctx, evaluation.ResourceServer) + if err != nil { + return nil, err + } + resolvedRouteKeys[resourceServerKey] = true + if ok { + resolvedExternalEngines[resourceServerKey] = externalEngine + resolvedExternalIdentifiers[resourceServerKey] = resourceServerIdentifier + } + } + if !ok { + defaultRequest.Evaluations = append(defaultRequest.Evaluations, evaluation) + defaultIndexes = append(defaultIndexes, index) + continue + } + evaluation.ResourceServer.Identifier = resourceServerIdentifier + externalRequest := externalRequests[externalEngine] + externalRequest.Evaluations = append(externalRequest.Evaluations, evaluation) + externalRequests[externalEngine] = externalRequest + externalIndexes[externalEngine] = append(externalIndexes[externalEngine], index) + } + + for externalEngine, externalRequest := range externalRequests { + indexes := externalIndexes[externalEngine] + if err := evaluateResolvedBatch(ctx, externalEngine, externalRequest, indexes, responses); err != nil { + return nil, err + } + } + if err := evaluateResolvedBatch(ctx, s.engine, defaultRequest, defaultIndexes, responses); err != nil { + return nil, err + } + + return &engine.AccessEvaluationsResponse{Evaluations: responses}, nil +} + +// evaluateResolvedBatch evaluates a group of requests and places responses at their original indexes. +func evaluateResolvedBatch( + ctx context.Context, + authorizationEngine engine.AuthorizationEngine, + request engine.AccessEvaluationsRequest, + indexes []int, + responses []engine.AccessEvaluationResponse, +) error { + if len(request.Evaluations) == 0 || authorizationEngine == nil { + return nil + } + engineResponse, err := authorizationEngine.EvaluateAccessBatch(ctx, request) + if err != nil { + return err + } + for index, evaluation := range engineResponse.Evaluations { + if index < len(indexes) { + responses[indexes[index]] = evaluation + } + } + return nil +} + +// resolveEngine resolves the authorization engine configured for a resource server. +func (s *authorizationService) resolveEngine( + ctx context.Context, + resourceServer engine.ResourceServer, +) (engine.AuthorizationEngine, string, bool, error) { + resourceServerKey := resourceServerRouteKey(resourceServer) + if resourceServerKey == "" { + return nil, "", false, nil + } + return s.resolveConnectionExternalEngine(ctx, resourceServer) +} + +// resolveConnectionExternalEngine creates an external AuthZEN engine from a resource server connection. +func (s *authorizationService) resolveConnectionExternalEngine( + ctx context.Context, + requestResourceServer engine.ResourceServer, +) (engine.AuthorizationEngine, string, bool, error) { + if s.resourceService == nil { + return nil, "", false, nil + } + resourceServer, svcErr := s.getResourceServer(ctx, requestResourceServer) + if svcErr != nil { + if svcErr.Code == resource.ErrorResourceServerNotFound.Code { + return nil, "", false, nil + } + return nil, "", false, + fmt.Errorf("failed to resolve resource server: %s", svcErr.Error.DefaultValue) + } + if resourceServer == nil || + resourceServer.AuthorizationEngine.Type != providers.AuthorizationEngineTypeExternalAuthZENPDP { + return nil, "", false, nil + } + connectionID := strings.TrimSpace(resourceServer.AuthorizationEngine.Properties.PDPConnectionID) + if connectionID == "" { + return nil, "", false, nil + } + externalEngine, ok, err := engine.NewAuthZENPDP(ctx, connectionID, s.httpClient) + return externalEngine, strings.TrimSpace(resourceServer.Identifier), ok, err +} + +// resourceServerRouteKey returns the external routing key for a resource server. +func resourceServerRouteKey(resourceServer engine.ResourceServer) string { + return strings.TrimSpace(resourceServer.ID) +} + +// getResourceServer retrieves the persisted resource server used for engine resolution. +func (s *authorizationService) getResourceServer( + ctx context.Context, + resourceServer engine.ResourceServer, +) (*providers.ResourceServer, *tidcommon.ServiceError) { + return s.resourceService.GetResourceServer(ctx, strings.TrimSpace(resourceServer.ID)) +} + +// enrichRequest adds persisted user attributes to user subject evaluations. +func (s *authorizationService) enrichRequest( + ctx context.Context, + request providers.AccessEvaluationsRequest, +) (providers.AccessEvaluationsRequest, *tidcommon.ServiceError) { + if s.userService == nil { + return request, nil + } + enriched := providers.AccessEvaluationsRequest{ + Evaluations: make([]providers.AccessEvaluationRequest, 0, len(request.Evaluations)), + } + users := make(map[string]*providers.User) + for _, evaluation := range request.Evaluations { + if evaluation.Subject.Type != providers.EntityCategoryUser.String() { + enriched.Evaluations = append(enriched.Evaluations, evaluation) + continue + } + user, ok := users[evaluation.Subject.ID] + if !ok { + var svcErr *tidcommon.ServiceError + user, svcErr = s.userService.GetUser(ctx, evaluation.Subject.ID, false) + if svcErr != nil { + if svcErr.Code != userpkg.ErrorUserNotFound.Code { + return providers.AccessEvaluationsRequest{}, &tidcommon.InternalServerError + } + user = nil + } + users[evaluation.Subject.ID] = user + } + if user != nil { + properties := map[string]interface{}{} + if len(user.Attributes) > 0 { + if err := json.Unmarshal(user.Attributes, &properties); err != nil { + return providers.AccessEvaluationsRequest{}, &tidcommon.InternalServerError + } + } + if user.OUID != "" { + properties["ouId"] = user.OUID + } + for key, value := range evaluation.Subject.Properties { + properties[key] = value + } + evaluation.Subject.Properties = properties + } + enriched.Evaluations = append(enriched.Evaluations, evaluation) + } + return enriched, nil +} + +// toEngineAccessEvaluationsRequest converts provider evaluations to engine evaluations. func toEngineAccessEvaluationsRequest(request providers.AccessEvaluationsRequest) engine.AccessEvaluationsRequest { evaluations := make([]engine.AccessEvaluationRequest, 0, len(request.Evaluations)) for _, evaluation := range request.Evaluations { @@ -87,6 +293,7 @@ func toEngineAccessEvaluationsRequest(request providers.AccessEvaluationsRequest }, ResourceServer: engine.ResourceServer{ ID: evaluation.ResourceServer.ID, + ResourceID: evaluation.ResourceServer.ResourceID, Properties: evaluation.ResourceServer.Properties, }, Permission: engine.Permission{ @@ -99,6 +306,7 @@ func toEngineAccessEvaluationsRequest(request providers.AccessEvaluationsRequest return engine.AccessEvaluationsRequest{Evaluations: evaluations} } +// fromEngineAccessEvaluationsResponse converts engine responses to provider responses. func fromEngineAccessEvaluationsResponse( response *engine.AccessEvaluationsResponse) *providers.AccessEvaluationsResponse { if response == nil { diff --git a/backend/internal/authz/service_test.go b/backend/internal/authz/service_test.go index 1436a2012b..36f0030550 100644 --- a/backend/internal/authz/service_test.go +++ b/backend/internal/authz/service_test.go @@ -5,17 +5,26 @@ package authz import ( "context" + "encoding/json" "errors" + "net/http" + "net/http/httptest" "testing" tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/thunder-id/thunderid/internal/authz/engine" + "github.com/thunder-id/thunderid/internal/connection/authzenpdp" + "github.com/thunder-id/thunderid/internal/resource" + userpkg "github.com/thunder-id/thunderid/internal/user" enginemock "github.com/thunder-id/thunderid/tests/mocks/authz/engine" + "github.com/thunder-id/thunderid/tests/mocks/resourcemock" + "github.com/thunder-id/thunderid/tests/mocks/usermock" ) type AuthorizationServiceTestSuite struct { @@ -28,9 +37,14 @@ func TestAuthorizationServiceTestSuite(t *testing.T) { suite.Run(t, new(AuthorizationServiceTestSuite)) } +func TestResourceServerRouteKey(t *testing.T) { + require.Equal(t, "internal-id", resourceServerRouteKey(engine.ResourceServer{ID: " internal-id "})) + require.Empty(t, resourceServerRouteKey(engine.ResourceServer{})) +} + func (suite *AuthorizationServiceTestSuite) SetupTest() { suite.mockEngine = enginemock.NewAuthorizationEngineMock(suite.T()) - suite.service = newAuthorizationService(suite.mockEngine) + suite.service = newAuthorizationService(suite.mockEngine, nil, nil, nil) } func (suite *AuthorizationServiceTestSuite) TestEvaluateAccessSuccess() { @@ -207,3 +221,353 @@ func (suite *AuthorizationServiceTestSuite) TestEvaluateAccessEmptyEngineRespons suite.NotNil(response) suite.False(response.Decision) } + +func TestAuthorizationServiceEnrichesUserPropertiesAndCachesUsers(t *testing.T) { + userService := usermock.NewUserServiceInterfaceMock(t) + userService.EXPECT().GetUser(mock.Anything, "user1", false).Return(&providers.User{ + OUID: "ou-1", + Attributes: json.RawMessage(`{"email":"alice@example.com"}`), + }, (*tidcommon.ServiceError)(nil)).Once() + + service := &authorizationService{userService: userService} + request := providers.AccessEvaluationsRequest{Evaluations: []providers.AccessEvaluationRequest{ + { + Subject: providers.Subject{ + Type: providers.EntityCategoryUser.String(), + ID: "user1", + Properties: map[string]interface{}{"source": "request"}, + }, + }, + {Subject: providers.Subject{Type: providers.EntityCategoryUser.String(), ID: "user1"}}, + {Subject: providers.Subject{Type: "agent", ID: "agent1"}}, + }} + + enriched, svcErr := service.enrichRequest(context.Background(), request) + require.Nil(t, svcErr) + require.Equal(t, map[string]interface{}{ + "email": "alice@example.com", + "ouId": "ou-1", + "source": "request", + }, enriched.Evaluations[0].Subject.Properties) + require.Equal(t, map[string]interface{}{"email": "alice@example.com", "ouId": "ou-1"}, + enriched.Evaluations[1].Subject.Properties) + require.Equal(t, request.Evaluations[2].Subject, enriched.Evaluations[2].Subject) +} + +func TestAuthorizationServiceEnrichRequestRejectsInvalidUserAttributes(t *testing.T) { + userService := usermock.NewUserServiceInterfaceMock(t) + userService.EXPECT().GetUser(mock.Anything, "user1", false).Return(&providers.User{ + Attributes: json.RawMessage(`{"email":`), + }, (*tidcommon.ServiceError)(nil)) + + service := &authorizationService{userService: userService} + _, svcErr := service.enrichRequest(context.Background(), providers.AccessEvaluationsRequest{ + Evaluations: []providers.AccessEvaluationRequest{{ + Subject: providers.Subject{Type: providers.EntityCategoryUser.String(), ID: "user1"}, + }}, + }) + + require.Equal(t, tidcommon.InternalServerError.Code, svcErr.Code) +} + +func TestAuthorizationServiceEnrichRequestMapsUserLookupError(t *testing.T) { + userService := usermock.NewUserServiceInterfaceMock(t) + userService.EXPECT().GetUser(mock.Anything, "user1", false).Return((*providers.User)(nil), &tidcommon.ServiceError{ + Code: "USR-1000", + }) + + service := &authorizationService{userService: userService} + _, svcErr := service.enrichRequest(context.Background(), providers.AccessEvaluationsRequest{ + Evaluations: []providers.AccessEvaluationRequest{{ + Subject: providers.Subject{Type: providers.EntityCategoryUser.String(), ID: "user1"}, + }}, + }) + + require.Equal(t, tidcommon.InternalServerError.Code, svcErr.Code) +} + +func TestAuthorizationServiceEnrichRequestContinuesForUnknownUser(t *testing.T) { + userService := usermock.NewUserServiceInterfaceMock(t) + userService.EXPECT().GetUser(mock.Anything, "unknown", false). + Return((*providers.User)(nil), &userpkg.ErrorUserNotFound).Once() + userService.EXPECT().GetUser(mock.Anything, "user1", false).Return(&providers.User{ + OUID: "ou-1", + Attributes: json.RawMessage(`{"email":"alice@example.com"}`), + }, (*tidcommon.ServiceError)(nil)).Once() + + service := &authorizationService{userService: userService} + request := providers.AccessEvaluationsRequest{Evaluations: []providers.AccessEvaluationRequest{ + {Subject: providers.Subject{Type: providers.EntityCategoryUser.String(), ID: "unknown"}}, + {Subject: providers.Subject{Type: providers.EntityCategoryUser.String(), ID: "user1"}}, + }} + + enriched, svcErr := service.enrichRequest(context.Background(), request) + require.Nil(t, svcErr) + require.Len(t, enriched.Evaluations, 2) + require.Equal(t, request.Evaluations[0].Subject, enriched.Evaluations[0].Subject) + require.Equal(t, map[string]interface{}{ + "email": "alice@example.com", + "ouId": "ou-1", + }, enriched.Evaluations[1].Subject.Properties) +} + +func TestAuthorizationServiceResolveEngineUsesResourceServerConnection(t *testing.T) { + ctx := context.Background() + oldGetter := authzenpdp.GetAuthZENPDPRuntimeConfig + t.Cleanup(func() { authzenpdp.GetAuthZENPDPRuntimeConfig = oldGetter }) + authzenpdp.GetAuthZENPDPRuntimeConfig = func( + _ context.Context, id string, + ) (*authzenpdp.AuthZENPDPRuntimeConfig, error) { + require.Equal(t, "pdp-1", id) + return &authzenpdp.AuthZENPDPRuntimeConfig{ + TimeoutMS: 1000, + ID: "pdp-1", + Endpoint: "http://localhost:9000/access/v1/evaluation", + BatchEndpoint: "http://localhost:9000/access/v1/evaluations", + }, nil + } + resourceService := resourcemock.NewResourceServiceInterfaceMock(t) + resourceService.EXPECT().GetResourceServer(mock.Anything, "rs-1"). + Return(&providers.ResourceServer{ + ID: "rs-1", + Identifier: "https://api.example.com", + AuthorizationEngine: providers.AuthorizationEngineConfig{ + Type: providers.AuthorizationEngineTypeExternalAuthZENPDP, + Properties: providers.AuthorizationEngineProperties{ + PDPConnectionID: " pdp-1 ", + }, + }, + }, (*tidcommon.ServiceError)(nil)) + service := &authorizationService{ + resourceService: resourceService, + httpClient: http.DefaultClient, + } + + externalEngine, identifier, ok, err := service.resolveEngine(ctx, engine.ResourceServer{ID: "rs-1"}) + + require.NoError(t, err) + require.Equal(t, "https://api.example.com", identifier) + require.True(t, ok) + require.NotNil(t, externalEngine) +} + +func TestAuthorizationServiceResolveEngineFallsBackForMissingResourceServer(t *testing.T) { + resourceService := resourcemock.NewResourceServiceInterfaceMock(t) + resourceService.EXPECT().GetResourceServer(mock.Anything, "rs-1"). + Return(nil, &resource.ErrorResourceServerNotFound) + service := &authorizationService{ + resourceService: resourceService, + } + + externalEngine, identifier, ok, err := service.resolveEngine(context.Background(), engine.ResourceServer{ + ID: "rs-1", + }) + + require.NoError(t, err) + require.Empty(t, identifier) + require.False(t, ok) + require.Nil(t, externalEngine) +} + +func TestAuthorizationServiceResolveEngineFallsBackForMissingPDPConnection(t *testing.T) { + oldGetter := authzenpdp.GetAuthZENPDPRuntimeConfig + t.Cleanup(func() { authzenpdp.GetAuthZENPDPRuntimeConfig = oldGetter }) + authzenpdp.GetAuthZENPDPRuntimeConfig = func( + _ context.Context, _ string, + ) (*authzenpdp.AuthZENPDPRuntimeConfig, error) { + return nil, nil + } + resourceService := resourcemock.NewResourceServiceInterfaceMock(t) + resourceService.EXPECT().GetResourceServer(mock.Anything, "rs-1").Return(&providers.ResourceServer{ + ID: "rs-1", + AuthorizationEngine: providers.AuthorizationEngineConfig{ + Type: providers.AuthorizationEngineTypeExternalAuthZENPDP, + Properties: providers.AuthorizationEngineProperties{ + PDPConnectionID: "pdp-1", + }, + }, + }, (*tidcommon.ServiceError)(nil)) + service := &authorizationService{ + resourceService: resourceService, + } + + externalEngine, identifier, ok, err := service.resolveEngine(context.Background(), engine.ResourceServer{ + ID: "rs-1", + }) + + require.NoError(t, err) + require.Empty(t, identifier) + require.False(t, ok) + require.Nil(t, externalEngine) +} + +func TestAuthorizationServiceEvaluateAccessBatchRoutesExternalPDPByResourceServerID(t *testing.T) { + ctx := context.Background() + pdpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/access/v1/evaluations": + var request struct { + Evaluations []struct { + Resource struct { + Type string `json:"type"` + ID string `json:"id"` + } `json:"resource"` + } `json:"evaluations"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + require.Len(t, request.Evaluations, 1) + require.Equal(t, "https://api.example.com", request.Evaluations[0].Resource.Type) + require.Equal(t, "booking-1", request.Evaluations[0].Resource.ID) + _, _ = w.Write([]byte(`{"evaluations":[{"decision":true}]}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(pdpServer.Close) + oldGetter := authzenpdp.GetAuthZENPDPRuntimeConfig + t.Cleanup(func() { authzenpdp.GetAuthZENPDPRuntimeConfig = oldGetter }) + authzenpdp.GetAuthZENPDPRuntimeConfig = func( + _ context.Context, id string, + ) (*authzenpdp.AuthZENPDPRuntimeConfig, error) { + require.Equal(t, "pdp-1", id) + return &authzenpdp.AuthZENPDPRuntimeConfig{ + TimeoutMS: 1000, + ID: "pdp-1", + Endpoint: pdpServer.URL + "/access/v1/evaluation", + BatchEndpoint: pdpServer.URL + "/access/v1/evaluations", + }, nil + } + resourceService := resourcemock.NewResourceServiceInterfaceMock(t) + resourceService.EXPECT().GetResourceServer(mock.Anything, "rs-1"). + Return(&providers.ResourceServer{ + ID: "rs-1", + Identifier: "https://api.example.com", + AuthorizationEngine: providers.AuthorizationEngineConfig{ + Type: providers.AuthorizationEngineTypeExternalAuthZENPDP, + Properties: providers.AuthorizationEngineProperties{ + PDPConnectionID: "pdp-1", + }, + }, + }, (*tidcommon.ServiceError)(nil)) + defaultEngine := &authorizationTestEngine{decision: false} + service := &authorizationService{ + engine: defaultEngine, + resourceService: resourceService, + httpClient: pdpServer.Client(), + } + + response, err := service.evaluateWithResolvedEngines(ctx, engine.AccessEvaluationsRequest{ + Evaluations: []engine.AccessEvaluationRequest{{ + Subject: engine.Subject{ID: "user1"}, + ResourceServer: engine.ResourceServer{ID: "rs-1", ResourceID: "booking-1"}, + Permission: engine.Permission{Name: "read"}, + }}, + }) + + require.NoError(t, err) + require.Len(t, response.Evaluations, 1) + require.True(t, response.Evaluations[0].Decision) +} + +func TestAuthorizationServiceEvaluateAccessBatchResolvesEnginesAndPreservesOrder(t *testing.T) { + ctx := context.Background() + pdpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/access/v1/evaluations", r.URL.Path) + _, _ = w.Write([]byte(`{"evaluations":[{"decision":true},{"decision":false}]}`)) + })) + t.Cleanup(pdpServer.Close) + oldGetter := authzenpdp.GetAuthZENPDPRuntimeConfig + t.Cleanup(func() { authzenpdp.GetAuthZENPDPRuntimeConfig = oldGetter }) + authzenpdp.GetAuthZENPDPRuntimeConfig = func( + _ context.Context, id string, + ) (*authzenpdp.AuthZENPDPRuntimeConfig, error) { + require.Equal(t, "pdp-1", id) + return &authzenpdp.AuthZENPDPRuntimeConfig{ + TimeoutMS: 1000, + ID: "pdp-1", + Endpoint: pdpServer.URL + "/access/v1/evaluation", + BatchEndpoint: pdpServer.URL + "/access/v1/evaluations", + }, nil + } + resourceService := resourcemock.NewResourceServiceInterfaceMock(t) + resourceService.EXPECT().GetResourceServer(mock.Anything, "local-rs"). + Return(&providers.ResourceServer{ + ID: "local-rs", + AuthorizationEngine: providers.AuthorizationEngineConfig{ + Type: "rbac", + }, + }, (*tidcommon.ServiceError)(nil)). + Once() + resourceService.EXPECT().GetResourceServer(mock.Anything, "rs-1"). + Return(&providers.ResourceServer{ + ID: "rs-1", + Identifier: "https://api.example.com", + AuthorizationEngine: providers.AuthorizationEngineConfig{ + Type: providers.AuthorizationEngineTypeExternalAuthZENPDP, + Properties: providers.AuthorizationEngineProperties{ + PDPConnectionID: "pdp-1", + }, + }, + }, (*tidcommon.ServiceError)(nil)). + Once() + defaultEngine := &authorizationTestEngine{decision: false} + service := &authorizationService{ + engine: defaultEngine, + resourceService: resourceService, + httpClient: pdpServer.Client(), + } + + response, err := service.evaluateWithResolvedEngines(ctx, engine.AccessEvaluationsRequest{ + Evaluations: []engine.AccessEvaluationRequest{ + { + ResourceServer: engine.ResourceServer{ID: "local-rs"}, + Permission: engine.Permission{Name: "read"}, + }, + { + ResourceServer: engine.ResourceServer{ID: "rs-1", ResourceID: "booking-1"}, + Permission: engine.Permission{Name: "read"}, + }, + { + ResourceServer: engine.ResourceServer{ID: "local-rs"}, + Permission: engine.Permission{Name: "write"}, + }, + { + ResourceServer: engine.ResourceServer{ID: "rs-1", ResourceID: "booking-2"}, + Permission: engine.Permission{Name: "cancel"}, + }, + }, + }) + + require.NoError(t, err) + require.Equal(t, []engine.AccessEvaluationResponse{ + {Decision: false}, + {Decision: true}, + {Decision: false}, + {Decision: false}, + }, response.Evaluations) + require.Equal(t, 1, defaultEngine.called) +} + +type authorizationTestEngine struct { + decision bool + called int +} + +func (e *authorizationTestEngine) EvaluateAccess( + _ context.Context, + _ engine.AccessEvaluationRequest, +) (*engine.AccessEvaluationResponse, error) { + return &engine.AccessEvaluationResponse{Decision: e.decision}, nil +} + +func (e *authorizationTestEngine) EvaluateAccessBatch( + _ context.Context, + request engine.AccessEvaluationsRequest, +) (*engine.AccessEvaluationsResponse, error) { + e.called++ + responses := make([]engine.AccessEvaluationResponse, 0, len(request.Evaluations)) + for range request.Evaluations { + responses = append(responses, engine.AccessEvaluationResponse{Decision: e.decision}) + } + return &engine.AccessEvaluationsResponse{Evaluations: responses}, nil +} diff --git a/backend/internal/authzen/model.go b/backend/internal/authzen/model.go index e67700c8e6..bacc076101 100644 --- a/backend/internal/authzen/model.go +++ b/backend/internal/authzen/model.go @@ -11,7 +11,7 @@ type Subject struct { } // Resource identifies the protected resource in an AuthZEN access evaluation request. -// Type is the ThunderID resource server identifier. ID is reserved for future instance-based authorization. +// Type is the ThunderID resource server identifier. ID identifies the resource instance. type Resource struct { Type string `json:"type"` ID string `json:"id"` diff --git a/backend/internal/authzen/service.go b/backend/internal/authzen/service.go index 54cf36ce43..d1991787b7 100644 --- a/backend/internal/authzen/service.go +++ b/backend/internal/authzen/service.go @@ -61,11 +61,13 @@ func (s *authzenService) EvaluateAccess(ctx context.Context, request AccessEvalu return nil, svcErr } - if svcErr := s.validateSubject(ctx, request.Subject); svcErr != nil { + resolvedSubject, svcErr := s.resolveSubject(ctx, request.Subject) + if svcErr != nil { return nil, svcErr } + request.Subject = resolvedSubject - resourceServerID, svcErr := s.resolveResourceServerID(ctx, request.Resource.Type) + resourceServerID, svcErr := s.resolveResourceServerID(ctx, request.Resource) if svcErr != nil { if svcErr.Code == ErrorInvalidResource.Code { return &AccessEvaluationResponse{ @@ -76,11 +78,12 @@ func (s *authzenService) EvaluateAccess(ctx context.Context, request AccessEvalu return nil, svcErr } - if svcErr := s.validateAction(ctx, resourceServerID, request.Action.Name); svcErr != nil { + permission := authZENPermission(request.Resource.Type, request.Action.Name) + if svcErr := s.validateAction(ctx, resourceServerID, permission); svcErr != nil { if svcErr.Code == ErrorInvalidAction.Code { return &AccessEvaluationResponse{ Decision: false, - Context: buildInvalidActionContext(request.Action.Name), + Context: buildInvalidActionContext(permission), }, nil } return nil, svcErr @@ -92,7 +95,7 @@ func (s *authzenService) EvaluateAccess(ctx context.Context, request AccessEvalu } authzResp, svcErr := s.authzService.EvaluateAccess( - ctx, toAuthzAccessEvaluationRequest(request, groupIDs, resourceServerID)) + ctx, toAuthzAccessEvaluationRequest(request, groupIDs, resourceServerID, permission)) if svcErr != nil { s.logger.Error(ctx, "Authorization evaluation failed", log.MaskedString(log.LoggerKeyUserID, request.Subject.ID), @@ -112,13 +115,12 @@ func (s *authzenService) EvaluateAccessBatch(ctx context.Context, request Access if len(request.Evaluations) == 0 { return nil, &ErrorMissingEvaluations } - authzEvaluations := make([]providers.AccessEvaluationRequest, 0, len(request.Evaluations)) responses := make([]AccessEvaluationResponse, len(request.Evaluations)) authzEvaluationIndexes := make([]int, 0, len(request.Evaluations)) groupIDsBySubject := make(map[string][]string) resourceServerIDsByIdentifier := make(map[string]string) - validSubjects := make(map[string]struct{}) + resolvedSubjects := make(map[string]Subject) validActions := make(map[string]struct{}) for i, evaluation := range request.Evaluations { if svcErr := validateEvaluationRequest(evaluation); svcErr != nil { @@ -131,7 +133,7 @@ func (s *authzenService) EvaluateAccessBatch(ctx context.Context, request Access resourceServerID, ok := resourceServerIDsByIdentifier[evaluation.Resource.Type] if !ok { - resolvedResourceServerID, svcErr := s.resolveResourceServerID(ctx, evaluation.Resource.Type) + resolvedResourceServerID, svcErr := s.resolveResourceServerID(ctx, evaluation.Resource) if svcErr != nil { if svcErr.Code == ErrorInvalidResource.Code { responses[i] = AccessEvaluationResponse{ @@ -147,8 +149,11 @@ func (s *authzenService) EvaluateAccessBatch(ctx context.Context, request Access } subjectKey := evaluation.Subject.Type + ":" + evaluation.Subject.ID - if _, ok := validSubjects[subjectKey]; !ok { - if svcErr := s.validateSubject(ctx, evaluation.Subject); svcErr != nil { + resolvedSubject, ok := resolvedSubjects[subjectKey] + if !ok { + var svcErr *tidcommon.ServiceError + resolvedSubject, svcErr = s.resolveSubject(ctx, evaluation.Subject) + if svcErr != nil { if svcErr.Code == ErrorInvalidSubject.Code { responses[i] = AccessEvaluationResponse{ Decision: false, @@ -158,16 +163,18 @@ func (s *authzenService) EvaluateAccessBatch(ctx context.Context, request Access } return nil, svcErr } - validSubjects[subjectKey] = struct{}{} + resolvedSubjects[subjectKey] = resolvedSubject } + evaluation.Subject = resolvedSubject - actionKey := resourceServerID + ":" + evaluation.Action.Name + permission := authZENPermission(evaluation.Resource.Type, evaluation.Action.Name) + actionKey := resourceServerID + ":" + permission if _, ok := validActions[actionKey]; !ok { - if svcErr := s.validateAction(ctx, resourceServerID, evaluation.Action.Name); svcErr != nil { + if svcErr := s.validateAction(ctx, resourceServerID, permission); svcErr != nil { if svcErr.Code == ErrorInvalidAction.Code { responses[i] = AccessEvaluationResponse{ Decision: false, - Context: buildInvalidActionContext(evaluation.Action.Name), + Context: buildInvalidActionContext(permission), } continue } @@ -190,7 +197,8 @@ func (s *authzenService) EvaluateAccessBatch(ctx context.Context, request Access groupIDs = resolvedGroupIDs } authzEvaluations = append( - authzEvaluations, toAuthzAccessEvaluationRequest(evaluation, groupIDs, resourceServerID)) + authzEvaluations, toAuthzAccessEvaluationRequest( + evaluation, groupIDs, resourceServerID, permission)) authzEvaluationIndexes = append(authzEvaluationIndexes, i) } @@ -233,8 +241,13 @@ func (s *authzenService) SearchActions(ctx context.Context, request AccessAction if strings.TrimSpace(request.Resource.Type) == "" { return nil, &ErrorMissingResource } + resolvedSubject, svcErr := s.resolveSubject(ctx, request.Subject) + if svcErr != nil { + return nil, svcErr + } + request.Subject = resolvedSubject - resourceServerID, svcErr := s.resolveResourceServerID(ctx, request.Resource.Type) + resourceServerID, svcErr := s.resolveResourceServerID(ctx, request.Resource) if svcErr != nil { return nil, svcErr } @@ -259,7 +272,7 @@ func (s *authzenService) SearchActions(ctx context.Context, request AccessAction continue } requestedPermissions = append(requestedPermissions, action.Permission) - actionByPermission[action.Permission] = Action{Name: action.Permission} + actionByPermission[action.Permission] = Action{Name: authZENActionName(action.Permission)} } authzEvaluations := make([]providers.AccessEvaluationRequest, 0, len(requestedPermissions)) @@ -273,6 +286,7 @@ func (s *authzenService) SearchActions(ctx context.Context, request AccessAction }, ResourceServer: providers.AccessEvaluationResourceServer{ ID: resourceServerID, + ResourceID: request.Resource.ID, Properties: request.Resource.Properties, }, Permission: providers.Permission{ @@ -423,33 +437,37 @@ func appendUniquePermissionActions( return actions } -// validateSubject verifies that the subject exists and matches its type. -func (s *authzenService) validateSubject(ctx context.Context, subject Subject) *tidcommon.ServiceError { +// resolveSubject verifies the subject and infers an omitted type from its entity category. +func (s *authzenService) resolveSubject(ctx context.Context, subject Subject) (Subject, *tidcommon.ServiceError) { if s.entityProvider == nil { - return nil - } - if strings.TrimSpace(subject.Type) == "" { - return nil + return subject, nil } entity, err := s.entityProvider.GetEntity(subject.ID) if err != nil { if err.Code == entityprovider.ErrorCodeNotImplemented { - return nil + return subject, nil } if err.Code == entityprovider.ErrorCodeEntityNotFound { - return &ErrorInvalidSubject + return Subject{}, &ErrorInvalidSubject } s.logger.Error(ctx, "Failed to validate subject", log.MaskedString(log.LoggerKeyUserID, subject.ID), log.String("error", err.Error())) - return &tidcommon.InternalServerError + return Subject{}, &tidcommon.InternalServerError } - if entity == nil || entity.Category.String() != subject.Type { - return &ErrorInvalidSubject + if entity == nil { + return Subject{}, &ErrorInvalidSubject } - return nil + if strings.TrimSpace(subject.Type) == "" { + subject.Type = entity.Category.String() + return subject, nil + } + if entity.Category.String() != subject.Type { + return Subject{}, &ErrorInvalidSubject + } + return subject, nil } // validateAction verifies that an action is registered on the resource server. @@ -489,28 +507,42 @@ func validateEvaluationRequest(request AccessEvaluationRequest) *tidcommon.Servi return nil } -// resolveResourceServerID resolves a resource server identifier to its internal ID. -func (s *authzenService) resolveResourceServerID(ctx context.Context, resourceServerIdentifier string) ( +// resolveResourceServerID resolves a resource server using the current and legacy AuthZEN forms. +func (s *authzenService) resolveResourceServerID(ctx context.Context, authzenResource Resource) ( string, *tidcommon.ServiceError) { - if strings.TrimSpace(resourceServerIdentifier) == "" { + resourceType := strings.TrimSpace(authzenResource.Type) + if resourceType == "" { return "", &ErrorMissingResource } - if s.resourceService == nil { - return resourceServerIdentifier, nil + return resourceType, nil + } + + resourceServer, svcErr := s.resourceService.GetResourceServerByIdentifier(ctx, resourceType) + if svcErr == nil { + return resourceServer.ID, nil + } + if svcErr.Code != resource.ErrorResourceServerNotFound.Code { + s.logger.Error(ctx, "Failed to retrieve resource server by identifier", + log.String("resourceServerIdentifier", resourceType), + log.String("error", svcErr.Error.DefaultValue)) + return "", &tidcommon.InternalServerError } - resourceServer, svcErr := s.resourceService.GetResourceServerByIdentifier(ctx, resourceServerIdentifier) + resourceIdentifier := strings.TrimSpace(authzenResource.ID) + if resourceIdentifier == "" || resourceIdentifier == resourceType { + return "", &ErrorInvalidResource + } + resourceServer, svcErr = s.resourceService.GetResourceServerByIdentifier(ctx, resourceIdentifier) if svcErr != nil { if svcErr.Code == resource.ErrorResourceServerNotFound.Code { return "", &ErrorInvalidResource } s.logger.Error(ctx, "Failed to retrieve resource server by identifier", - log.String("resourceServerIdentifier", resourceServerIdentifier), + log.String("resourceServerIdentifier", resourceIdentifier), log.String("error", svcErr.Error.DefaultValue)) return "", &tidcommon.InternalServerError } - return resourceServer.ID, nil } @@ -545,6 +577,7 @@ func toAuthzAccessEvaluationRequest( request AccessEvaluationRequest, groupIDs []string, resourceServerID string, + permission string, ) providers.AccessEvaluationRequest { return providers.AccessEvaluationRequest{ Subject: providers.Subject{ @@ -555,16 +588,30 @@ func toAuthzAccessEvaluationRequest( }, ResourceServer: providers.AccessEvaluationResourceServer{ ID: resourceServerID, + ResourceID: request.Resource.ID, Properties: request.Resource.Properties, }, Permission: providers.Permission{ - Name: request.Action.Name, + Name: permission, Properties: request.Action.Properties, }, Context: request.Context, } } +// authZENPermission converts an AuthZEN action into ThunderID's permission format. +func authZENPermission(_ string, actionName string) string { + return actionName +} + +// authZENActionName returns the final action segment for AuthZEN action-search responses. +func authZENActionName(permission string) string { + if index := strings.LastIndex(permission, ":"); index >= 0 { + return permission[index+1:] + } + return permission +} + // buildDecisionContext returns the engine context or a default denial context. func buildDecisionContext(decision bool, context map[string]interface{}) map[string]interface{} { if context != nil { diff --git a/backend/internal/authzen/service_test.go b/backend/internal/authzen/service_test.go index c854ac42b6..b0aaab6ff7 100644 --- a/backend/internal/authzen/service_test.go +++ b/backend/internal/authzen/service_test.go @@ -81,9 +81,13 @@ func (s *ServiceTestSuite) TestEvaluateAccessAllowed() { {ID: "group2"}, }, nil) s.authzMock.On("EvaluateAccess", mock.Anything, providers.AccessEvaluationRequest{ - Subject: providers.Subject{Type: "user", ID: "user1", GroupIDs: []string{"group1", "group2"}}, - ResourceServer: providers.AccessEvaluationResourceServer{ID: testResourceServerID}, - Permission: providers.Permission{Name: testBookingReadAction}, + Subject: providers.Subject{Type: "user", ID: "user1", GroupIDs: []string{"group1", "group2"}}, + ResourceServer: providers.AccessEvaluationResourceServer{ + ID: testResourceServerID, + ResourceID: testBookingResourceID, + Properties: nil, + }, + Permission: providers.Permission{Name: testBookingReadAction}, }).Return(&providers.AccessEvaluationResponse{Decision: true}, nil) resp, svcErr := s.service.EvaluateAccess(context.Background(), req) @@ -128,7 +132,8 @@ func (s *ServiceTestSuite) TestEvaluateAccessPassesPropertiesToAuthz() { }, ResourceServer: providers.AccessEvaluationResourceServer{ ID: testResourceServerID, - Properties: resourceProperties, + ResourceID: testBookingResourceID, + Properties: map[string]interface{}{"owner": "user1"}, }, Permission: providers.Permission{ Name: testBookingReadAction, @@ -155,9 +160,13 @@ func (s *ServiceTestSuite) TestEvaluateAccessDenied() { s.mockValidAction("booking:delete") s.entityProviderMock.On("GetTransitiveEntityGroups", "user1").Return([]providers.EntityGroup{}, nil) s.authzMock.On("EvaluateAccess", mock.Anything, providers.AccessEvaluationRequest{ - Subject: providers.Subject{Type: "user", ID: "user1", GroupIDs: []string{}}, - ResourceServer: providers.AccessEvaluationResourceServer{ID: testResourceServerID}, - Permission: providers.Permission{Name: "booking:delete"}, + Subject: providers.Subject{Type: "user", ID: "user1", GroupIDs: []string{}}, + ResourceServer: providers.AccessEvaluationResourceServer{ + ID: testResourceServerID, + ResourceID: testBookingResourceID, + Properties: nil, + }, + Permission: providers.Permission{Name: "booking:delete"}, }).Return(&providers.AccessEvaluationResponse{Decision: false}, nil) resp, svcErr := s.service.EvaluateAccess(context.Background(), req) @@ -188,9 +197,13 @@ func (s *ServiceTestSuite) TestEvaluateAccessProviderNotImplementedUsesEmptyGrou entityprovider.ErrorCodeNotImplemented, "not implemented", "not implemented"), ) s.authzMock.On("EvaluateAccess", mock.Anything, providers.AccessEvaluationRequest{ - Subject: providers.Subject{Type: "app", ID: "app1", GroupIDs: []string{}}, - ResourceServer: providers.AccessEvaluationResourceServer{ID: testResourceServerID}, - Permission: providers.Permission{Name: "report:read"}, + Subject: providers.Subject{Type: "app", ID: "app1", GroupIDs: []string{}}, + ResourceServer: providers.AccessEvaluationResourceServer{ + ID: testResourceServerID, + ResourceID: "report1", + Properties: nil, + }, + Permission: providers.Permission{Name: "report:read"}, }).Return(&providers.AccessEvaluationResponse{Decision: true}, nil) resp, svcErr := s.service.EvaluateAccess(context.Background(), req) @@ -200,20 +213,25 @@ func (s *ServiceTestSuite) TestEvaluateAccessProviderNotImplementedUsesEmptyGrou s.True(resp.Decision) } -func (s *ServiceTestSuite) TestEvaluateAccessSkipsSubjectValidationWhenTypeEmpty() { +func (s *ServiceTestSuite) TestEvaluateAccessInfersSubjectTypeWhenTypeEmpty() { req := AccessEvaluationRequest{ Subject: Subject{ID: "user1"}, Resource: Resource{Type: "booking", ID: testBookingResourceID}, Action: Action{Name: testBookingReadAction}, } + s.mockValidSubject() s.mockResourceServerIdentifier("booking") s.mockValidAction(testBookingReadAction) s.entityProviderMock.On("GetTransitiveEntityGroups", "user1").Return([]providers.EntityGroup{}, nil) s.authzMock.On("EvaluateAccess", mock.Anything, providers.AccessEvaluationRequest{ - Subject: providers.Subject{ID: "user1", GroupIDs: []string{}}, - ResourceServer: providers.AccessEvaluationResourceServer{ID: testResourceServerID}, - Permission: providers.Permission{Name: testBookingReadAction}, + Subject: providers.Subject{Type: "user", ID: "user1", GroupIDs: []string{}}, + ResourceServer: providers.AccessEvaluationResourceServer{ + ID: testResourceServerID, + ResourceID: testBookingResourceID, + Properties: nil, + }, + Permission: providers.Permission{Name: testBookingReadAction}, }).Return(&providers.AccessEvaluationResponse{Decision: true}, nil) resp, svcErr := s.service.EvaluateAccess(context.Background(), req) @@ -221,7 +239,6 @@ func (s *ServiceTestSuite) TestEvaluateAccessSkipsSubjectValidationWhenTypeEmpty s.Nil(svcErr) s.NotNil(resp) s.True(resp.Decision) - s.entityProviderMock.AssertNotCalled(s.T(), "GetEntity", mock.Anything) } func (s *ServiceTestSuite) TestEvaluateAccessGroupResolutionFailure() { @@ -259,9 +276,13 @@ func (s *ServiceTestSuite) TestEvaluateAccessAuthorizationFailure() { s.mockValidAction(testBookingReadAction) s.entityProviderMock.On("GetTransitiveEntityGroups", "user1").Return([]providers.EntityGroup{}, nil) s.authzMock.On("EvaluateAccess", mock.Anything, providers.AccessEvaluationRequest{ - Subject: providers.Subject{Type: "user", ID: "user1", GroupIDs: []string{}}, - ResourceServer: providers.AccessEvaluationResourceServer{ID: testResourceServerID}, - Permission: providers.Permission{Name: testBookingReadAction}, + Subject: providers.Subject{Type: "user", ID: "user1", GroupIDs: []string{}}, + ResourceServer: providers.AccessEvaluationResourceServer{ + ID: testResourceServerID, + ResourceID: testBookingResourceID, + Properties: nil, + }, + Permission: providers.Permission{Name: testBookingReadAction}, }).Return((*providers.AccessEvaluationResponse)(nil), &tidcommon.InternalServerError) resp, svcErr := s.service.EvaluateAccess(context.Background(), req) @@ -376,6 +397,8 @@ func (s *ServiceTestSuite) TestEvaluateAccessUnknownResourceReturnsErrorContext( s.mockValidSubject() s.resourceMock.On("GetResourceServerByIdentifier", mock.Anything, "unknown"). Return((*providers.ResourceServer)(nil), &resource.ErrorResourceServerNotFound).Once() + s.resourceMock.On("GetResourceServerByIdentifier", mock.Anything, testBookingResourceID). + Return((*providers.ResourceServer)(nil), &resource.ErrorResourceServerNotFound).Once() resp, svcErr := s.service.EvaluateAccess(context.Background(), req) @@ -440,6 +463,45 @@ func (s *ServiceTestSuite) TestEvaluateAccessBatchPreservesOrder() { s.entityProviderMock.AssertNumberOfCalls(s.T(), "GetTransitiveEntityGroups", 1) } +func (s *ServiceTestSuite) TestEvaluateAccessBatchInfersSubjectTypeOnce() { + req := AccessEvaluationsRequest{ + Evaluations: []AccessEvaluationRequest{ + { + Subject: Subject{ID: testSubjectID}, + Resource: Resource{Type: "booking", ID: testBookingResourceID}, + Action: Action{Name: testBookingReadAction}, + }, + { + Subject: Subject{ID: testSubjectID}, + Resource: Resource{Type: "booking", ID: testBookingResourceID}, + Action: Action{Name: "booking:create"}, + }, + }, + } + + s.mockValidSubject() + s.mockResourceServerIdentifier("booking") + s.mockValidAction(testBookingReadAction) + s.mockValidAction("booking:create") + s.entityProviderMock.On("GetTransitiveEntityGroups", testSubjectID).Return([]providers.EntityGroup{}, nil).Once() + s.authzMock.On("EvaluateAccessBatch", mock.Anything, + mock.MatchedBy(func(req providers.AccessEvaluationsRequest) bool { + return len(req.Evaluations) == 2 && + req.Evaluations[0].Subject.Type == testSubjectType && + req.Evaluations[1].Subject.Type == testSubjectType + })).Return(&providers.AccessEvaluationsResponse{ + Evaluations: []providers.AccessEvaluationResponse{{Decision: true}, {Decision: true}}, + }, nil) + + resp, svcErr := s.service.EvaluateAccessBatch(context.Background(), req) + + s.Nil(svcErr) + s.NotNil(resp) + s.True(resp.Evaluations[0].Decision) + s.True(resp.Evaluations[1].Decision) + s.entityProviderMock.AssertNumberOfCalls(s.T(), "GetEntity", 1) +} + func (s *ServiceTestSuite) TestEvaluateAccessBatchInvalidActionReturnsFalse() { req := AccessEvaluationsRequest{ Evaluations: []AccessEvaluationRequest{ @@ -692,10 +754,11 @@ func (s *ServiceTestSuite) TestEvaluateAccessBatchMissingEvaluations() { func (s *ServiceTestSuite) TestSearchActionsReturnsAuthorizedActions() { req := AccessActionSearchRequest{ - Subject: Subject{Type: "user", ID: "user1"}, + Subject: Subject{ID: "user1"}, Resource: Resource{Type: "booking", ID: testBookingResourceID}, } + s.mockValidSubject() s.entityProviderMock.On("GetTransitiveEntityGroups", "user1").Return([]providers.EntityGroup{ {ID: "group1"}, }, nil) @@ -735,6 +798,7 @@ func (s *ServiceTestSuite) TestSearchActionsReturnsAuthorizedActions() { mock.MatchedBy(func(req providers.AccessEvaluationsRequest) bool { return len(req.Evaluations) == 3 && req.Evaluations[0].Subject.ID == "user1" && + req.Evaluations[0].Subject.Type == testSubjectType && req.Evaluations[0].Subject.GroupIDs[0] == "group1" && req.Evaluations[0].ResourceServer.ID == testResourceServerID && req.Evaluations[0].Permission.Name == "booking:booking:read" && @@ -754,8 +818,29 @@ func (s *ServiceTestSuite) TestSearchActionsReturnsAuthorizedActions() { s.Nil(svcErr) s.NotNil(resp) s.Len(resp.Results, 2) - s.Equal("booking:booking:read", resp.Results[0].Name) - s.Equal("invoice:invoice:approve", resp.Results[1].Name) + s.Equal("read", resp.Results[0].Name) + s.Equal("approve", resp.Results[1].Name) +} + +func (s *ServiceTestSuite) TestSearchActionsRejectsMismatchedSubjectType() { + req := AccessActionSearchRequest{ + Subject: Subject{Type: "admin", ID: testSubjectID}, + Resource: Resource{Type: "booking", ID: testBookingResourceID}, + } + + s.entityProviderMock.On("GetEntity", testSubjectID).Return(&providers.Entity{ + ID: testSubjectID, + Category: providers.EntityCategoryUser, + }, nil) + + resp, svcErr := s.service.SearchActions(context.Background(), req) + + s.Nil(resp) + s.NotNil(svcErr) + s.Equal(ErrorInvalidSubject.Code, svcErr.Code) + s.resourceMock.AssertNotCalled(s.T(), "GetResourceServerByIdentifier", + mock.Anything, mock.Anything) + s.authzMock.AssertNotCalled(s.T(), "EvaluateAccessBatch", mock.Anything, mock.Anything) } func (s *ServiceTestSuite) TestSearchActionsPaginatesResourceServerActions() { @@ -764,6 +849,7 @@ func (s *ServiceTestSuite) TestSearchActionsPaginatesResourceServerActions() { Resource: Resource{Type: "booking", ID: testBookingResourceID}, } + s.mockValidSubject() s.entityProviderMock.On("GetTransitiveEntityGroups", "user1").Return([]providers.EntityGroup{}, nil) s.mockResourceServerIdentifier("booking") s.resourceMock.On("GetActionList", mock.Anything, testResourceServerID, (*string)(nil), @@ -805,8 +891,8 @@ func (s *ServiceTestSuite) TestSearchActionsPaginatesResourceServerActions() { s.Nil(svcErr) s.NotNil(resp) s.Len(resp.Results, 2) - s.Equal("booking:booking:read", resp.Results[0].Name) - s.Equal("booking:booking:write", resp.Results[1].Name) + s.Equal("read", resp.Results[0].Name) + s.Equal("write", resp.Results[1].Name) } func (s *ServiceTestSuite) TestSearchActionsReturnsEmptyResultsWhenDenied() { @@ -815,6 +901,7 @@ func (s *ServiceTestSuite) TestSearchActionsReturnsEmptyResultsWhenDenied() { Resource: Resource{Type: "booking", ID: testBookingResourceID}, } + s.mockValidSubject() s.entityProviderMock.On("GetTransitiveEntityGroups", "user1").Return([]providers.EntityGroup{}, nil) s.mockResourceServerIdentifier("booking") s.resourceMock.On("GetActionList", mock.Anything, testResourceServerID, (*string)(nil), @@ -877,8 +964,11 @@ func (s *ServiceTestSuite) TestSearchActionsUnknownResourceReturnsInvalidResourc Resource: Resource{Type: "unknown", ID: testBookingResourceID}, } + s.mockValidSubject() s.resourceMock.On("GetResourceServerByIdentifier", mock.Anything, "unknown"). Return((*providers.ResourceServer)(nil), &resource.ErrorResourceServerNotFound).Once() + s.resourceMock.On("GetResourceServerByIdentifier", mock.Anything, testBookingResourceID). + Return((*providers.ResourceServer)(nil), &resource.ErrorResourceServerNotFound).Once() resp, svcErr := s.service.SearchActions(context.Background(), req) @@ -893,6 +983,7 @@ func (s *ServiceTestSuite) TestSearchActionsResourceServiceError() { Resource: Resource{Type: "booking", ID: testBookingResourceID}, } + s.mockValidSubject() s.entityProviderMock.On("GetTransitiveEntityGroups", "user1").Return([]providers.EntityGroup{}, nil) s.mockResourceServerIdentifier("booking") s.resourceMock.On("GetActionList", mock.Anything, testResourceServerID, (*string)(nil), @@ -912,6 +1003,7 @@ func (s *ServiceTestSuite) TestSearchActionsAuthorizationServiceError() { Resource: Resource{Type: "booking", ID: testBookingResourceID}, } + s.mockValidSubject() s.entityProviderMock.On("GetTransitiveEntityGroups", "user1").Return([]providers.EntityGroup{}, nil) s.mockResourceServerIdentifier("booking") s.resourceMock.On("GetActionList", mock.Anything, testResourceServerID, (*string)(nil), diff --git a/backend/internal/connection/authzenpdp/mapping.go b/backend/internal/connection/authzenpdp/mapping.go new file mode 100644 index 0000000000..daf67d2383 --- /dev/null +++ b/backend/internal/connection/authzenpdp/mapping.go @@ -0,0 +1,181 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package authzenpdp + +import ( + "sort" + "strings" +) + +// FromRequest converts an API request into an internal connection. +func FromRequest(req ConnectionRequest) AuthZENPDPConnection { + connection := AuthZENPDPConnection{ + Name: req.Name, + Description: req.Description, + Endpoint: req.Endpoint, + BatchEndpoint: req.BatchEndpoint, + TimeoutMS: req.TimeoutMS, + RetryCount: req.RetryCount, + SubjectProperties: splitSubjectProperties(req.SubjectProperties), + SubjectPropertyMappings: SplitSubjectPropertyMappings(req.SubjectPropertyMappings), + SubjectAttributeMappings: sanitizeSubjectAttributeMappings(req.SubjectAttributeMappings), + } + if connection.TimeoutMS <= 0 { + connection.TimeoutMS = DefaultTimeoutMS() + } + if connection.RetryCount < 0 { + connection.RetryCount = DefaultRetryCount() + } + return connection +} + +// ToResponse converts an internal connection into an API response. +func ToResponse(connection AuthZENPDPConnection) ConnectionResponse { + return ConnectionResponse{ + ID: connection.ID, + Name: connection.Name, + Description: connection.Description, + Type: VendorName, + Endpoint: connection.Endpoint, + BatchEndpoint: connection.BatchEndpoint, + TimeoutMS: connection.TimeoutMS, + RetryCount: connection.RetryCount, + SubjectProperties: strings.Join(connection.SubjectProperties, " "), + SubjectPropertyMappings: JoinSubjectPropertyMappings(connection.SubjectPropertyMappings), + SubjectAttributeMappings: connection.SubjectAttributeMappings, + } +} + +// SplitSubjectPropertyMappings parses the API representation of subject-property mappings. +func SplitSubjectPropertyMappings(value string) map[string]string { + if strings.TrimSpace(value) == "" { + return nil + } + mappings := map[string]string{} + for _, segment := range strings.Split(value, ",") { + segment = strings.TrimSpace(segment) + if segment == "" { + continue + } + separator := strings.Index(segment, ":") + if separator <= 0 { + continue + } + source := strings.TrimSpace(segment[:separator]) + target := strings.TrimSpace(segment[separator+1:]) + if source != "" && target != "" { + mappings[source] = target + } + } + if len(mappings) == 0 { + return nil + } + return mappings +} + +// JoinSubjectPropertyMappings serializes subject-property mappings for the API representation. +func JoinSubjectPropertyMappings(mappings map[string]string) string { + if len(mappings) == 0 { + return "" + } + parts := make([]string, 0, len(mappings)) + for source, target := range mappings { + if source != "" && target != "" { + parts = append(parts, source+": "+target) + } + } + sort.Strings(parts) + return strings.Join(parts, ", ") +} + +// cloneStringMap returns a shallow copy of a string map. +func cloneStringMap(values map[string]string) map[string]string { + if len(values) == 0 { + return nil + } + clone := make(map[string]string, len(values)) + for key, value := range values { + clone[key] = value + } + return clone +} + +// cloneSubjectAttributeMappings copies subject mappings and their attribute rows. +func cloneSubjectAttributeMappings(groups []SubjectAttributeMapping) []SubjectAttributeMapping { + if len(groups) == 0 { + return nil + } + clone := make([]SubjectAttributeMapping, 0, len(groups)) + for _, group := range groups { + clone = append(clone, SubjectAttributeMapping{ + UserType: group.UserType, + Attributes: append([]SubjectAttributeRow(nil), group.Attributes...), + }) + } + return clone +} + +// splitSubjectProperties parses and deduplicates a list of subject properties. +func splitSubjectProperties(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + parts := strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == '\n' || r == '\t' || r == ' ' + }) + properties := make([]string, 0, len(parts)) + seen := map[string]struct{}{} + for _, part := range parts { + property := strings.TrimSpace(part) + if property == "" { + continue + } + if _, ok := seen[property]; ok { + continue + } + seen[property] = struct{}{} + properties = append(properties, property) + } + return properties +} + +// sanitizeSubjectAttributeMappings trims mappings and removes empty entries. +func sanitizeSubjectAttributeMappings(groups []SubjectAttributeMapping) []SubjectAttributeMapping { + if len(groups) == 0 { + return nil + } + result := make([]SubjectAttributeMapping, 0, len(groups)) + for _, group := range groups { + attributes := make([]SubjectAttributeRow, 0, len(group.Attributes)) + for _, attribute := range group.Attributes { + name := strings.TrimSpace(attribute.Attribute) + if name == "" { + continue + } + attributes = append(attributes, SubjectAttributeRow{ + Attribute: name, + PDPAttribute: strings.TrimSpace(attribute.PDPAttribute), + }) + } + userType := strings.TrimSpace(group.UserType) + if userType == "" && len(attributes) == 0 { + continue + } + result = append(result, SubjectAttributeMapping{ + UserType: userType, + Attributes: attributes, + }) + } + if len(result) == 0 { + return nil + } + return result +} + +// NormalizedSubjectMapping returns direct subject-property mappings. +func NormalizedSubjectMapping(connection AuthZENPDPConnection) ([]string, map[string]string) { + subjectProperties := append([]string(nil), connection.SubjectProperties...) + subjectPropertyMappings := cloneStringMap(connection.SubjectPropertyMappings) + return subjectProperties, subjectPropertyMappings +} diff --git a/backend/internal/connection/authzenpdp/model.go b/backend/internal/connection/authzenpdp/model.go new file mode 100644 index 0000000000..5075610bc1 --- /dev/null +++ b/backend/internal/connection/authzenpdp/model.go @@ -0,0 +1,161 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package authzenpdp manages external AuthZEN PDP connections. +package authzenpdp + +import ( + "encoding/json" + + "github.com/thunder-id/thunderid/internal/system/config" +) + +// VendorName is the connection vendor identifier for external AuthZEN PDPs. +const VendorName = "authzen-pdp" + +// DefaultTimeoutMS returns the configured server timeout default. +func DefaultTimeoutMS() int { + if !config.IsServerRuntimeInitialized() { + return 0 + } + return config.GetServerRuntime().Config.AuthZENPDP.TimeoutMS +} + +// DefaultRetryCount returns the configured server retry default. +func DefaultRetryCount() int { + if !config.IsServerRuntimeInitialized() { + return 0 + } + value := config.GetServerRuntime().Config.AuthZENPDP.RetryCount + if value == nil { + return 0 + } + return *value +} + +// ConnectionRequest is the API representation of an external AuthZEN PDP connection request. +type ConnectionRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Endpoint string `json:"-"` + BatchEndpoint string `json:"batchEndpoint,omitempty"` + TimeoutMS int `json:"timeoutMs,omitempty"` + RetryCount int `json:"retryCount,omitempty"` + SubjectProperties string `json:"subjectProperties,omitempty"` + SubjectPropertyMappings string `json:"subjectPropertyMappings,omitempty"` + SubjectAttributeMappings []SubjectAttributeMapping `json:"subjectAttributeMappings,omitempty"` +} + +// UnmarshalJSON decodes an external AuthZEN PDP connection request. +func (r *ConnectionRequest) UnmarshalJSON(data []byte) error { + var raw struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Endpoint json.RawMessage `json:"endpoint"` + BatchEndpoint string `json:"batchEndpoint,omitempty"` + TimeoutMS int `json:"timeoutMs,omitempty"` + RetryCount int `json:"retryCount"` + SubjectProperties string `json:"subjectProperties,omitempty"` + SubjectPropertyMappings string `json:"subjectPropertyMappings,omitempty"` + SubjectAttributeMappings []SubjectAttributeMapping `json:"subjectAttributeMappings,omitempty"` + } + raw.TimeoutMS = DefaultTimeoutMS() + raw.RetryCount = DefaultRetryCount() + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if len(raw.Endpoint) > 0 && string(raw.Endpoint) != "null" { + if err := json.Unmarshal(raw.Endpoint, &r.Endpoint); err != nil { + return err + } + } + r.Name = raw.Name + r.Description = raw.Description + r.TimeoutMS = raw.TimeoutMS + r.RetryCount = raw.RetryCount + r.SubjectProperties = raw.SubjectProperties + r.SubjectPropertyMappings = raw.SubjectPropertyMappings + r.SubjectAttributeMappings = raw.SubjectAttributeMappings + r.BatchEndpoint = raw.BatchEndpoint + return nil +} + +// MarshalJSON encodes an external AuthZEN PDP connection request. +func (r ConnectionRequest) MarshalJSON() ([]byte, error) { + payload := struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Endpoint string `json:"endpoint"` + BatchEndpoint string `json:"batchEndpoint,omitempty"` + TimeoutMS int `json:"timeoutMs,omitempty"` + RetryCount int `json:"retryCount"` + SubjectProperties string `json:"subjectProperties,omitempty"` + SubjectPropertyMappings string `json:"subjectPropertyMappings,omitempty"` + SubjectAttributeMappings []SubjectAttributeMapping `json:"subjectAttributeMappings,omitempty"` + }{ + Name: r.Name, + Description: r.Description, + Endpoint: r.Endpoint, + BatchEndpoint: r.BatchEndpoint, + TimeoutMS: r.TimeoutMS, + RetryCount: r.RetryCount, + SubjectProperties: r.SubjectProperties, + SubjectPropertyMappings: r.SubjectPropertyMappings, + SubjectAttributeMappings: r.SubjectAttributeMappings, + } + return json.Marshal(payload) +} + +// ConnectionResponse is the API representation of an external AuthZEN PDP connection. +type ConnectionResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Type string `json:"type"` + Endpoint string `json:"endpoint"` + BatchEndpoint string `json:"batchEndpoint,omitempty"` + TimeoutMS int `json:"timeoutMs"` + RetryCount int `json:"retryCount"` + SubjectProperties string `json:"subjectProperties,omitempty"` + SubjectPropertyMappings string `json:"subjectPropertyMappings,omitempty"` + SubjectAttributeMappings []SubjectAttributeMapping `json:"subjectAttributeMappings,omitempty"` +} + +// AuthZENPDPConnection is the internal representation of an external AuthZEN PDP connection. +type AuthZENPDPConnection struct { + ID string + Name string + Description string + Endpoint string + BatchEndpoint string + TimeoutMS int + RetryCount int + SubjectProperties []string + SubjectPropertyMappings map[string]string + SubjectAttributeMappings []SubjectAttributeMapping +} + +// SubjectAttributeMapping maps ThunderID user-type attributes to PDP subject attributes. +type SubjectAttributeMapping struct { + UserType string `json:"userType" yaml:"userType"` + Attributes []SubjectAttributeRow `json:"attributes" yaml:"attributes"` +} + +// SubjectAttributeRow identifies one subject attribute mapping. +type SubjectAttributeRow struct { + Attribute string `json:"attribute" yaml:"attribute"` + PDPAttribute string `json:"pdpAttribute,omitempty" yaml:"pdpAttribute,omitempty"` +} + +// AuthZENPDPRuntimeConfig is the runtime-safe subset of a saved AuthZEN PDP connection. +type AuthZENPDPRuntimeConfig struct { + ID string + Name string + Endpoint string + BatchEndpoint string + TimeoutMS int + RetryCount int + SubjectProperties []string + SubjectPropertyMappings map[string]string + SubjectAttributeMappings []SubjectAttributeMapping +} diff --git a/backend/internal/connection/authzenpdp/model_test.go b/backend/internal/connection/authzenpdp/model_test.go new file mode 100644 index 0000000000..60d5403bc5 --- /dev/null +++ b/backend/internal/connection/authzenpdp/model_test.go @@ -0,0 +1,190 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package authzenpdp + +import ( + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/thunder-id/thunderid/internal/system/config" +) + +func TestMain(m *testing.M) { + data, err := os.ReadFile("../../../cmd/server/config/default.json") + if err != nil { + panic(err) + } + var cfg config.Config + if err := json.Unmarshal(data, &cfg); err != nil { + panic(err) + } + if err := config.InitializeServerRuntime("", &cfg); err != nil { + panic(err) + } + os.Exit(m.Run()) +} + +func TestConnectionRequestServerDefaultsAndOverrides(t *testing.T) { + cfg := &config.GetServerRuntime().Config.AuthZENPDP + original := *cfg + t.Cleanup(func() { *cfg = original }) + retries := 4 + cfg.TimeoutMS, cfg.RetryCount = 1200, &retries + var inherited, explicit ConnectionRequest + require.NoError(t, json.Unmarshal([]byte(`{}`), &inherited)) + require.Equal(t, 1200, inherited.TimeoutMS) + require.Equal(t, 4, inherited.RetryCount) + require.NoError(t, json.Unmarshal([]byte(`{"timeoutMs":45000,"retryCount":0}`), &explicit)) + connection := FromRequest(explicit) + require.Equal(t, 45000, connection.TimeoutMS) + require.Zero(t, connection.RetryCount) + data, err := json.Marshal(explicit) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(data, &inherited)) + require.Zero(t, inherited.RetryCount) +} + +func TestConnectionRequestJSONRoundTrip(t *testing.T) { + request := ConnectionRequest{ + Name: "External PDP", + Description: "Test connection", + Endpoint: "https://pdp.example.com/access/v1/evaluation", + BatchEndpoint: "https://pdp.example.com/access/v1/evaluations", + TimeoutMS: 1000, + RetryCount: 2, + SubjectProperties: "email groups", + SubjectPropertyMappings: "email: mail, groups: roles", + SubjectAttributeMappings: []SubjectAttributeMapping{{ + UserType: "Customer", + Attributes: []SubjectAttributeRow{{Attribute: "email", PDPAttribute: "mail"}}, + }}, + } + + data, err := json.Marshal(request) + require.NoError(t, err) + require.Contains(t, string(data), `"endpoint":"https://pdp.example.com/access/v1/evaluation"`) + require.Contains(t, string(data), `"batchEndpoint":"https://pdp.example.com/access/v1/evaluations"`) + + var decoded ConnectionRequest + require.NoError(t, json.Unmarshal(data, &decoded)) + require.Equal(t, request, decoded) +} + +func TestConnectionRequestUnmarshalRejectsInvalidEndpoint(t *testing.T) { + var request ConnectionRequest + + err := json.Unmarshal([]byte(`{"endpoint":42}`), &request) + require.Error(t, err) +} + +func TestConnectionRequestUnmarshalAllowsNullEndpoint(t *testing.T) { + var request ConnectionRequest + + require.NoError(t, json.Unmarshal([]byte(`{"name":"PDP","endpoint":null}`), &request)) + require.Equal(t, "PDP", request.Name) + require.Empty(t, request.Endpoint) +} + +func TestValidateEndpoint(t *testing.T) { + tests := []struct { + name string + endpoint string + valid bool + }{ + {name: "absolute", endpoint: "http://localhost:3592/access/v1/evaluation", valid: true}, + {name: "unsupported scheme", endpoint: "ftp://pdp.example.com/access/v1/evaluation"}, + {name: "missing host", endpoint: "http:///access/v1/evaluation"}, + {name: "relative", endpoint: "/access/v1/evaluation"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateEndpoint(tt.endpoint) + if tt.valid { + require.NoError(t, err) + return + } + require.EqualError(t, err, "endpoint must be an absolute URL") + }) + } +} + +func TestFromRequestNormalizesConnection(t *testing.T) { + connection := FromRequest(ConnectionRequest{ + Name: "PDP", + Endpoint: "https://pdp.example.com", + BatchEndpoint: "https://pdp.example.com/batch", + SubjectProperties: "email, groups email", + SubjectPropertyMappings: "email: mail, invalid, groups: roles", + SubjectAttributeMappings: []SubjectAttributeMapping{{ + UserType: " Customer ", + Attributes: []SubjectAttributeRow{ + {Attribute: " email ", PDPAttribute: " mail "}, + {Attribute: ""}, + }, + }}, + }) + + require.Equal(t, []string{"email", "groups"}, connection.SubjectProperties) + require.Equal(t, "https://pdp.example.com/batch", connection.BatchEndpoint) + require.Equal(t, map[string]string{"email": "mail", "groups": "roles"}, connection.SubjectPropertyMappings) + require.Equal(t, DefaultTimeoutMS(), connection.TimeoutMS) + require.Zero(t, connection.RetryCount) + require.Equal(t, "Customer", connection.SubjectAttributeMappings[0].UserType) +} + +func TestToResponseSerializesConnection(t *testing.T) { + response := ToResponse(AuthZENPDPConnection{ + ID: "pdp-1", + Name: "PDP", + Endpoint: "https://pdp.example.com/access/v1/evaluation", + BatchEndpoint: "https://pdp.example.com/access/v1/evaluations", + TimeoutMS: 1000, + RetryCount: 2, + SubjectProperties: []string{"groups", "email"}, + SubjectPropertyMappings: map[string]string{"groups": "roles", "email": "mail"}, + SubjectAttributeMappings: []SubjectAttributeMapping{{UserType: "Customer"}}, + }) + + require.Equal(t, "pdp-1", response.ID) + require.Equal(t, VendorName, response.Type) + require.Equal(t, "https://pdp.example.com/access/v1/evaluation", response.Endpoint) + require.Equal(t, "https://pdp.example.com/access/v1/evaluations", response.BatchEndpoint) + require.Equal(t, "groups email", response.SubjectProperties) + require.Equal(t, "email: mail, groups: roles", response.SubjectPropertyMappings) +} + +func TestSubjectPropertyMappingsParsingAndJoining(t *testing.T) { + require.Nil(t, SplitSubjectPropertyMappings(" , invalid, : missing ")) + require.Equal(t, map[string]string{"email": "mail", "groups": "roles"}, + SplitSubjectPropertyMappings("email: mail, groups: roles, invalid")) + require.Equal(t, "email: mail, groups: roles", JoinSubjectPropertyMappings(map[string]string{ + "groups": "roles", "email": "mail", "": "ignored", + })) + require.Empty(t, JoinSubjectPropertyMappings(nil)) +} + +func TestRuntimeConfigPreservesSubjectAttributeMappings(t *testing.T) { + connection := AuthZENPDPConnection{ + SubjectAttributeMappings: []SubjectAttributeMapping{ + { + UserType: "Agent", + Attributes: []SubjectAttributeRow{{Attribute: "status", PDPAttribute: "agent_status"}}, + }, + { + UserType: "Customer", + Attributes: []SubjectAttributeRow{{Attribute: "status", PDPAttribute: "customer_status"}}, + }, + }, + } + + groups := cloneSubjectAttributeMappings(connection.SubjectAttributeMappings) + groups[0].Attributes[0].PDPAttribute = "changed" + + require.Equal(t, "agent_status", connection.SubjectAttributeMappings[0].Attributes[0].PDPAttribute) + require.Equal(t, "customer_status", connection.SubjectAttributeMappings[1].Attributes[0].PDPAttribute) +} diff --git a/backend/internal/connection/authzenpdp/service.go b/backend/internal/connection/authzenpdp/service.go new file mode 100644 index 0000000000..ffdc88ee53 --- /dev/null +++ b/backend/internal/connection/authzenpdp/service.go @@ -0,0 +1,108 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package authzenpdp + +import ( + "context" + "fmt" + "net/url" + "strings" +) + +// Service provides storage operations for external AuthZEN PDP connections. +type Service struct { + store Store +} + +// NewService creates an AuthZEN PDP connection service. +func NewService(store Store) *Service { + return &Service{store: store} +} + +// GetAuthZENPDPRuntimeConfig returns a saved AuthZEN PDP connection for token-issuance routing. +var GetAuthZENPDPRuntimeConfig = getAuthZENPDPRuntimeConfig + +func getAuthZENPDPRuntimeConfig(ctx context.Context, id string) (*AuthZENPDPRuntimeConfig, error) { + connection, err := NewStore().Get(ctx, id) + if err != nil || connection == nil { + return nil, err + } + return &AuthZENPDPRuntimeConfig{ + ID: connection.ID, + Name: connection.Name, + Endpoint: strings.TrimSpace(connection.Endpoint), + BatchEndpoint: connection.BatchEndpoint, + TimeoutMS: connection.TimeoutMS, + RetryCount: connection.RetryCount, + SubjectProperties: append([]string(nil), connection.SubjectProperties...), + SubjectPropertyMappings: cloneStringMap(connection.SubjectPropertyMappings), + SubjectAttributeMappings: cloneSubjectAttributeMappings(connection.SubjectAttributeMappings), + }, nil +} + +// ValidateEndpoint reports whether endpoint is an absolute HTTP or HTTPS URL. +func ValidateEndpoint(endpoint string) error { + parsedEndpoint, err := url.Parse(endpoint) + if err != nil || parsedEndpoint.Host == "" || + (parsedEndpoint.Scheme != "http" && parsedEndpoint.Scheme != "https") { + return fmt.Errorf("endpoint must be an absolute URL") + } + return nil +} + +func validateConnection(connection AuthZENPDPConnection) error { + if err := ValidateEndpoint(connection.Endpoint); err != nil { + return fmt.Errorf("invalid access evaluation endpoint: %w", err) + } + if err := ValidateEndpoint(connection.BatchEndpoint); err != nil { + return fmt.Errorf("invalid access evaluations endpoint: %w", err) + } + return nil +} + +// NormalizeEndpoints trims and validates the configured AuthZEN PDP endpoints. +func NormalizeEndpoints(connection *AuthZENPDPConnection) error { + if connection == nil { + return fmt.Errorf("connection is required") + } + connection.Endpoint = strings.TrimSpace(connection.Endpoint) + connection.BatchEndpoint = strings.TrimSpace(connection.BatchEndpoint) + return validateConnection(*connection) +} + +// Create stores an external AuthZEN PDP connection. +func (s *Service) Create(ctx context.Context, connection AuthZENPDPConnection) error { + if err := validateConnection(connection); err != nil { + return err + } + return s.store.Create(ctx, connection) +} + +// List returns all external AuthZEN PDP connections. +func (s *Service) List(ctx context.Context) ([]AuthZENPDPConnection, error) { + return s.store.List(ctx) +} + +// Get returns an external AuthZEN PDP connection by ID. +func (s *Service) Get(ctx context.Context, id string) (*AuthZENPDPConnection, error) { + return s.store.Get(ctx, id) +} + +// GetByName returns an external AuthZEN PDP connection by name. +func (s *Service) GetByName(ctx context.Context, name string) (*AuthZENPDPConnection, error) { + return s.store.GetByName(ctx, name) +} + +// Update replaces an external AuthZEN PDP connection. +func (s *Service) Update(ctx context.Context, id string, connection AuthZENPDPConnection) error { + if err := validateConnection(connection); err != nil { + return err + } + return s.store.Update(ctx, id, connection) +} + +// Delete removes an external AuthZEN PDP connection. +func (s *Service) Delete(ctx context.Context, id string) error { + return s.store.Delete(ctx, id) +} diff --git a/backend/internal/connection/authzenpdp/service_test.go b/backend/internal/connection/authzenpdp/service_test.go new file mode 100644 index 0000000000..baaa4a6275 --- /dev/null +++ b/backend/internal/connection/authzenpdp/service_test.go @@ -0,0 +1,104 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package authzenpdp + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +type serviceStoreStub struct { + connection AuthZENPDPConnection + connections []AuthZENPDPConnection + id string + createCalls int + updateCalls int + err error +} + +func (s *serviceStoreStub) Create(_ context.Context, connection AuthZENPDPConnection) error { + s.createCalls++ + s.connection = connection + return s.err +} + +func (s *serviceStoreStub) List(context.Context) ([]AuthZENPDPConnection, error) { + return s.connections, s.err +} + +func (s *serviceStoreStub) Get(_ context.Context, id string) (*AuthZENPDPConnection, error) { + s.id = id + if s.err != nil { + return nil, s.err + } + return &s.connection, nil +} + +func (s *serviceStoreStub) GetByName(_ context.Context, name string) (*AuthZENPDPConnection, error) { + if s.connection.Name == name { + return &s.connection, nil + } + return nil, nil +} + +func (s *serviceStoreStub) Update(_ context.Context, id string, connection AuthZENPDPConnection) error { + s.updateCalls++ + s.id = id + s.connection = connection + return s.err +} + +func (s *serviceStoreStub) Delete(_ context.Context, id string) error { + s.id = id + return s.err +} + +func TestServiceDelegatesStoreOperations(t *testing.T) { + ctx := context.Background() + store := &serviceStoreStub{ + connection: AuthZENPDPConnection{ID: "pdp-1"}, + connections: []AuthZENPDPConnection{{ID: "pdp-1"}}, + } + service := NewService(store) + connection := AuthZENPDPConnection{ + ID: "pdp-2", + Name: "External PDP", + Endpoint: "https://pdp.example.com/evaluation", + BatchEndpoint: "https://pdp.example.com/evaluations", + } + + require.NoError(t, service.Create(ctx, connection)) + require.Equal(t, connection, store.connection) + + connections, err := service.List(ctx) + require.NoError(t, err) + require.Equal(t, store.connections, connections) + + result, err := service.Get(ctx, "pdp-1") + require.NoError(t, err) + require.Equal(t, "pdp-1", store.id) + require.Equal(t, store.connection, *result) + + require.NoError(t, service.Update(ctx, "pdp-3", connection)) + require.Equal(t, "pdp-3", store.id) + require.NoError(t, service.Delete(ctx, "pdp-4")) + require.Equal(t, "pdp-4", store.id) +} + +func TestServiceRejectsInvalidConnectionBeforePersistence(t *testing.T) { + store := &serviceStoreStub{} + service := NewService(store) + connection := AuthZENPDPConnection{ + Name: "External PDP", + Endpoint: "https://pdp.example.com/evaluation", + BatchEndpoint: "ftp://pdp.example.com/evaluations", + } + + require.Error(t, service.Create(context.Background(), connection)) + require.Error(t, service.Update(context.Background(), "pdp-1", connection)) + require.Zero(t, store.createCalls) + require.Zero(t, store.updateCalls) +} diff --git a/backend/internal/connection/authzenpdp/store.go b/backend/internal/connection/authzenpdp/store.go new file mode 100644 index 0000000000..0f7ecef477 --- /dev/null +++ b/backend/internal/connection/authzenpdp/store.go @@ -0,0 +1,194 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package authzenpdp + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/thunder-id/thunderid/internal/system/config" + "github.com/thunder-id/thunderid/internal/system/database/provider" +) + +// Store persists external AuthZEN PDP connections. +type Store interface { + Create(ctx context.Context, connection AuthZENPDPConnection) error + List(ctx context.Context) ([]AuthZENPDPConnection, error) + Get(ctx context.Context, id string) (*AuthZENPDPConnection, error) + GetByName(ctx context.Context, name string) (*AuthZENPDPConnection, error) + Update(ctx context.Context, id string, connection AuthZENPDPConnection) error + Delete(ctx context.Context, id string) error +} + +type authZENPDPStore struct { + dbProvider provider.DBProviderInterface + deploymentID string +} + +// NewStore creates a store for external AuthZEN PDP connections. +func NewStore() Store { + return &authZENPDPStore{ + dbProvider: provider.GetDBProvider(), + deploymentID: config.GetServerRuntime().Config.Server.Identifier, + } +} + +// Create persists an external AuthZEN PDP connection. +func (s *authZENPDPStore) Create(ctx context.Context, connection AuthZENPDPConnection) error { + dbClient, err := s.dbProvider.GetConfigDBClient() + if err != nil { + return fmt.Errorf("failed to get database client: %w", err) + } + properties, err := encodeAuthZENPDPProperties(connection) + if err != nil { + return err + } + _, err = dbClient.ExecuteContext(ctx, queryCreateAuthZENPDPConnection, + connection.ID, connection.Name, connection.Description, properties, s.deploymentID) + return err +} + +// List retrieves all external AuthZEN PDP connections for the deployment. +func (s *authZENPDPStore) List(ctx context.Context) ([]AuthZENPDPConnection, error) { + dbClient, err := s.dbProvider.GetConfigDBClient() + if err != nil { + return nil, fmt.Errorf("failed to get database client: %w", err) + } + rows, err := dbClient.QueryContext(ctx, queryListAuthZENPDPConnections, s.deploymentID) + if err != nil { + return nil, err + } + connections := make([]AuthZENPDPConnection, 0, len(rows)) + for _, row := range rows { + connection, err := buildAuthZENPDPConnection(row) + if err != nil { + return nil, err + } + connections = append(connections, connection) + } + return connections, nil +} + +// Get retrieves an external AuthZEN PDP connection by ID. +func (s *authZENPDPStore) Get(ctx context.Context, id string) (*AuthZENPDPConnection, error) { + dbClient, err := s.dbProvider.GetConfigDBClient() + if err != nil { + return nil, fmt.Errorf("failed to get database client: %w", err) + } + rows, err := dbClient.QueryContext(ctx, queryGetAuthZENPDPConnectionByID, id, s.deploymentID) + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, nil + } + connection, err := buildAuthZENPDPConnection(rows[0]) + if err != nil { + return nil, err + } + return &connection, nil +} + +// GetByName retrieves an external AuthZEN PDP connection by name. +func (s *authZENPDPStore) GetByName(ctx context.Context, name string) (*AuthZENPDPConnection, error) { + dbClient, err := s.dbProvider.GetConfigDBClient() + if err != nil { + return nil, fmt.Errorf("failed to get database client: %w", err) + } + rows, err := dbClient.QueryContext(ctx, queryGetAuthZENPDPConnectionByName, name, s.deploymentID) + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, nil + } + connection, err := buildAuthZENPDPConnection(rows[0]) + if err != nil { + return nil, err + } + return &connection, nil +} + +// Update replaces an external AuthZEN PDP connection by ID. +func (s *authZENPDPStore) Update(ctx context.Context, id string, connection AuthZENPDPConnection) error { + dbClient, err := s.dbProvider.GetConfigDBClient() + if err != nil { + return fmt.Errorf("failed to get database client: %w", err) + } + properties, err := encodeAuthZENPDPProperties(connection) + if err != nil { + return err + } + _, err = dbClient.ExecuteContext(ctx, queryUpdateAuthZENPDPConnection, + connection.Name, connection.Description, properties, id, s.deploymentID) + return err +} + +// Delete removes an external AuthZEN PDP connection by ID. +func (s *authZENPDPStore) Delete(ctx context.Context, id string) error { + dbClient, err := s.dbProvider.GetConfigDBClient() + if err != nil { + return fmt.Errorf("failed to get database client: %w", err) + } + _, err = dbClient.ExecuteContext(ctx, queryDeleteAuthZENPDPConnection, id, s.deploymentID) + return err +} + +// authZENPDPProperties holds the connection settings stored as JSON. +type authZENPDPProperties struct { + Endpoint string `json:"endpoint"` + BatchEndpoint string `json:"batchEndpoint"` + TimeoutMS int `json:"timeoutMs"` + RetryCount int `json:"retryCount"` + SubjectProperties []string `json:"subjectProperties,omitempty"` + SubjectPropertyMappings map[string]string `json:"subjectPropertyMappings,omitempty"` + SubjectAttributeMappings []SubjectAttributeMapping `json:"subjectAttributeMappings,omitempty"` +} + +func encodeAuthZENPDPProperties(connection AuthZENPDPConnection) (string, error) { + data, err := json.Marshal(authZENPDPProperties{ + Endpoint: connection.Endpoint, BatchEndpoint: connection.BatchEndpoint, + TimeoutMS: connection.TimeoutMS, RetryCount: connection.RetryCount, + SubjectProperties: connection.SubjectProperties, + SubjectPropertyMappings: connection.SubjectPropertyMappings, + SubjectAttributeMappings: connection.SubjectAttributeMappings, + }) + if err != nil { + return "", fmt.Errorf("failed to encode AuthZEN PDP properties: %w", err) + } + return string(data), nil +} + +func buildAuthZENPDPConnection(row map[string]interface{}) (AuthZENPDPConnection, error) { + properties := authZENPDPProperties{TimeoutMS: DefaultTimeoutMS(), RetryCount: DefaultRetryCount()} + if err := json.Unmarshal([]byte(stringValue(row["properties"])), &properties); err != nil { + return AuthZENPDPConnection{}, fmt.Errorf("failed to decode AuthZEN PDP properties: %w", err) + } + if properties.TimeoutMS <= 0 { + properties.TimeoutMS = DefaultTimeoutMS() + } + if properties.RetryCount < 0 { + properties.RetryCount = DefaultRetryCount() + } + return AuthZENPDPConnection{ + ID: stringValue(row["id"]), Name: stringValue(row["name"]), Description: stringValue(row["description"]), + Endpoint: properties.Endpoint, BatchEndpoint: properties.BatchEndpoint, + TimeoutMS: properties.TimeoutMS, RetryCount: properties.RetryCount, + SubjectProperties: properties.SubjectProperties, + SubjectPropertyMappings: properties.SubjectPropertyMappings, + SubjectAttributeMappings: properties.SubjectAttributeMappings, + }, nil +} + +func stringValue(value interface{}) string { + switch v := value.(type) { + case string: + return v + case []byte: + return string(v) + default: + return "" + } +} diff --git a/backend/internal/connection/authzenpdp/store_constants.go b/backend/internal/connection/authzenpdp/store_constants.go new file mode 100644 index 0000000000..f945ee364d --- /dev/null +++ b/backend/internal/connection/authzenpdp/store_constants.go @@ -0,0 +1,58 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package authzenpdp + +import dbmodel "github.com/thunder-id/thunderid/internal/system/database/model" + +var ( + queryCreateAuthZENPDPConnection = dbmodel.DBQuery{ + ID: "CON-AUTHZEN-PDP-01", + Query: `INSERT INTO "AUTHZEN_PDP_CONNECTION" + (ID, NAME, DESCRIPTION, PROPERTIES, DEPLOYMENT_ID) + VALUES ($1, $2, $3, $4, $5)`, + } + + queryGetAuthZENPDPConnectionByID = dbmodel.DBQuery{ + ID: "CON-AUTHZEN-PDP-02", + Query: `SELECT ID, NAME, DESCRIPTION, PROPERTIES + FROM "AUTHZEN_PDP_CONNECTION" + WHERE ID = $1 AND DEPLOYMENT_ID = $2`, + } + + queryGetAuthZENPDPConnectionByName = dbmodel.DBQuery{ + ID: "CON-AUTHZEN-PDP-06", + Query: `SELECT ID, NAME, DESCRIPTION, PROPERTIES + FROM "AUTHZEN_PDP_CONNECTION" + WHERE NAME = $1 AND DEPLOYMENT_ID = $2`, + } + + queryListAuthZENPDPConnections = dbmodel.DBQuery{ + ID: "CON-AUTHZEN-PDP-03", + Query: `SELECT ID, NAME, DESCRIPTION, PROPERTIES + FROM "AUTHZEN_PDP_CONNECTION" + WHERE DEPLOYMENT_ID = $1 + ORDER BY NAME ASC, ID ASC`, + } + + queryUpdateAuthZENPDPConnection = dbmodel.DBQuery{ + ID: "CON-AUTHZEN-PDP-04", + PostgresQuery: `UPDATE "AUTHZEN_PDP_CONNECTION" + SET NAME = $1, DESCRIPTION = $2, PROPERTIES = $3, + UPDATED_AT = NOW() + WHERE ID = $4 AND DEPLOYMENT_ID = $5`, + SQLiteQuery: `UPDATE "AUTHZEN_PDP_CONNECTION" + SET NAME = $1, DESCRIPTION = $2, PROPERTIES = $3, + UPDATED_AT = datetime('now') + WHERE ID = $4 AND DEPLOYMENT_ID = $5`, + Query: `UPDATE "AUTHZEN_PDP_CONNECTION" + SET NAME = $1, DESCRIPTION = $2, PROPERTIES = $3, + UPDATED_AT = datetime('now') + WHERE ID = $4 AND DEPLOYMENT_ID = $5`, + } + + queryDeleteAuthZENPDPConnection = dbmodel.DBQuery{ + ID: "CON-AUTHZEN-PDP-05", + Query: `DELETE FROM "AUTHZEN_PDP_CONNECTION" WHERE ID = $1 AND DEPLOYMENT_ID = $2`, + } +) diff --git a/backend/internal/connection/authzenpdp/store_test.go b/backend/internal/connection/authzenpdp/store_test.go new file mode 100644 index 0000000000..b9033a3eeb --- /dev/null +++ b/backend/internal/connection/authzenpdp/store_test.go @@ -0,0 +1,57 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package authzenpdp + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAuthZENPDPConnectionSettingsRoundTripThroughProperties(t *testing.T) { + expected := AuthZENPDPConnection{ + ID: "pdp-1", Name: "PDP", Description: "Test connection", + Endpoint: "https://pdp.example.com/evaluation", + BatchEndpoint: "https://pdp.example.com/evaluations", + TimeoutMS: 1500, RetryCount: 0, + SubjectProperties: []string{"email"}, + SubjectPropertyMappings: map[string]string{"email": "mail"}, + SubjectAttributeMappings: []SubjectAttributeMapping{{ + UserType: "Customer", + Attributes: []SubjectAttributeRow{{Attribute: "status", PDPAttribute: "account_status"}}, + }}, + } + properties, err := encodeAuthZENPDPProperties(expected) + require.NoError(t, err) + actual, err := buildAuthZENPDPConnection(map[string]interface{}{ + "id": []byte(expected.ID), "name": expected.Name, "description": expected.Description, + "properties": []byte(properties), + }) + require.NoError(t, err) + require.Equal(t, expected, actual) +} + +func TestBuildAuthZENPDPConnectionDefaults(t *testing.T) { + for _, raw := range []string{`{}`, `{"timeoutMs":-1,"retryCount":-1}`} { + t.Run(raw, func(t *testing.T) { + connection, err := buildAuthZENPDPConnection(map[string]interface{}{"properties": raw}) + require.NoError(t, err) + require.Equal(t, DefaultTimeoutMS(), connection.TimeoutMS) + require.Equal(t, DefaultRetryCount(), connection.RetryCount) + }) + } +} + +func TestBuildAuthZENPDPConnectionRejectsMalformedProperties(t *testing.T) { + for _, raw := range []string{"", "{", `{"timeoutMs":"invalid"}`} { + _, err := buildAuthZENPDPConnection(map[string]interface{}{"properties": raw}) + require.Error(t, err) + } +} + +func TestStringValueSupportsDatabaseValueTypes(t *testing.T) { + require.Equal(t, "value", stringValue("value")) + require.Equal(t, "value", stringValue([]byte("value"))) + require.Empty(t, stringValue(42)) +} diff --git a/backend/internal/connection/connection_declarative_model.go b/backend/internal/connection/connection_declarative_model.go index fed10d2af4..35e6dc5661 100644 --- a/backend/internal/connection/connection_declarative_model.go +++ b/backend/internal/connection/connection_declarative_model.go @@ -3,7 +3,10 @@ package connection -import "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +import ( + "github.com/thunder-id/thunderid/internal/connection/authzenpdp" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) // connectionExportModel is the unified declarative/export representation of a connection, // matching the /connections API's typed, vendor-scoped shape (as opposed to the legacy @@ -35,6 +38,23 @@ type connectionExportModel struct { //nolint:lll // long struct tag: both yaml and json keys needed for declarative load/export and import AttributeConfiguration *providers.AttributeConfiguration `yaml:"attributeConfiguration,omitempty" json:"attributeConfiguration,omitempty"` + // AuthZEN PDP connection fields. + //nolint:lll // long struct tag: both yaml and json keys are required + Endpoint string `yaml:"endpoint,omitempty" json:"endpoint,omitempty"` + //nolint:lll // long struct tag: both yaml and json keys are required + BatchEndpoint string `yaml:"batchEndpoint,omitempty" json:"batchEndpoint,omitempty"` + //nolint:lll // long struct tag: both yaml and json keys are required + TimeoutMS int `yaml:"timeoutMs,omitempty" json:"timeoutMs,omitempty"` + //nolint:lll // long struct tag: both yaml and json keys are required + RetryCount *int `yaml:"retryCount,omitempty" json:"retryCount,omitempty"` + //nolint:lll // long struct tag: both yaml and json keys are required + SubjectProperties []string `yaml:"subjectProperties,omitempty" json:"subjectProperties,omitempty"` + //nolint:lll // long struct tag: both yaml and json keys are required + SubjectPropertyMappings string `yaml:"subjectPropertyMappings,omitempty" json:"subjectPropertyMappings,omitempty"` + //nolint:lll // long struct tag: both yaml and json keys are required + SubjectAttributeMappings []authzenpdp.SubjectAttributeMapping `yaml:"subjectAttributeMappings,omitempty" json:"subjectAttributeMappings,omitempty"` + //nolint:lll // long struct tag: both yaml and json keys are required + // SMS-backed vendor fields (twilio, vonage, sms-gateway). AccountSID string `yaml:"accountSid,omitempty" json:"accountSid,omitempty"` AuthToken string `yaml:"authToken,omitempty" json:"authToken,omitempty"` diff --git a/backend/internal/connection/declarative_resource.go b/backend/internal/connection/declarative_resource.go index 1191845570..365651ca15 100644 --- a/backend/internal/connection/declarative_resource.go +++ b/backend/internal/connection/declarative_resource.go @@ -9,6 +9,7 @@ import ( "strconv" "testing" + "github.com/thunder-id/thunderid/internal/connection/authzenpdp" "github.com/thunder-id/thunderid/internal/idp" "github.com/thunder-id/thunderid/internal/notification" ncommon "github.com/thunder-id/thunderid/internal/notification/common" @@ -34,14 +35,24 @@ const ( // that matches the /connections API and console — replacing the legacy "identity_provider" and // "notification_sender" resource types. type connectionExporter struct { - idpService idp.IDPServiceInterface - senderService notification.NotificationSenderMgtSvcInterface + idpService idp.IDPServiceInterface + senderService notification.NotificationSenderMgtSvcInterface + authZENPDPService *authzenpdp.Service } // newConnectionExporter creates a new connection exporter. func newConnectionExporter(idpService idp.IDPServiceInterface, - senderService notification.NotificationSenderMgtSvcInterface) *connectionExporter { - return &connectionExporter{idpService: idpService, senderService: senderService} + senderService notification.NotificationSenderMgtSvcInterface, + authZENPDPServices ...*authzenpdp.Service) *connectionExporter { + var authZENPDPService *authzenpdp.Service + if len(authZENPDPServices) > 0 { + authZENPDPService = authZENPDPServices[0] + } + return &connectionExporter{ + idpService: idpService, + senderService: senderService, + authZENPDPService: authZENPDPService, + } } // NewConnectionExporterForTest creates a new connection exporter for testing purposes. @@ -91,6 +102,16 @@ func (e *connectionExporter) GetAllResourceIDs(ctx context.Context) ([]string, * } } + if e.authZENPDPService != nil { + pdpConnections, err := e.authZENPDPService.List(ctx) + if err != nil { + return nil, &tidcommon.InternalServerError + } + for _, connection := range pdpConnections { + ids = append(ids, connection.ID) + } + } + return ids, nil } @@ -117,14 +138,29 @@ func (e *connectionExporter) GetResourceByID(ctx context.Context, id string) ( } senderDTO, svcErr := e.senderService.GetSender(ctx, id) - if svcErr != nil { + if svcErr == nil { + model, err := connectionModelFromSenderDTO(*senderDTO) + if err != nil { + return nil, "", &tidcommon.InternalServerError + } + return &model, model.Name, nil + } + if svcErr.Code != notification.ErrorSenderNotFound.Code { return nil, "", svcErr } - model, err := connectionModelFromSenderDTO(*senderDTO) - if err != nil { - return nil, "", &tidcommon.InternalServerError + + if e.authZENPDPService != nil { + pdpConnection, err := e.authZENPDPService.Get(ctx, id) + if err != nil { + return nil, "", &tidcommon.InternalServerError + } + if pdpConnection != nil { + model := connectionModelFromAuthZENPDP(*pdpConnection) + return &model, model.Name, nil + } } - return &model, model.Name, nil + + return nil, "", svcErr } // ValidateResource validates a connection resource prior to export. @@ -172,6 +208,8 @@ func (e *connectionExporter) GetResourceRulesForResource( return &declarativeresource.ResourceRules{Variables: []string{"AuthToken"}} case "vonage": return &declarativeresource.ResourceRules{Variables: []string{"APISecret"}} + case authzenpdp.VendorName: + return &declarativeresource.ResourceRules{} default: // sms-gateway (and any future no-secret vendor) has nothing to externalize. return &declarativeresource.ResourceRules{} @@ -279,6 +317,23 @@ func connectionModelFromSenderDTO(dto ncommon.NotificationSenderDTO) (connection return model, nil } +// connectionModelFromAuthZENPDP builds the unified export model from an AuthZEN PDP connection. +func connectionModelFromAuthZENPDP(connection authzenpdp.AuthZENPDPConnection) connectionExportModel { + return connectionExportModel{ + ID: connection.ID, + Type: authzenpdp.VendorName, + Name: connection.Name, + Description: connection.Description, + Endpoint: connection.Endpoint, + BatchEndpoint: connection.BatchEndpoint, + TimeoutMS: connection.TimeoutMS, + RetryCount: &connection.RetryCount, + SubjectProperties: append([]string(nil), connection.SubjectProperties...), + SubjectPropertyMappings: authzenpdp.JoinSubjectPropertyMappings(connection.SubjectPropertyMappings), + SubjectAttributeMappings: connection.SubjectAttributeMappings, + } +} + // connectionModelToDTO converts a parsed connection document into the underlying // identity-provider or notification-sender DTO, dispatching on the vendor discriminator. // Exactly one of the two returned DTOs is non-nil. @@ -370,6 +425,30 @@ func connectionModelToDTO(model connectionExportModel) (*providers.IDPDTO, *ncom } } +// connectionModelToAuthZENPDP converts the unified connection export model into an AuthZEN PDP connection. +func connectionModelToAuthZENPDP(model connectionExportModel) *authzenpdp.AuthZENPDPConnection { + connection := authzenpdp.AuthZENPDPConnection{ + ID: model.ID, + Name: model.Name, + Description: model.Description, + Endpoint: model.Endpoint, + BatchEndpoint: model.BatchEndpoint, + TimeoutMS: model.TimeoutMS, + RetryCount: authzenpdp.DefaultRetryCount(), + SubjectProperties: append([]string(nil), model.SubjectProperties...), + SubjectPropertyMappings: authzenpdp.SplitSubjectPropertyMappings(model.SubjectPropertyMappings), + SubjectAttributeMappings: model.SubjectAttributeMappings, + } + if model.RetryCount != nil { + connection.RetryCount = *model.RetryCount + } + if connection.TimeoutMS == 0 { + connection.TimeoutMS = authzenpdp.DefaultTimeoutMS() + } + connection.SubjectProperties, connection.SubjectPropertyMappings = authzenpdp.NormalizedSubjectMapping(connection) + return &connection +} + // ParseConnectionFromNode decodes a yaml.Node into the underlying identity-provider or // notification-sender DTO, dispatching on the vendor discriminator. Used by the runtime import // service. Exactly one of the two returned DTOs is non-nil. @@ -381,6 +460,18 @@ func ParseConnectionFromNode(node *yaml.Node) (*providers.IDPDTO, *ncommon.Notif return connectionModelToDTO(model) } +// ParseAuthZENPDPConnectionFromNode decodes an AuthZEN PDP connection document. +func ParseAuthZENPDPConnectionFromNode(node *yaml.Node) (*authzenpdp.AuthZENPDPConnection, error) { + var model connectionExportModel + if err := node.Decode(&model); err != nil { + return nil, fmt.Errorf("failed to parse connection document: %w", err) + } + if model.Type != authzenpdp.VendorName { + return nil, nil + } + return connectionModelToAuthZENPDP(model), nil +} + // parseToConnectionDTOWrapper wraps connectionModelToDTO to match ResourceConfig.Parser, // returning whichever of the two underlying DTOs the document's vendor maps to. func parseToConnectionDTOWrapper(data []byte) (interface{}, error) { @@ -388,6 +479,9 @@ func parseToConnectionDTOWrapper(data []byte) (interface{}, error) { if err := yaml.Unmarshal(data, &model); err != nil { return nil, err } + if model.Type == authzenpdp.VendorName { + return connectionModelToAuthZENPDP(model), nil + } idpDTO, senderDTO, err := connectionModelToDTO(model) if err != nil { return nil, err @@ -406,6 +500,8 @@ func connectionResourceID(dto interface{}) string { return d.ID case *ncommon.NotificationSenderDTO: return d.ID + case *authzenpdp.AuthZENPDPConnection: + return d.ID default: return "" } @@ -441,6 +537,10 @@ func validateConnectionDTOWrapper(dto interface{}, idpService idp.IDPServiceInte if d.Name == "" { return fmt.Errorf("connection resource %q is missing a name", d.ID) } + case *authzenpdp.AuthZENPDPConnection: + if d.Name == "" || d.Endpoint == "" || d.BatchEndpoint == "" { + return fmt.Errorf("connection resource %q requires a name, endpoint, and batchEndpoint", d.ID) + } } return nil } @@ -451,8 +551,9 @@ func validateConnectionDTOWrapper(dto interface{}, idpService idp.IDPServiceInte // the idp/notification services read via composite/declarative store modes — see // declarativeresource.GenericFileBasedStore, keyed by entity.KeyTypeIDP / KeyTypeNotificationSender. type connectionDeclarativeStore struct { - idpStore *declarativeresource.GenericFileBasedStore - senderStore *declarativeresource.GenericFileBasedStore + idpStore *declarativeresource.GenericFileBasedStore + senderStore *declarativeresource.GenericFileBasedStore + authZENPDPService *authzenpdp.Service } // Create implements declarativeresource.Storer, routing to the store matching the DTO type. @@ -468,6 +569,18 @@ func (s *connectionDeclarativeStore) Create(id string, data interface{}) error { return s.idpStore.Create(id, dto) case *ncommon.NotificationSenderDTO: return s.senderStore.Create(id, dto) + case *authzenpdp.AuthZENPDPConnection: + if !declarativeresource.IsDeclarativeModeEnabled() { + return nil + } + if s.authZENPDPService == nil { + return fmt.Errorf("AuthZEN PDP store is not configured") + } + dto.ID = id + if err := authzenpdp.NormalizeEndpoints(dto); err != nil { + return fmt.Errorf("invalid AuthZEN PDP endpoints for connection resource %q", id) + } + return s.authZENPDPService.Create(context.Background(), *dto) default: return fmt.Errorf("unsupported connection resource type: %T", data) } @@ -480,14 +593,16 @@ func (s *connectionDeclarativeStore) Create(id string, data interface{}) error { // (identity_provider.store) calls for loading, or when no connection files are present. // connectionDeclarativeStore.Create further gates IdP-typed documents individually so a // composite/declarative identity_provider.store is honored even when the global flag is off. -func loadDeclarativeResources(idpService idp.IDPServiceInterface) error { +func loadDeclarativeResources(idpService idp.IDPServiceInterface, + authZENPDPService *authzenpdp.Service) error { if !declarativeresource.IsDeclarativeModeEnabled() && !idp.ShouldLoadDeclarativeIDPResources() { return nil } storer := &connectionDeclarativeStore{ - idpStore: declarativeresource.NewGenericFileBasedStore(entity.KeyTypeIDP), - senderStore: declarativeresource.NewGenericFileBasedStore(entity.KeyTypeNotificationSender), + idpStore: declarativeresource.NewGenericFileBasedStore(entity.KeyTypeIDP), + senderStore: declarativeresource.NewGenericFileBasedStore(entity.KeyTypeNotificationSender), + authZENPDPService: authZENPDPService, } resourceConfig := declarativeresource.ResourceConfig{ ResourceType: paramTypeConnection, diff --git a/backend/internal/connection/declarative_resource_test.go b/backend/internal/connection/declarative_resource_test.go index 58d3379674..7bec62ccba 100644 --- a/backend/internal/connection/declarative_resource_test.go +++ b/backend/internal/connection/declarative_resource_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/internal/connection/authzenpdp" "github.com/thunder-id/thunderid/internal/idp" ncommon "github.com/thunder-id/thunderid/internal/notification/common" "github.com/thunder-id/thunderid/internal/system/cmodels" @@ -192,6 +193,39 @@ func (s *DeclarativeResourceTestSuite) TestConnectionModelToDTOUnsupportedVendor s.Error(err) } +func (s *DeclarativeResourceTestSuite) TestAuthZENPDPConnectionExportModelRoundTrip() { + doc := []byte(` +id: pdp-1 +type: authzen-pdp +name: Cerbos PDP +endpoint: http://localhost:3592/access/v1/evaluation +batchEndpoint: http://localhost:3592/access/v1/evaluations +subjectProperties: + - accountStatus +subjectPropertyMappings: "accountStatus: account_status" +subjectAttributeMappings: + - userType: TravelCustomer + attributes: + - attribute: accountStatus + pdpAttribute: account_status +`) + + dto, err := parseToConnectionDTOWrapper(doc) + s.Require().NoError(err) + pdp, ok := dto.(*authzenpdp.AuthZENPDPConnection) + s.Require().True(ok) + s.Equal("pdp-1", pdp.ID) + s.Equal("authzen-pdp", connectionModelFromAuthZENPDP(*pdp).Type) + s.Equal("http://localhost:3592/access/v1/evaluation", pdp.Endpoint) + s.Equal("http://localhost:3592/access/v1/evaluations", pdp.BatchEndpoint) + s.Equal("account_status", pdp.SubjectPropertyMappings["accountStatus"]) + exported := connectionModelFromAuthZENPDP(*pdp) + s.Equal("pdp-1", connectionResourceID(pdp)) + + roundTripped := connectionModelToAuthZENPDP(exported) + s.Require().NotNil(roundTripped) +} + func (s *DeclarativeResourceTestSuite) TestParseConnectionFromNodeIDPVendor() { doc := ` id: corp-google @@ -347,6 +381,7 @@ func (s *DeclarativeResourceTestSuite) TestGetResourceRulesForResourceSecretSele {connectionExportModel{Type: "google"}, nil}, // no secret set -> nothing to externalize {connectionExportModel{Type: "twilio"}, []string{"AuthToken"}}, {connectionExportModel{Type: "vonage"}, []string{"APISecret"}}, + {connectionExportModel{Type: authzenpdp.VendorName}, nil}, {connectionExportModel{Type: smsGatewayVendorName}, nil}, } for _, tc := range cases { @@ -427,6 +462,46 @@ func (s *DeclarativeResourceTestSuite) TestConnectionDeclarativeStoreDispatchesB s.Error(store.Create("bad", "not-a-dto")) } +func (s *DeclarativeResourceTestSuite) TestConnectionDeclarativeStoreStoresAuthZENPDPEndpoints() { + config.ResetServerRuntime() + s.Require().NoError(config.InitializeServerRuntime("/tmp/test", &config.Config{ + DeclarativeResources: config.DeclarativeResources{Enabled: true}, + })) + s.T().Cleanup(config.ResetServerRuntime) + + store := newTestAuthZENPDPStore() + declarativeStore := &connectionDeclarativeStore{authZENPDPService: authzenpdp.NewService(store)} + + dto := &authzenpdp.AuthZENPDPConnection{ + Name: "PDP", + Endpoint: "https://pdp.example.com/access/v1/evaluation", + BatchEndpoint: "https://pdp.example.com/access/v1/evaluations", + } + + s.Require().NoError(declarativeStore.Create("pdp-1", dto)) + s.Equal("https://pdp.example.com/access/v1/evaluation", store.connections["pdp-1"].Endpoint) + s.Equal("https://pdp.example.com/access/v1/evaluations", store.connections["pdp-1"].BatchEndpoint) +} + +func (s *DeclarativeResourceTestSuite) TestConnectionDeclarativeStoreSkipsAuthZENPDPWhenDisabled() { + config.ResetServerRuntime() + s.Require().NoError(config.InitializeServerRuntime("/tmp/test", &config.Config{ + IdentityProvider: config.IdentityProviderConfig{Store: "composite"}, + })) + s.T().Cleanup(config.ResetServerRuntime) + + store := newTestAuthZENPDPStore() + declarativeStore := &connectionDeclarativeStore{authZENPDPService: authzenpdp.NewService(store)} + dto := &authzenpdp.AuthZENPDPConnection{ + Name: "PDP", + Endpoint: "https://pdp.example.com/access/v1/evaluation", + BatchEndpoint: "https://pdp.example.com/access/v1/evaluations", + } + + s.Require().NoError(declarativeStore.Create("pdp-1", dto)) + s.NotContains(store.connections, "pdp-1") +} + // TestConnectionDeclarativeStoreSkipsIDPWhenIDPStoreModeIsMutable verifies that IdP-typed // documents are silently skipped (not an error) when the identity-provider package's own // per-service store mode resolves to mutable, even though the connection package's file diff --git a/backend/internal/connection/error_constants.go b/backend/internal/connection/error_constants.go index 111b4285c3..7633768e66 100644 --- a/backend/internal/connection/error_constants.go +++ b/backend/internal/connection/error_constants.go @@ -20,7 +20,7 @@ var ( }, ErrorDescription: tidcommon.I18nMessage{ Key: "error.connectionservice.invalid_category_description", - DefaultValue: "The category must be one of: identity-provider, sms-provider", + DefaultValue: "The category must be one of: identity-provider, sms-provider, authorization-pdp", }, } // ErrorInvalidLimit is the error returned when an invalid limit query parameter is provided. @@ -49,4 +49,67 @@ var ( DefaultValue: "The offset parameter must be a non-negative integer", }, } + ErrorConnectionNotFound = tidcommon.ServiceError{ + Type: tidcommon.ClientErrorType, + Code: "CON-1004", + Error: tidcommon.I18nMessage{ + Key: "error.connectionservice.connection_not_found", + DefaultValue: "Connection not found", + }, + ErrorDescription: tidcommon.I18nMessage{ + Key: "error.connectionservice.connection_not_found_description", + DefaultValue: "No connection exists for the supplied identifier", + }, + } + ErrorConnectionHasBlockingDependencies = tidcommon.ServiceError{ + Type: tidcommon.ClientErrorType, + Code: "CON-1005", + Error: tidcommon.I18nMessage{ + Key: "error.connectionservice.connection_has_blocking_dependencies", + DefaultValue: "Connection cannot be deleted", + }, + ErrorDescription: tidcommon.I18nMessage{ + Key: "error.connectionservice.connection_has_blocking_dependencies_description", + DefaultValue: "The connection cannot be deleted because other resources depend on it. " + + "Remove or reassign them first.", + }, + } + ErrorInvalidAuthZENPDPEndpoint = tidcommon.ServiceError{ + Type: tidcommon.ClientErrorType, + Code: "CON-1006", + Error: tidcommon.I18nMessage{ + Key: "error.connectionservice.invalid_authzen_pdp_endpoint", + DefaultValue: "Invalid AuthZEN PDP endpoint", + }, + ErrorDescription: tidcommon.I18nMessage{ + Key: "error.connectionservice.invalid_authzen_pdp_endpoint_description", + DefaultValue: "The single and batch evaluation endpoints must be absolute URLs.", + }, + } + // ErrorInvalidRequestFormat is returned when an AuthZEN PDP request body is malformed. + ErrorInvalidRequestFormat = tidcommon.ServiceError{ + Type: tidcommon.ClientErrorType, + Code: "CON-1007", + Error: tidcommon.I18nMessage{ + Key: "error.connectionservice.invalid_request_format", + DefaultValue: "Invalid request format", + }, + ErrorDescription: tidcommon.I18nMessage{ + Key: "error.connectionservice.invalid_request_format_description", + DefaultValue: "The request body is malformed or contains invalid data", + }, + } + // ErrorAuthZENPDPAlreadyExists is returned when an AuthZEN PDP has the same name. + ErrorAuthZENPDPAlreadyExists = tidcommon.ServiceError{ + Type: tidcommon.ClientErrorType, + Code: "CON-1008", + Error: tidcommon.I18nMessage{ + Key: "error.connectionservice.authzen_pdp_already_exists", + DefaultValue: "An AuthZEN PDP connection with the same name already exists", + }, + ErrorDescription: tidcommon.I18nMessage{ + Key: "error.connectionservice.authzen_pdp_already_exists_description", + DefaultValue: "Choose a different name for the AuthZEN PDP connection", + }, + } ) diff --git a/backend/internal/connection/handler.go b/backend/internal/connection/handler.go index 9ed9917d47..0a9abc2834 100644 --- a/backend/internal/connection/handler.go +++ b/backend/internal/connection/handler.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" + "github.com/thunder-id/thunderid/internal/connection/authzenpdp" "github.com/thunder-id/thunderid/internal/idp" "github.com/thunder-id/thunderid/internal/notification" ncommon "github.com/thunder-id/thunderid/internal/notification/common" @@ -244,6 +245,109 @@ func (h *handler) handleListConnections(w http.ResponseWriter, r *http.Request) sysutils.WriteSuccessResponse(ctx, w, http.StatusOK, resp) } +// createAuthZENPDPConnection creates an external AuthZEN PDP connection. +func (h *handler) createAuthZENPDPConnection(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + req, err := sysutils.DecodeJSONBody[authzenpdp.ConnectionRequest](r) + if err != nil { + writeServiceError(ctx, w, &ErrorInvalidRequestFormat) + return + } + created, svcErr := h.svc.createAuthZENPDP(ctx, authzenpdp.FromRequest(*req)) + if svcErr != nil { + writeServiceError(ctx, w, svcErr) + return + } + sysutils.WriteSuccessResponse(ctx, w, http.StatusCreated, authzenpdp.ToResponse(*created)) +} + +// listAuthZENPDPConnections lists configured external AuthZEN PDP connections. +func (h *handler) listAuthZENPDPConnections(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + connections, svcErr := h.svc.listAuthZENPDP(ctx) + if svcErr != nil { + writeServiceError(ctx, w, svcErr) + return + } + summaries := make([]connectionInstanceSummary, 0, len(connections)) + for _, connection := range connections { + summaries = append(summaries, connectionInstanceSummary{ + ID: connection.ID, + Name: connection.Name, + Description: connection.Description, + }) + } + sysutils.WriteSuccessResponse(ctx, w, http.StatusOK, summaries) +} + +// getAuthZENPDPConnection returns an external AuthZEN PDP connection by ID. +func (h *handler) getAuthZENPDPConnection(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := r.PathValue("id") + if strings.TrimSpace(id) == "" { + writeServiceError(ctx, w, &ErrorConnectionNotFound) + return + } + connection, svcErr := h.svc.getAuthZENPDP(ctx, id) + if svcErr != nil { + writeServiceError(ctx, w, svcErr) + return + } + sysutils.WriteSuccessResponse(ctx, w, http.StatusOK, authzenpdp.ToResponse(*connection)) +} + +// updateAuthZENPDPConnection updates an external AuthZEN PDP connection by ID. +func (h *handler) updateAuthZENPDPConnection(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := r.PathValue("id") + if strings.TrimSpace(id) == "" { + writeServiceError(ctx, w, &ErrorConnectionNotFound) + return + } + req, err := sysutils.DecodeJSONBody[authzenpdp.ConnectionRequest](r) + if err != nil { + writeServiceError(ctx, w, &ErrorInvalidRequestFormat) + return + } + updated, svcErr := h.svc.updateAuthZENPDP(ctx, id, authzenpdp.FromRequest(*req)) + if svcErr != nil { + writeServiceError(ctx, w, svcErr) + return + } + sysutils.WriteSuccessResponse(ctx, w, http.StatusOK, authzenpdp.ToResponse(*updated)) +} + +// deleteAuthZENPDPConnection deletes an external AuthZEN PDP connection by ID. +func (h *handler) deleteAuthZENPDPConnection(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := r.PathValue("id") + if strings.TrimSpace(id) == "" { + writeServiceError(ctx, w, &ErrorConnectionNotFound) + return + } + if svcErr := h.svc.deleteAuthZENPDP(ctx, id); svcErr != nil { + writeServiceError(ctx, w, svcErr) + return + } + sysutils.WriteSuccessResponse(ctx, w, http.StatusNoContent, nil) +} + +// usagesAuthZENPDPConnection lists resources that reference an external AuthZEN PDP connection. +func (h *handler) usagesAuthZENPDPConnection(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := r.PathValue("id") + if strings.TrimSpace(id) == "" { + writeServiceError(ctx, w, &ErrorConnectionNotFound) + return + } + usages, svcErr := h.svc.usagesAuthZENPDP(ctx, id) + if svcErr != nil { + writeServiceError(ctx, w, svcErr) + return + } + sysutils.WriteSuccessResponse(ctx, w, http.StatusOK, usages) +} + // createSMSConnection decodes a typed request, maps it to a notification-sender DTO via the // vendor's mapper, delegates creation, and writes the encoded response. func createSMSConnection[Req any, Resp any](h *handler, w http.ResponseWriter, r *http.Request, diff --git a/backend/internal/connection/init.go b/backend/internal/connection/init.go index 748a83be5e..2673069848 100644 --- a/backend/internal/connection/init.go +++ b/backend/internal/connection/init.go @@ -6,6 +6,7 @@ package connection import ( "net/http" + "github.com/thunder-id/thunderid/internal/connection/authzenpdp" "github.com/thunder-id/thunderid/internal/idp" "github.com/thunder-id/thunderid/internal/notification" ncommon "github.com/thunder-id/thunderid/internal/notification/common" @@ -18,17 +19,27 @@ import ( // services, registers the /connections routes, loads declarative connection resources, and // returns the connection exporter for the export API. func Initialize(mux *http.ServeMux, idpService idp.IDPServiceInterface, - notificationService notification.NotificationSenderMgtSvcInterface) ( + notificationService notification.NotificationSenderMgtSvcInterface, + resourceService resourceServerLister) ( declarativeresource.ResourceExporter, error) { - svc := newService(idpService, notificationService) + authZENPDPService := authzenpdp.NewService(authzenpdp.NewStore()) + return initialize(mux, idpService, notificationService, resourceService, authZENPDPService) +} + +func initialize(mux *http.ServeMux, idpService idp.IDPServiceInterface, + notificationService notification.NotificationSenderMgtSvcInterface, + resourceService resourceServerLister, + authZENPDPService *authzenpdp.Service) ( + declarativeresource.ResourceExporter, error) { + svc := newService(idpService, notificationService, resourceService, authZENPDPService) h := newHandler(svc) registerRoutes(mux, h) - if err := loadDeclarativeResources(idpService); err != nil { + if err := loadDeclarativeResources(idpService, authZENPDPService); err != nil { return nil, err } - return newConnectionExporter(idpService, notificationService), nil + return newConnectionExporter(idpService, notificationService, authZENPDPService), nil } func noContent(w http.ResponseWriter, _ *http.Request) { @@ -98,6 +109,8 @@ func registerRoutes(mux *http.ServeMux, h *handler) { getSMSHandler(h, ncommon.MessageProviderTypeCustom, smsGatewayFromSenderDTO), updateSMSHandler(h, ncommon.MessageProviderTypeCustom, smsGatewayToSenderDTO, smsGatewayFromSenderDTO), collectionOpts, itemOpts) + + registerAuthZENPDPVendorRoutes(mux, h, "/connections/"+authzenpdp.VendorName, collectionOpts, itemOpts) } // registerVendorRoutes registers the collection (list/create) and item (get/update/delete) @@ -149,3 +162,30 @@ func registerSMSVendorRoutes(mux *http.ServeMux, h *handler, base string, provid mux.HandleFunc(middleware.WithCORS("GET "+base+"/{id}/usages", h.usagesSMSInstance(provider), usagesOpts)) mux.HandleFunc(middleware.WithCORS("OPTIONS "+base+"/{id}/usages", noContent, usagesOpts)) } + +// registerAuthZENPDPVendorRoutes registers CRUD, CORS, and usage routes for external AuthZEN PDP connections. +func registerAuthZENPDPVendorRoutes( + mux *http.ServeMux, + h *handler, + base string, + collectionOpts middleware.CORSOptions, + itemOpts middleware.CORSOptions, +) { + mux.HandleFunc(middleware.WithCORS("GET "+base, h.listAuthZENPDPConnections, collectionOpts)) + mux.HandleFunc(middleware.WithCORS("POST "+base, h.createAuthZENPDPConnection, collectionOpts)) + mux.HandleFunc(middleware.WithCORS("OPTIONS "+base, noContent, collectionOpts)) + + mux.HandleFunc(middleware.WithCORS("GET "+base+"/{id}", h.getAuthZENPDPConnection, itemOpts)) + mux.HandleFunc(middleware.WithCORS("PUT "+base+"/{id}", h.updateAuthZENPDPConnection, itemOpts)) + mux.HandleFunc(middleware.WithCORS("DELETE "+base+"/{id}", h.deleteAuthZENPDPConnection, itemOpts)) + mux.HandleFunc(middleware.WithCORS("OPTIONS "+base+"/{id}", noContent, itemOpts)) + + usagesOpts := middleware.CORSOptions{ + AllowedMethods: []string{"GET"}, + AllowedHeaders: middleware.DefaultAllowedHeaders, + AllowCredentials: true, + MaxAge: 600, + } + mux.HandleFunc(middleware.WithCORS("GET "+base+"/{id}/usages", h.usagesAuthZENPDPConnection, usagesOpts)) + mux.HandleFunc(middleware.WithCORS("OPTIONS "+base+"/{id}/usages", noContent, usagesOpts)) +} diff --git a/backend/internal/connection/init_test.go b/backend/internal/connection/init_test.go index 43de39b9f9..5c7910c53a 100644 --- a/backend/internal/connection/init_test.go +++ b/backend/internal/connection/init_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/internal/connection/authzenpdp" "github.com/thunder-id/thunderid/internal/idp" ncommon "github.com/thunder-id/thunderid/internal/notification/common" "github.com/thunder-id/thunderid/internal/system/cmodels" @@ -55,7 +56,9 @@ func newConnectionTestHandler(t *testing.T) (*handler, *idpmock.IDPServiceInterf t.Cleanup(config.ResetServerRuntime) mockIDP := idpmock.NewIDPServiceInterfaceMock(t) mockNotif := notificationmock.NewNotificationSenderMgtSvcInterfaceMock(t) - return newHandler(newService(mockIDP, mockNotif)), mockIDP, mockNotif + return newHandler(newService( + mockIDP, mockNotif, &testResourceServerLister{}, authzenpdp.NewService(newTestAuthZENPDPStore()), + )), mockIDP, mockNotif } // mustProperty builds a property, failing the test on error. @@ -72,9 +75,11 @@ func boolPtr(b bool) *bool { return &b } // ServeMux, exercising route registration, CORS/OPTIONS handling, and path-value extraction. type InitTestSuite struct { suite.Suite - mux *http.ServeMux - mockIDP *idpmock.IDPServiceInterfaceMock - mockNotif *notificationmock.NotificationSenderMgtSvcInterfaceMock + mux *http.ServeMux + mockIDP *idpmock.IDPServiceInterfaceMock + mockNotif *notificationmock.NotificationSenderMgtSvcInterfaceMock + mockResource *testResourceServerLister + authZENPDPStore *testAuthZENPDPStore } func TestInitSuite(t *testing.T) { @@ -83,10 +88,13 @@ func TestInitSuite(t *testing.T) { func (s *InitTestSuite) SetupTest() { initConfigWithTestCryptoKey(s.T()) + s.authZENPDPStore = newTestAuthZENPDPStore() s.mockIDP = idpmock.NewIDPServiceInterfaceMock(s.T()) s.mockNotif = notificationmock.NewNotificationSenderMgtSvcInterfaceMock(s.T()) + s.mockResource = &testResourceServerLister{} s.mux = http.NewServeMux() - _, err := Initialize(s.mux, s.mockIDP, s.mockNotif) + _, err := initialize(s.mux, s.mockIDP, s.mockNotif, s.mockResource, + authzenpdp.NewService(s.authZENPDPStore)) s.Require().NoError(err) } @@ -148,6 +156,13 @@ func (s *InitTestSuite) TestRouteTable() { s.mockNotif.On("GetSenderUsages", mock.Anything, "sg-1"). Return(emptyUsages, (*tidcommon.ServiceError)(nil)) + s.authZENPDPStore.connections["pdp-1"] = authzenpdp.AuthZENPDPConnection{ + ID: "pdp-1", + Name: "PDP", + Endpoint: "https://pdp.example.com/access/v1/evaluation", + BatchEndpoint: "https://pdp.example.com/access/v1/evaluations", + } + body, _ := json.Marshal(githubConnectionRequest{ Name: "GH", ClientID: "c", ClientSecret: "s", RedirectURI: "https://app/cb", }) @@ -157,6 +172,14 @@ func (s *InitTestSuite) TestRouteTable() { smsGatewayBody, _ := json.Marshal(smsGatewayConnectionRequest{ Name: "SG", URL: "https://sms.example.com/send", HTTPMethod: "POST", }) + authZENPDPBody, _ := json.Marshal(authzenpdp.ConnectionRequest{ + Name: "PDP-new", Endpoint: "https://pdp.example.com/access/v1/evaluation", + BatchEndpoint: "https://pdp.example.com/access/v1/evaluations", + }) + authZENPDPUpdateBody, _ := json.Marshal(authzenpdp.ConnectionRequest{ + Name: "PDP", Endpoint: "https://pdp.example.com/access/v1/evaluation", + BatchEndpoint: "https://pdp.example.com/access/v1/evaluations", + }) cases := []struct { method, path string @@ -192,6 +215,15 @@ func (s *InitTestSuite) TestRouteTable() { {http.MethodOptions, "/connections/sms-gateway/sg-1", nil, http.StatusNoContent}, {http.MethodGet, "/connections/sms-gateway/sg-1/usages", nil, http.StatusOK}, {http.MethodOptions, "/connections/sms-gateway/sg-1/usages", nil, http.StatusNoContent}, + {http.MethodPost, "/connections/authzen-pdp", authZENPDPBody, http.StatusCreated}, + {http.MethodGet, "/connections/authzen-pdp", nil, http.StatusOK}, + {http.MethodOptions, "/connections/authzen-pdp", nil, http.StatusNoContent}, + {http.MethodGet, "/connections/authzen-pdp/pdp-1", nil, http.StatusOK}, + {http.MethodPut, "/connections/authzen-pdp/pdp-1", authZENPDPUpdateBody, http.StatusOK}, + {http.MethodGet, "/connections/authzen-pdp/pdp-1/usages", nil, http.StatusOK}, + {http.MethodOptions, "/connections/authzen-pdp/pdp-1/usages", nil, http.StatusNoContent}, + {http.MethodDelete, "/connections/authzen-pdp/pdp-1", nil, http.StatusNoContent}, + {http.MethodOptions, "/connections/authzen-pdp/pdp-1", nil, http.StatusNoContent}, } for _, tc := range cases { req := httptest.NewRequest(tc.method, tc.path, bytes.NewReader(tc.body)) diff --git a/backend/internal/connection/mapping.go b/backend/internal/connection/mapping.go index 25e98218a5..a4adeca90f 100644 --- a/backend/internal/connection/mapping.go +++ b/backend/internal/connection/mapping.go @@ -106,12 +106,14 @@ func writeServiceError(ctx context.Context, w http.ResponseWriter, svcErr *tidco status := http.StatusInternalServerError if svcErr.Type == tidcommon.ClientErrorType { switch svcErr.Code { - case idp.ErrorIDPNotFound.Code, notification.ErrorSenderNotFound.Code: + case idp.ErrorIDPNotFound.Code, notification.ErrorSenderNotFound.Code, ErrorConnectionNotFound.Code: status = http.StatusNotFound case idp.ErrorIDPAlreadyExists.Code, idp.ErrorIDPHasBlockingDependencies.Code, notification.ErrorDuplicateSenderName.Code, - notification.ErrorSenderHasBlockingDependencies.Code: + notification.ErrorSenderHasBlockingDependencies.Code, + ErrorConnectionHasBlockingDependencies.Code, + ErrorAuthZENPDPAlreadyExists.Code: status = http.StatusConflict default: status = http.StatusBadRequest diff --git a/backend/internal/connection/mapping_test.go b/backend/internal/connection/mapping_test.go index d1ea91d372..61b8f401b7 100644 --- a/backend/internal/connection/mapping_test.go +++ b/backend/internal/connection/mapping_test.go @@ -119,6 +119,7 @@ func (s *MappingTestSuite) TestWriteServiceErrorStatusMapping() { {¬ification.ErrorDuplicateSenderName, http.StatusConflict}, {¬ification.ErrorSenderHasBlockingDependencies, http.StatusConflict}, {¬ification.ErrorInvalidProvider, http.StatusBadRequest}, + {&ErrorConnectionHasBlockingDependencies, http.StatusConflict}, {&tidcommon.InternalServerError, http.StatusInternalServerError}, } for _, tc := range cases { diff --git a/backend/internal/connection/models.go b/backend/internal/connection/models.go index 38cdb9dd11..db4c50e2e1 100644 --- a/backend/internal/connection/models.go +++ b/backend/internal/connection/models.go @@ -55,13 +55,14 @@ type connectionCategory string const ( categoryIdentityProvider connectionCategory = "identity-provider" categorySMSProvider connectionCategory = "sms-provider" + categoryAuthorizationPDP connectionCategory = "authorization-pdp" ) // parseConnectionCategory validates the raw category query value. Empty means "no filter"; // any other unrecognized value returns false. func parseConnectionCategory(raw string) (connectionCategory, bool) { switch connectionCategory(raw) { - case "", categoryIdentityProvider, categorySMSProvider: + case "", categoryIdentityProvider, categorySMSProvider, categoryAuthorizationPDP: return connectionCategory(raw), true default: return "", false diff --git a/backend/internal/connection/service.go b/backend/internal/connection/service.go index 45edc7edcb..7e0171a2eb 100644 --- a/backend/internal/connection/service.go +++ b/backend/internal/connection/service.go @@ -8,10 +8,13 @@ import ( "sort" "strings" + "github.com/thunder-id/thunderid/internal/connection/authzenpdp" "github.com/thunder-id/thunderid/internal/idp" "github.com/thunder-id/thunderid/internal/notification" ncommon "github.com/thunder-id/thunderid/internal/notification/common" + "github.com/thunder-id/thunderid/internal/resource" serverconst "github.com/thunder-id/thunderid/internal/system/constants" + declarativeresource "github.com/thunder-id/thunderid/internal/system/declarative_resource" "github.com/thunder-id/thunderid/internal/system/resourcedependency" sysutils "github.com/thunder-id/thunderid/internal/system/utils" tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" @@ -24,13 +27,28 @@ import ( type service struct { idpService idp.IDPServiceInterface notificationService notification.NotificationSenderMgtSvcInterface + resourceService resourceServerLister + authZENPDPService *authzenpdp.Service +} + +type resourceServerLister interface { + GetResourceServerList( + ctx context.Context, limit, offset int, + ) (*resource.ResourceServerList, *tidcommon.ServiceError) } // newService creates a connection service over the given identity-provider and // notification-sender services. func newService(idpService idp.IDPServiceInterface, - notificationService notification.NotificationSenderMgtSvcInterface) *service { - return &service{idpService: idpService, notificationService: notificationService} + notificationService notification.NotificationSenderMgtSvcInterface, + resourceService resourceServerLister, + authZENPDPService *authzenpdp.Service) *service { + return &service{ + idpService: idpService, + notificationService: notificationService, + resourceService: resourceService, + authZENPDPService: authZENPDPService, + } } // listByType returns the configured instances of the given identity-provider type. @@ -140,6 +158,22 @@ func (s *service) listInstances(ctx context.Context, category connectionCategory } } + if category == "" || category == categoryAuthorizationPDP { + connections, svcErr := s.listAuthZENPDP(ctx) + if svcErr != nil { + return nil, svcErr + } + for _, connection := range connections { + instances = append(instances, connectionInstance{ + ID: connection.ID, + Name: connection.Name, + Description: connection.Description, + Type: authzenpdp.VendorName, + Categories: []connectionCategory{categoryAuthorizationPDP}, + }) + } + } + sort.SliceStable(instances, func(i, j int) bool { if instances[i].Type != instances[j].Type { return instances[i].Type < instances[j].Type @@ -270,6 +304,115 @@ func (s *service) deleteSMSByProvider(ctx context.Context, provider ncommon.Mess return s.notificationService.DeleteSender(ctx, id) } +// createAuthZENPDP validates and stores an external AuthZEN PDP connection. +func (s *service) createAuthZENPDP( + ctx context.Context, + connection authzenpdp.AuthZENPDPConnection, +) (*authzenpdp.AuthZENPDPConnection, *tidcommon.ServiceError) { + if svcErr := declarativeresource.CheckDeclarativeCreate(); svcErr != nil { + return nil, svcErr + } + if s.authZENPDPService == nil { + return nil, &tidcommon.InternalServerError + } + if err := authzenpdp.NormalizeEndpoints(&connection); err != nil { + return nil, &ErrorInvalidAuthZENPDPEndpoint + } + existing, err := s.authZENPDPService.GetByName(ctx, connection.Name) + if err != nil { + return nil, &tidcommon.InternalServerError + } + if existing != nil { + return nil, &ErrorAuthZENPDPAlreadyExists + } + connection.ID = sysutils.GenerateUUID() + if err := s.authZENPDPService.Create(ctx, connection); err != nil { + return nil, &tidcommon.InternalServerError + } + return &connection, nil +} + +// listAuthZENPDP returns all external AuthZEN PDP connections. +func (s *service) listAuthZENPDP(ctx context.Context) ([]authzenpdp.AuthZENPDPConnection, *tidcommon.ServiceError) { + if s.authZENPDPService == nil { + return nil, &tidcommon.InternalServerError + } + connections, err := s.authZENPDPService.List(ctx) + if err != nil { + return nil, &tidcommon.InternalServerError + } + return connections, nil +} + +// getAuthZENPDP returns an external AuthZEN PDP connection by ID. +func (s *service) getAuthZENPDP(ctx context.Context, id string) (*authzenpdp.AuthZENPDPConnection, + *tidcommon.ServiceError) { + if s.authZENPDPService == nil { + return nil, &tidcommon.InternalServerError + } + connection, err := s.authZENPDPService.Get(ctx, id) + if err != nil { + return nil, &tidcommon.InternalServerError + } + if connection == nil { + return nil, &ErrorConnectionNotFound + } + return connection, nil +} + +// updateAuthZENPDP validates and updates an external AuthZEN PDP connection by ID. +func (s *service) updateAuthZENPDP( + ctx context.Context, + id string, + connection authzenpdp.AuthZENPDPConnection, +) (*authzenpdp.AuthZENPDPConnection, *tidcommon.ServiceError) { + if svcErr := declarativeresource.CheckDeclarativeUpdate(); svcErr != nil { + return nil, svcErr + } + if _, svcErr := s.getAuthZENPDP(ctx, id); svcErr != nil { + return nil, svcErr + } + if err := authzenpdp.NormalizeEndpoints(&connection); err != nil { + return nil, &ErrorInvalidAuthZENPDPEndpoint + } + existing, err := s.authZENPDPService.GetByName(ctx, connection.Name) + if err != nil { + return nil, &tidcommon.InternalServerError + } + if existing != nil && existing.ID != id { + return nil, &ErrorAuthZENPDPAlreadyExists + } + if err := s.authZENPDPService.Update(ctx, id, connection); err != nil { + return nil, &tidcommon.InternalServerError + } + updated, err := s.authZENPDPService.Get(ctx, id) + if err != nil || updated == nil { + return nil, &tidcommon.InternalServerError + } + return updated, nil +} + +// deleteAuthZENPDP deletes an external AuthZEN PDP connection when it has no blocking usages. +func (s *service) deleteAuthZENPDP(ctx context.Context, id string) *tidcommon.ServiceError { + if svcErr := declarativeresource.CheckDeclarativeDelete(); svcErr != nil { + return svcErr + } + if _, svcErr := s.getAuthZENPDP(ctx, id); svcErr != nil { + return svcErr + } + usages, svcErr := s.usagesAuthZENPDP(ctx, id) + if svcErr != nil { + return svcErr + } + if len(resourcedependency.BlockingUsages(usages)) > 0 { + return &ErrorConnectionHasBlockingDependencies + } + if err := s.authZENPDPService.Delete(ctx, id); err != nil { + return &tidcommon.InternalServerError + } + return nil +} + // usagesByType verifies the instance is of the expected type, then returns the resources that // reference it. Drives the pre-delete confirmation dialog. func (s *service) usagesByType(ctx context.Context, idpType providers.IDPType, id string) ( @@ -289,3 +432,49 @@ func (s *service) usagesSMSByProvider(ctx context.Context, provider ncommon.Mess } return s.notificationService.GetSenderUsages(ctx, id) } + +// usagesAuthZENPDP returns resources that reference an external AuthZEN PDP connection. +func (s *service) usagesAuthZENPDP(ctx context.Context, id string) ( + *resourcedependency.DependenciesResponse, *tidcommon.ServiceError) { + if _, svcErr := s.getAuthZENPDP(ctx, id); svcErr != nil { + return nil, svcErr + } + if s.resourceService == nil { + return nil, &tidcommon.InternalServerError + } + + usages := make([]resourcedependency.ResourceDependency, 0) + offset := 0 + for { + list, svcErr := s.resourceService.GetResourceServerList(ctx, serverconst.MaxPageSize, offset) + if svcErr != nil { + return nil, svcErr + } + if list == nil || list.Count == 0 { + break + } + for _, resourceServer := range list.ResourceServers { + if resourceServer.AuthorizationEngine.Properties.PDPConnectionID != id { + continue + } + usages = append(usages, resourcedependency.ResourceDependency{ + ResourceType: resourcedependency.ResourceTypeResourceServer, + ID: resourceServer.ID, + DisplayName: resourceServer.Name, + BehaviorOnDelete: resourcedependency.BehaviorRestrict, + }) + } + offset += list.Count + if offset >= list.TotalResults { + break + } + } + + total := len(usages) + return &resourcedependency.DependenciesResponse{ + TotalResults: &total, + Count: total, + Summary: map[string]int{resourcedependency.ResourceTypeResourceServer: total}, + Usages: usages, + }, nil +} diff --git a/backend/internal/connection/service_test.go b/backend/internal/connection/service_test.go index 38f5d41275..c195006f2c 100644 --- a/backend/internal/connection/service_test.go +++ b/backend/internal/connection/service_test.go @@ -10,9 +10,11 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/internal/connection/authzenpdp" "github.com/thunder-id/thunderid/internal/idp" "github.com/thunder-id/thunderid/internal/notification" ncommon "github.com/thunder-id/thunderid/internal/notification/common" + "github.com/thunder-id/thunderid/internal/resource" "github.com/thunder-id/thunderid/internal/system/cmodels" "github.com/thunder-id/thunderid/internal/system/config" serverconst "github.com/thunder-id/thunderid/internal/system/constants" @@ -30,6 +32,79 @@ type ServiceTestSuite struct { mockNotif *notificationmock.NotificationSenderMgtSvcInterfaceMock } +type testAuthZENPDPStore struct { + connections map[string]authzenpdp.AuthZENPDPConnection +} + +type testResourceServerLister struct { + lists map[int]*resource.ResourceServerList + err *tidcommon.ServiceError + called []int +} + +func (l *testResourceServerLister) GetResourceServerList( + _ context.Context, + _ int, + offset int, +) (*resource.ResourceServerList, *tidcommon.ServiceError) { + l.called = append(l.called, offset) + if l.err != nil { + return nil, l.err + } + if list, ok := l.lists[offset]; ok { + return list, nil + } + return &resource.ResourceServerList{}, nil +} + +func newTestAuthZENPDPStore() *testAuthZENPDPStore { + return &testAuthZENPDPStore{connections: map[string]authzenpdp.AuthZENPDPConnection{}} +} + +func (s *testAuthZENPDPStore) Create(_ context.Context, connection authzenpdp.AuthZENPDPConnection) error { + s.connections[connection.ID] = connection + return nil +} + +func (s *testAuthZENPDPStore) List(_ context.Context) ([]authzenpdp.AuthZENPDPConnection, error) { + connections := make([]authzenpdp.AuthZENPDPConnection, 0, len(s.connections)) + for _, connection := range s.connections { + connections = append(connections, connection) + } + return connections, nil +} + +func (s *testAuthZENPDPStore) Get(_ context.Context, id string) (*authzenpdp.AuthZENPDPConnection, error) { + connection, ok := s.connections[id] + if !ok { + return nil, nil + } + return &connection, nil +} + +func (s *testAuthZENPDPStore) GetByName(_ context.Context, + name string) (*authzenpdp.AuthZENPDPConnection, error) { + for _, connection := range s.connections { + if connection.Name == name { + connectionCopy := connection + return &connectionCopy, nil + } + } + return nil, nil +} + +func (s *testAuthZENPDPStore) Update(_ context.Context, id string, + connection authzenpdp.AuthZENPDPConnection) error { + connection.ID = id + s.connections[id] = connection + return nil +} + +func (s *testAuthZENPDPStore) Delete(_ context.Context, id string) error { + delete(s.connections, id) + return nil +} + func TestServiceSuite(t *testing.T) { suite.Run(t, new(ServiceTestSuite)) } @@ -38,7 +113,8 @@ func (s *ServiceTestSuite) SetupTest() { initConfigWithTestCryptoKey(s.T()) s.mockIDP = idpmock.NewIDPServiceInterfaceMock(s.T()) s.mockNotif = notificationmock.NewNotificationSenderMgtSvcInterfaceMock(s.T()) - s.svc = newService(s.mockIDP, s.mockNotif) + s.svc = newService(s.mockIDP, s.mockNotif, &testResourceServerLister{}, + authzenpdp.NewService(newTestAuthZENPDPStore())) } func (s *ServiceTestSuite) TearDownTest() { @@ -69,6 +145,62 @@ func (s *ServiceTestSuite) TestListByTypeError() { s.NotNil(svcErr) } +func (s *ServiceTestSuite) TestCreateAuthZENPDPStoresConfiguredEndpoints() { + store := newTestAuthZENPDPStore() + s.svc = newService(s.mockIDP, s.mockNotif, &testResourceServerLister{}, authzenpdp.NewService(store)) + + created, svcErr := s.svc.createAuthZENPDP(context.Background(), authzenpdp.AuthZENPDPConnection{ + Name: "PDP", + Endpoint: " https://pdp.example.com/access/v1/evaluation ", + BatchEndpoint: " https://pdp.example.com/access/v1/evaluations ", + }) + + s.Nil(svcErr) + s.Require().NotNil(created) + s.NotEmpty(created.ID) + s.Equal("https://pdp.example.com/access/v1/evaluation", created.Endpoint) + s.Equal("https://pdp.example.com/access/v1/evaluations", created.BatchEndpoint) + s.Equal(created.Endpoint, store.connections[created.ID].Endpoint) + s.Equal(created.BatchEndpoint, store.connections[created.ID].BatchEndpoint) +} + +func (s *ServiceTestSuite) TestCreateAuthZENPDPRejectsDuplicateName() { + store := newTestAuthZENPDPStore() + store.connections["pdp-1"] = authzenpdp.AuthZENPDPConnection{Name: "PDP"} + s.svc = newService(s.mockIDP, s.mockNotif, &testResourceServerLister{}, authzenpdp.NewService(store)) + + created, svcErr := s.svc.createAuthZENPDP(context.Background(), authzenpdp.AuthZENPDPConnection{ + Name: "PDP", + Endpoint: "https://pdp.example.com/access/v1/evaluation", + BatchEndpoint: "https://pdp.example.com/access/v1/evaluations", + }) + + s.Nil(created) + s.Equal(ErrorAuthZENPDPAlreadyExists.Code, svcErr.Code) +} + +func (s *ServiceTestSuite) TestUpdateAuthZENPDPStoresConfiguredEndpoints() { + store := newTestAuthZENPDPStore() + store.connections["pdp-1"] = authzenpdp.AuthZENPDPConnection{ + ID: "pdp-1", + Name: "PDP", + Endpoint: "https://old-pdp.example.com/access/v1/evaluation", + BatchEndpoint: "https://old-pdp.example.com/access/v1/evaluations", + } + s.svc = newService(s.mockIDP, s.mockNotif, &testResourceServerLister{}, authzenpdp.NewService(store)) + + updated, svcErr := s.svc.updateAuthZENPDP(context.Background(), "pdp-1", authzenpdp.AuthZENPDPConnection{ + Name: "New PDP", + Endpoint: "https://new-pdp.example.com/access/v1/evaluation", + BatchEndpoint: "https://new-pdp.example.com/access/v1/evaluations", + }) + + s.Nil(svcErr) + s.Require().NotNil(updated) + s.Equal("https://new-pdp.example.com/access/v1/evaluation", updated.Endpoint) + s.Equal("https://new-pdp.example.com/access/v1/evaluations", updated.BatchEndpoint) +} + func (s *ServiceTestSuite) TestListInstancesAllCategories() { s.mockIDP.On("GetIdentityProviderList", mock.Anything).Return([]idp.BasicIDPDTO{ {ID: "1", Name: "google B", Type: providers.IDPTypeGoogle}, @@ -497,6 +629,95 @@ func (s *ServiceTestSuite) TestUsagesSMSByProviderDelegates() { s.Equal(usages, result) } +func (s *ServiceTestSuite) TestUsagesAuthZENPDPReturnsReferencingResourceServers() { + store := newTestAuthZENPDPStore() + store.connections["pdp-1"] = authzenpdp.AuthZENPDPConnection{ + ID: "pdp-1", + Name: "PDP", + Endpoint: "https://pdp.example.com/access/v1/evaluation", + BatchEndpoint: "https://pdp.example.com/access/v1/evaluations", + } + resourceLister := &testResourceServerLister{ + lists: map[int]*resource.ResourceServerList{ + 0: { + TotalResults: 2, + Count: 2, + ResourceServers: []providers.ResourceServer{ + { + ID: "rs-1", + Name: "Travel API", + AuthorizationEngine: providers.AuthorizationEngineConfig{ + Type: providers.AuthorizationEngineTypeExternalAuthZENPDP, + Properties: providers.AuthorizationEngineProperties{ + PDPConnectionID: "pdp-1", + }, + }, + }, + { + ID: "rs-2", + Name: "Billing API", + AuthorizationEngine: providers.AuthorizationEngineConfig{ + Type: providers.AuthorizationEngineTypeExternalAuthZENPDP, + Properties: providers.AuthorizationEngineProperties{ + PDPConnectionID: "other", + }, + }, + }, + }, + }, + }, + } + s.svc = newService(s.mockIDP, s.mockNotif, resourceLister, authzenpdp.NewService(store)) + + result, svcErr := s.svc.usagesAuthZENPDP(context.Background(), "pdp-1") + + s.Nil(svcErr) + s.Require().NotNil(result.TotalResults) + s.Equal(1, *result.TotalResults) + s.Equal(1, result.Count) + s.Equal(1, result.Summary[resourcedependency.ResourceTypeResourceServer]) + s.Require().Len(result.Usages, 1) + s.Equal("rs-1", result.Usages[0].ID) + s.Equal("Travel API", result.Usages[0].DisplayName) + s.Equal(resourcedependency.BehaviorRestrict, result.Usages[0].BehaviorOnDelete) +} + +func (s *ServiceTestSuite) TestDeleteAuthZENPDPBlocksWhenResourceServerReferencesIt() { + store := newTestAuthZENPDPStore() + store.connections["pdp-1"] = authzenpdp.AuthZENPDPConnection{ + ID: "pdp-1", + Name: "PDP", + Endpoint: "https://pdp.example.com/access/v1/evaluation", + BatchEndpoint: "https://pdp.example.com/access/v1/evaluations", + } + s.svc = newService(s.mockIDP, s.mockNotif, &testResourceServerLister{ + lists: map[int]*resource.ResourceServerList{ + 0: { + TotalResults: 1, + Count: 1, + ResourceServers: []providers.ResourceServer{ + { + ID: "rs-1", + Name: "Travel API", + AuthorizationEngine: providers.AuthorizationEngineConfig{ + Type: providers.AuthorizationEngineTypeExternalAuthZENPDP, + Properties: providers.AuthorizationEngineProperties{ + PDPConnectionID: "pdp-1", + }, + }, + }, + }, + }, + }, + }, authzenpdp.NewService(store)) + + svcErr := s.svc.deleteAuthZENPDP(context.Background(), "pdp-1") + + s.Require().NotNil(svcErr) + s.Equal(ErrorConnectionHasBlockingDependencies.Code, svcErr.Code) + s.Contains(store.connections, "pdp-1") +} + // TestUsagesSMSByProviderWrongProvider verifies a sender of another provider is not exposed // through a vendor's usages endpoint. func (s *ServiceTestSuite) TestUsagesSMSByProviderWrongProvider() { diff --git a/backend/internal/flow/executor/authz_executor.go b/backend/internal/flow/executor/authz_executor.go index e5f2048039..3e0a6daf75 100644 --- a/backend/internal/flow/executor/authz_executor.go +++ b/backend/internal/flow/executor/authz_executor.go @@ -123,7 +123,6 @@ func (a *authorizationExecutor) Execute(ctx *providers.NodeContext) (*providers. if err != nil { return nil, errors.Join(errors.New("Failed to extract group IDs"), err) } - logger.Debug(ctx.Context, "Calling authorization service", log.MaskedString(log.LoggerKeyUserID, userID), log.Int("groupCount", len(groupIDs)), @@ -151,10 +150,7 @@ func (a *authorizationExecutor) Execute(ctx *providers.NodeContext) (*providers. // resolveResourceServerID determines the internal ID of the single resource server that permission // scopes are evaluated against. The binding is communicated as a resource server identifier: the OAuth // layer seeds it in runtime data, and a direct /flow/execute request (which does not go through the -// authorization endpoint) may supply it as an input. The identifier is resolved to its internal ID -// through the provider; an empty identifier asks a default-aware provider to resolve the deployment's -// configured default resource server. Returns "" when none can be resolved (unknown identifier, no -// default configured, or no resource provider available, for example the embedded engine). +// authorization endpoint) may supply it as an input. func (a *authorizationExecutor) resolveResourceServerID(ctx *providers.NodeContext) string { identifier := ctx.RuntimeData[common.RuntimeKeyResourceServerIdentifier] if identifier == "" { @@ -201,11 +197,14 @@ func (a *authorizationExecutor) buildAccessEvaluationsRequest( for _, permission := range requestedPermissions { evaluations = append(evaluations, providers.AccessEvaluationRequest{ Subject: providers.Subject{ + Type: "user", ID: entityID, GroupIDs: groupIDs, }, - ResourceServer: providers.AccessEvaluationResourceServer{ID: resourceServerID}, - Permission: providers.Permission{Name: permission}, + ResourceServer: providers.AccessEvaluationResourceServer{ + ID: resourceServerID, + }, + Permission: providers.Permission{Name: permission}, }) } return providers.AccessEvaluationsRequest{Evaluations: evaluations} diff --git a/backend/internal/flow/executor/authz_executor_test.go b/backend/internal/flow/executor/authz_executor_test.go index 47ddf24418..cbe8229af9 100644 --- a/backend/internal/flow/executor/authz_executor_test.go +++ b/backend/internal/flow/executor/authz_executor_test.go @@ -53,7 +53,6 @@ func createTestAuthzExecutorWithResource(t *testing.T, mockFlowFactory.On("CreateExecutor", ExecutorNameAuthorization, providers.ExecutorTypeUtility, []providers.Input{}, []providers.Input{}, mock.Anything). Return(createMockExecutor(t, "AuthorizationExecutor", providers.ExecutorTypeUtility)) - return newAuthorizationExecutor(mockFlowFactory, mockAuthzService, mockEntityProvider, mockAuthnProvider, resourceService) } diff --git a/backend/internal/oauth/oauth2/granthandlers/client_credentials.go b/backend/internal/oauth/oauth2/granthandlers/client_credentials.go index 75072074eb..0a164e40b9 100644 --- a/backend/internal/oauth/oauth2/granthandlers/client_credentials.go +++ b/backend/internal/oauth/oauth2/granthandlers/client_credentials.go @@ -109,7 +109,8 @@ func (h *clientCredentialsGrantHandler) HandleGrant(ctx context.Context, tokenRe } authzResp, svcErr := h.authzService.EvaluateAccessBatch(ctx, - buildAccessEvaluationsRequest(oauthApp.ID, groupIDs, scopes, targetRS.ID)) + buildAccessEvaluationsRequest(oauthApp.ID, oauthApp.EntityCategory, groupIDs, scopes, + targetRS.ID)) if svcErr != nil { logger.Error(ctx, "Failed to get authorized permissions for app", log.String("appID", oauthApp.ID), log.String("error", svcErr.Error.DefaultValue)) @@ -156,6 +157,7 @@ func (h *clientCredentialsGrantHandler) HandleGrant(ctx context.Context, tokenRe func buildAccessEvaluationsRequest( entityID string, + entityCategory providers.EntityCategory, groupIDs []string, permissions []string, resourceServerID string, @@ -164,16 +166,31 @@ func buildAccessEvaluationsRequest( for _, permission := range permissions { evaluations = append(evaluations, providers.AccessEvaluationRequest{ Subject: providers.Subject{ + Type: authZENSubjectType(entityCategory), ID: entityID, GroupIDs: groupIDs, }, - ResourceServer: providers.AccessEvaluationResourceServer{ID: resourceServerID}, - Permission: providers.Permission{Name: permission}, + ResourceServer: providers.AccessEvaluationResourceServer{ + ID: resourceServerID, + }, + Permission: providers.Permission{Name: permission}, }) } return providers.AccessEvaluationsRequest{Evaluations: evaluations} } +// authZENSubjectType maps a ThunderID entity category to its AuthZEN subject type. +func authZENSubjectType(entityCategory providers.EntityCategory) string { + switch entityCategory { + case providers.EntityCategoryApp: + return constants.SubTypeApp + case providers.EntityCategoryAgent: + return constants.SubTypeAgent + default: + return entityCategory.String() + } +} + func filterAuthorizedScopes(scopes []string, evaluations []providers.AccessEvaluationResponse) []string { authorizedScopes := make([]string, 0, len(evaluations)) for i, evaluation := range evaluations { diff --git a/backend/internal/oauth/oauth2/granthandlers/client_credentials_test.go b/backend/internal/oauth/oauth2/granthandlers/client_credentials_test.go index d054609e27..f317aa74ae 100644 --- a/backend/internal/oauth/oauth2/granthandlers/client_credentials_test.go +++ b/backend/internal/oauth/oauth2/granthandlers/client_credentials_test.go @@ -148,6 +148,24 @@ func mockEvaluateAccessBatch( Return(&providers.AccessEvaluationsResponse{Evaluations: evaluations}, nil) } +func TestBuildAccessEvaluationsRequestUsesEntityCategory(t *testing.T) { + for _, test := range []struct { + category providers.EntityCategory + typeName string + }{ + {category: providers.EntityCategoryApp, typeName: constants.SubTypeApp}, + {category: providers.EntityCategoryAgent, typeName: constants.SubTypeAgent}, + {category: providers.EntityCategoryUser, typeName: providers.EntityCategoryUser.String()}, + } { + request := buildAccessEvaluationsRequest( + "entity-1", test.category, nil, []string{"bookings:view"}, "rs-1", + ) + + assert.Len(t, request.Evaluations, 1) + assert.Equal(t, test.typeName, request.Evaluations[0].Subject.Type) + } +} + func (suite *ClientCredentialsGrantHandlerTestSuite) TestNewClientCredentialsGrantHandler() { handler := newClientCredentialsGrantHandler( suite.mockTokenBuilder, suite.mockOUService, suite.mockAuthzService, diff --git a/backend/internal/oauth/oauth2/granthandlers/refresh_token.go b/backend/internal/oauth/oauth2/granthandlers/refresh_token.go index 17fc32cc92..5586ec4b60 100644 --- a/backend/internal/oauth/oauth2/granthandlers/refresh_token.go +++ b/backend/internal/oauth/oauth2/granthandlers/refresh_token.go @@ -659,7 +659,8 @@ func (h *refreshTokenGrantHandler) reauthorizeScopes(ctx context.Context, subjec } authzResp, svcErr := h.authzService.EvaluateAccessBatch(ctx, - buildAccessEvaluationsRequest(subject, groupIDs, scopes, resourceServerID)) + buildAccessEvaluationsRequest(subject, subjectEntity.Category, groupIDs, scopes, + resourceServerID)) if svcErr != nil { logger.Error(ctx, "Failed to evaluate authorized permissions for refresh token subject", log.MaskedString(log.LoggerKeyUserID, subject), diff --git a/backend/internal/resource/composite_store_test.go b/backend/internal/resource/composite_store_test.go index 8a4a17b5c0..219fdadfe0 100644 --- a/backend/internal/resource/composite_store_test.go +++ b/backend/internal/resource/composite_store_test.go @@ -218,9 +218,10 @@ func (s *CompositeResourceStoreTestSuite) TestGetResourceServerList_VerifiesIsRe // Verify all resource servers have correct IsReadOnly flags for _, rs := range result { - if rs.ID == "rs-db1" || rs.ID == "rs-db2" { + switch rs.ID { + case "rs-db1", "rs-db2": assert.False(s.T(), rs.IsReadOnly, "DB resource server %s should have IsReadOnly=false", rs.ID) - } else if rs.ID == "rs-file1" { + case "rs-file1": assert.True(s.T(), rs.IsReadOnly, "File resource server %s should have IsReadOnly=true", rs.ID) } } @@ -252,9 +253,10 @@ func (s *CompositeResourceStoreTestSuite) TestGetResourceServerList_Deduplicates // Verify IsReadOnly flags are correct for _, rs := range result { - if rs.ID == testRS1ID || rs.ID == "rs2" { + switch rs.ID { + case testRS1ID, "rs2": assert.False(s.T(), rs.IsReadOnly, "DB resource server %s should have IsReadOnly=false", rs.ID) - } else if rs.ID == "rs3" { + case "rs3": assert.True(s.T(), rs.IsReadOnly, "File resource server %s should have IsReadOnly=true", rs.ID) } } @@ -1144,9 +1146,10 @@ func (s *CompositeResourceStoreTestSuite) TestMergeAndDeduplicateResourceServers // Verify IsReadOnly flags are correct for _, rs := range result { - if rs.ID == "rs-db1" || rs.ID == "rs-db2" { + switch rs.ID { + case "rs-db1", "rs-db2": assert.False(s.T(), rs.IsReadOnly, "DB resource server %s should have IsReadOnly=false", rs.ID) - } else if rs.ID == "rs-file1" || rs.ID == "rs-file2" { + case "rs-file1", "rs-file2": assert.True(s.T(), rs.IsReadOnly, "File resource server %s should have IsReadOnly=true", rs.ID) } } diff --git a/backend/internal/resource/declarative_resource.go b/backend/internal/resource/declarative_resource.go index 8ecadf6b34..3a66552f0e 100644 --- a/backend/internal/resource/declarative_resource.go +++ b/backend/internal/resource/declarative_resource.go @@ -91,14 +91,15 @@ func (e *resourceServerExporter) GetResourceByID(ctx context.Context, id string) // Build providers.ResourceServer with nested structure rs := &providers.ResourceServer{ - ID: server.ID, - Name: server.Name, - Description: server.Description, - Identifier: server.Identifier, - Type: server.Type, - OUID: server.OUID, - Delimiter: server.Delimiter, - Resources: []providers.Resource{}, + ID: server.ID, + Name: server.Name, + Description: server.Description, + Identifier: server.Identifier, + Type: server.Type, + OUID: server.OUID, + Delimiter: server.Delimiter, + AuthorizationEngine: server.AuthorizationEngine, + Resources: []providers.Resource{}, } allResources, err := e.service.GetAllResourceList(ctx, id) @@ -214,7 +215,7 @@ func loadDeclarativeResources(resourceStore resourceStoreInterface, resourceServ resourceConfig := declarativeresource.ResourceConfig{ ResourceType: "ResourceServer", DirectoryName: "resource_servers", - Parser: parseAndValidateResourceServerWrapper(resourceService), + Parser: parseAndValidateResourceServerWrapper(), Validator: func(data interface{}) error { return validateResourceServerWrapper(data, fileStore, dbStore, resourceService) }, @@ -232,7 +233,7 @@ func loadDeclarativeResources(resourceStore resourceStoreInterface, resourceServ } // parseAndValidateResourceServerWrapper combines parsing, processing, and validation for resource servers. -func parseAndValidateResourceServerWrapper(resourceService ResourceServiceInterface) func([]byte) (interface{}, error) { +func parseAndValidateResourceServerWrapper() func([]byte) (interface{}, error) { return func(data []byte) (interface{}, error) { // Parse YAML into providers.ResourceServer struct rs, err := parseToResourceServer(data) @@ -292,6 +293,16 @@ func parseToResourceServer(data []byte) (*providers.ResourceServer, error) { // ProcessResourceServer processes the resource server and computes permissions in-place. func ProcessResourceServer(rs *providers.ResourceServer) error { + if rs.AuthorizationEngine.Type == "" { + rs.AuthorizationEngine.Type = providers.AuthorizationEngineTypeRBAC + } + switch rs.AuthorizationEngine.Type { + case providers.AuthorizationEngineTypeRBAC: + rs.AuthorizationEngine.Properties = providers.AuthorizationEngineProperties{} + case providers.AuthorizationEngineTypeExternalAuthZENPDP: + default: + return fmt.Errorf("unsupported authorization engine type %q", rs.AuthorizationEngine.Type) + } delimiter := rs.Delimiter if delimiter == "" { delimiter = ":" // Default delimiter diff --git a/backend/internal/resource/declarative_resource_test.go b/backend/internal/resource/declarative_resource_test.go index 37eb27cc56..86206a3434 100644 --- a/backend/internal/resource/declarative_resource_test.go +++ b/backend/internal/resource/declarative_resource_test.go @@ -20,6 +20,22 @@ import ( "gopkg.in/yaml.v3" ) +func TestProcessResourceServerAuthorizationEngine(t *testing.T) { + for _, engineType := range []string{"", providers.AuthorizationEngineTypeRBAC} { + t.Run("default_"+engineType, func(t *testing.T) { + rs := &providers.ResourceServer{AuthorizationEngine: providers.AuthorizationEngineConfig{ + Type: engineType, + Properties: providers.AuthorizationEngineProperties{PDPConnectionID: "old-pdp"}, + }} + assert.NoError(t, ProcessResourceServer(rs)) + assert.Equal(t, providers.AuthorizationEngineTypeRBAC, rs.AuthorizationEngine.Type) + assert.Empty(t, rs.AuthorizationEngine.Properties.PDPConnectionID) + }) + } + rs := &providers.ResourceServer{AuthorizationEngine: providers.AuthorizationEngineConfig{Type: "invalid"}} + assert.Error(t, ProcessResourceServer(rs)) +} + // ResourceServerExporterTestSuite tests the resourceServerExporter. type ResourceServerExporterTestSuite struct { suite.Suite @@ -122,6 +138,12 @@ func (s *ResourceServerExporterTestSuite) TestGetResourceByID_Success() { Identifier: "test-server", OUID: "ou1", Delimiter: ":", + AuthorizationEngine: providers.AuthorizationEngineConfig{ + Type: providers.AuthorizationEngineTypeExternalAuthZENPDP, + Properties: providers.AuthorizationEngineProperties{ + PDPConnectionID: "pdp-1", + }, + }, } resources := []providers.Resource{ @@ -167,9 +189,20 @@ func (s *ResourceServerExporterTestSuite) TestGetResourceByID_Success() { assert.True(s.T(), ok) assert.Equal(s.T(), serverID, dto.ID) assert.Equal(s.T(), "Test Server", dto.Name) + assert.Equal(s.T(), server.AuthorizationEngine, dto.AuthorizationEngine) assert.Len(s.T(), dto.Resources, 1) assert.Len(s.T(), dto.Resources[0].Actions, 1) assert.Equal(s.T(), providers.ActionKindTool, dto.Resources[0].Actions[0].Kind) + + yamlBytes, marshalErr := yaml.Marshal(dto) + assert.NoError(s.T(), marshalErr) + assert.Contains(s.T(), string(yamlBytes), "type: authzen_pdp") + assert.Contains(s.T(), string(yamlBytes), "pdpConnectionId: pdp-1") + + imported, parseErr := parseToResourceServer(yamlBytes) + s.Require().NoError(parseErr) + s.Require().NotNil(imported) + assert.Equal(s.T(), server.AuthorizationEngine, imported.AuthorizationEngine) } func (s *ResourceServerExporterTestSuite) TestGetResourceByID_MCPExportImportRoundTrip() { @@ -226,6 +259,7 @@ func (s *ResourceServerExporterTestSuite) TestGetResourceByID_MCPExportImportRou // accepts the nested action carrying a kind. yamlBytes, marshalErr := yaml.Marshal(dto) assert.NoError(s.T(), marshalErr) + assert.NotContains(s.T(), string(yamlBytes), "authorizationEngine:") imported, parseErr := parseToResourceServer(yamlBytes) s.Require().NoError(parseErr) @@ -516,7 +550,7 @@ type: "MCP" ouId: "ou1" `) - parser := parseAndValidateResourceServerWrapper(nil) + parser := parseAndValidateResourceServerWrapper() result, err := parser(yamlData) assert.NoError(t, err) @@ -534,7 +568,7 @@ type: "BOGUS" ouId: "ou1" `) - parser := parseAndValidateResourceServerWrapper(nil) + parser := parseAndValidateResourceServerWrapper() result, err := parser(yamlData) assert.Error(t, err) @@ -812,7 +846,7 @@ resources: parent: "users" `) - parser := parseAndValidateResourceServerWrapper(nil) + parser := parseAndValidateResourceServerWrapper() result, err := parser(yamlData) assert.NoError(t, err) @@ -841,7 +875,7 @@ resources: parent: "ops" `) - parser := parseAndValidateResourceServerWrapper(nil) + parser := parseAndValidateResourceServerWrapper() result, err := parser(yamlData) assert.Error(t, err) @@ -868,7 +902,7 @@ resources: kind: "resource" `) - parser := parseAndValidateResourceServerWrapper(nil) + parser := parseAndValidateResourceServerWrapper() result, err := parser(yamlData) assert.NoError(t, err) @@ -881,7 +915,7 @@ resources: func TestParseAndValidateResourceServerWrapper_InvalidYAML(t *testing.T) { yamlData := []byte(`::invalid`) - parser := parseAndValidateResourceServerWrapper(nil) + parser := parseAndValidateResourceServerWrapper() result, err := parser(yamlData) assert.Error(t, err) diff --git a/backend/internal/resource/handler.go b/backend/internal/resource/handler.go index ec029fed49..6ba27383a8 100644 --- a/backend/internal/resource/handler.go +++ b/backend/internal/resource/handler.go @@ -116,10 +116,11 @@ func (h *resourceHandler) HandleResourceServerPutRequest(w http.ResponseWriter, sanitized := sanitizeUpdateResourceServerRequest(req) serviceReq := providers.ResourceServer{ - Name: sanitized.Name, - Description: sanitized.Description, - Identifier: sanitized.Identifier, - OUID: sanitized.OUID, + Name: sanitized.Name, + Description: sanitized.Description, + Identifier: sanitized.Identifier, + OUID: sanitized.OUID, + AuthorizationEngine: sanitized.AuthorizationEngine, } result, svcErr := h.resourceService.UpdateResourceServer(ctx, id, serviceReq) @@ -607,13 +608,21 @@ func sanitizeCreateResourceServerRequest(req *CreateResourceServerRequest) Creat // sanitizeUpdateResourceServerRequest sanitizes input for updating a resource server. func sanitizeUpdateResourceServerRequest(req *UpdateResourceServerRequest) UpdateResourceServerRequest { return UpdateResourceServerRequest{ - Name: sysutils.SanitizeString(req.Name), - Description: sysutils.SanitizeString(req.Description), - Identifier: sysutils.SanitizeString(req.Identifier), - OUID: sysutils.SanitizeString(req.OUID), + Name: sysutils.SanitizeString(req.Name), + Description: sysutils.SanitizeString(req.Description), + Identifier: sysutils.SanitizeString(req.Identifier), + OUID: sysutils.SanitizeString(req.OUID), + AuthorizationEngine: sanitizeAuthorizationEngine(req.AuthorizationEngine), } } +// sanitizeAuthorizationEngine sanitizes user-controlled authorization engine identifiers. +func sanitizeAuthorizationEngine(engine providers.AuthorizationEngineConfig) providers.AuthorizationEngineConfig { + engine.Type = sysutils.SanitizeString(engine.Type) + engine.Properties.PDPConnectionID = sysutils.SanitizeString(engine.Properties.PDPConnectionID) + return engine +} + // sanitizeCreateResourceRequest sanitizes input for creating a resource. func sanitizeCreateResourceRequest(req *CreateResourceRequest) CreateResourceRequest { sanitized := CreateResourceRequest{ @@ -666,14 +675,15 @@ func toResourceServerResponse(rs *providers.ResourceServer) *ResourceServerRespo resType = providers.ResourceServerTypeCustom } return &ResourceServerResponse{ - ID: rs.ID, - Name: rs.Name, - Description: rs.Description, - Identifier: rs.Identifier, - Type: resType, - OUID: rs.OUID, - Delimiter: rs.Delimiter, - IsReadOnly: rs.IsReadOnly, + ID: rs.ID, + Name: rs.Name, + Description: rs.Description, + Identifier: rs.Identifier, + Type: resType, + OUID: rs.OUID, + Delimiter: rs.Delimiter, + AuthorizationEngine: rs.AuthorizationEngine, + IsReadOnly: rs.IsReadOnly, } } diff --git a/backend/internal/resource/handler_test.go b/backend/internal/resource/handler_test.go index 43998bc415..5d3657cb2a 100644 --- a/backend/internal/resource/handler_test.go +++ b/backend/internal/resource/handler_test.go @@ -212,6 +212,38 @@ func (suite *HandlerTestSuite) TestHandleResourceServerPutRequest_Success() { suite.Equal(http.StatusOK, w.Code) } +func (suite *HandlerTestSuite) TestHandleResourceServerPutRequestExternalAuthZEN() { + reqBody := UpdateResourceServerRequest{ + Name: "external-pdp", + OUID: "ou-123", + AuthorizationEngine: providers.AuthorizationEngineConfig{ + Type: providers.AuthorizationEngineTypeExternalAuthZENPDP, + Properties: providers.AuthorizationEngineProperties{ + PDPConnectionID: "pdp-123", + }, + }, + } + suite.mockService.On( + "UpdateResourceServer", + mock.Anything, + "rs-123", + mock.MatchedBy(func(rs providers.ResourceServer) bool { + return rs.AuthorizationEngine.Type == providers.AuthorizationEngineTypeExternalAuthZENPDP && + rs.AuthorizationEngine.Properties.PDPConnectionID == "pdp-123" + }), + ).Return(&providers.ResourceServer{ID: "rs-123", Name: reqBody.Name}, nil) + + body, err := json.Marshal(reqBody) + suite.Require().NoError(err) + req := httptest.NewRequest("PUT", "/resource-servers/rs-123", bytes.NewReader(body)) + req.SetPathValue("id", "rs-123") + w := httptest.NewRecorder() + + suite.handler.HandleResourceServerPutRequest(w, req) + + suite.Equal(http.StatusOK, w.Code) +} + func (suite *HandlerTestSuite) TestHandleResourceServerDeleteRequest_Success() { suite.mockService.On("DeleteResourceServer", mock.Anything, "rs-123").Return(nil) diff --git a/backend/internal/resource/model.go b/backend/internal/resource/model.go index eb08388c04..13bf39f5b7 100644 --- a/backend/internal/resource/model.go +++ b/backend/internal/resource/model.go @@ -9,14 +9,15 @@ import "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" // ResourceServerResponse represents a resource server. type ResourceServerResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Identifier string `json:"identifier"` - Type providers.ResourceServerType `json:"type"` - OUID string `json:"ouId"` - Delimiter string `json:"delimiter"` - IsReadOnly bool `json:"isReadOnly"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Identifier string `json:"identifier"` + Type providers.ResourceServerType `json:"type"` + OUID string `json:"ouId"` + Delimiter string `json:"delimiter"` + AuthorizationEngine providers.AuthorizationEngineConfig `json:"authorizationEngine,omitempty"` + IsReadOnly bool `json:"isReadOnly"` } // ResourceResponse represents a resource. @@ -84,10 +85,11 @@ type CreateResourceServerRequest struct { // UpdateResourceServerRequest represents the request to update a resource server. type UpdateResourceServerRequest struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Identifier string `json:"identifier,omitempty"` - OUID string `json:"ouId" native:"required"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Identifier string `json:"identifier,omitempty"` + OUID string `json:"ouId" native:"required"` + AuthorizationEngine providers.AuthorizationEngineConfig `json:"authorizationEngine,omitempty"` } // CreateResourceRequest represents the request to create a resource. diff --git a/backend/internal/resource/service.go b/backend/internal/resource/service.go index d263c8330a..889e3cfc68 100644 --- a/backend/internal/resource/service.go +++ b/backend/internal/resource/service.go @@ -275,6 +275,14 @@ func (rs *resourceService) CreateResourceServer( } // Set default type if not provided + if resourceServer.AuthorizationEngine.Type == "" { + resourceServer.AuthorizationEngine.Type = providers.AuthorizationEngineTypeRBAC + } + + if resourceServer.AuthorizationEngine.Type == providers.AuthorizationEngineTypeRBAC { + resourceServer.AuthorizationEngine.Properties = providers.AuthorizationEngineProperties{} + } + if resourceServer.Type == "" { resourceServer.Type = providers.ResourceServerTypeCustom } @@ -312,13 +320,14 @@ func (rs *resourceService) CreateResourceServer( } createdRS = &providers.ResourceServer{ - ID: id, - Name: resourceServer.Name, - Description: resourceServer.Description, - Identifier: resourceServer.Identifier, - Type: resourceServer.Type, - OUID: resourceServer.OUID, - Delimiter: resourceServer.Delimiter, + ID: id, + Name: resourceServer.Name, + Description: resourceServer.Description, + Identifier: resourceServer.Identifier, + Type: resourceServer.Type, + OUID: resourceServer.OUID, + Delimiter: resourceServer.Delimiter, + AuthorizationEngine: resourceServer.AuthorizationEngine, } return nil }); err != nil { @@ -444,6 +453,16 @@ func (rs *resourceService) UpdateResourceServer( // Type is immutable and always preserved from the existing record resourceServer.Type = existingResServer.Type + if resourceServer.AuthorizationEngine.Type == "" { + resourceServer.AuthorizationEngine = existingResServer.AuthorizationEngine + } + if resourceServer.AuthorizationEngine.Type == "" { + resourceServer.AuthorizationEngine.Type = providers.AuthorizationEngineTypeRBAC + } + if resourceServer.AuthorizationEngine.Type == providers.AuthorizationEngineTypeRBAC { + resourceServer.AuthorizationEngine.Properties = providers.AuthorizationEngineProperties{} + } + // Identifier: preserve existing if not provided; check uniqueness if changed if resourceServer.Identifier == "" { resourceServer.Identifier = existingResServer.Identifier @@ -489,13 +508,14 @@ func (rs *resourceService) UpdateResourceServer( } updatedRS = &providers.ResourceServer{ - ID: id, - Name: resourceServer.Name, - Description: resourceServer.Description, - Identifier: resourceServer.Identifier, - Type: resourceServer.Type, - OUID: resourceServer.OUID, - Delimiter: resourceServer.Delimiter, + ID: id, + Name: resourceServer.Name, + Description: resourceServer.Description, + Identifier: resourceServer.Identifier, + Type: resourceServer.Type, + OUID: resourceServer.OUID, + Delimiter: resourceServer.Delimiter, + AuthorizationEngine: resourceServer.AuthorizationEngine, } return nil }); err != nil { @@ -1364,6 +1384,11 @@ func (rs *resourceService) validateResourceServerCreate( if resourceServer.Type != "" && !resourceServer.Type.IsValid() { return &ErrorInvalidRequestFormat } + if resourceServer.AuthorizationEngine.Type != "" && + resourceServer.AuthorizationEngine.Type != providers.AuthorizationEngineTypeRBAC && + resourceServer.AuthorizationEngine.Type != providers.AuthorizationEngineTypeExternalAuthZENPDP { + return &ErrorInvalidRequestFormat + } if resourceServer.Delimiter != "" { if err := validateDelimiter(resourceServer.Delimiter); err != nil { return err @@ -1382,6 +1407,11 @@ func (rs *resourceService) validateResourceServerUpdate( if resourceServer.OUID == "" { return &ErrorInvalidRequestFormat } + if resourceServer.AuthorizationEngine.Type != "" && + resourceServer.AuthorizationEngine.Type != providers.AuthorizationEngineTypeRBAC && + resourceServer.AuthorizationEngine.Type != providers.AuthorizationEngineTypeExternalAuthZENPDP { + return &ErrorInvalidRequestFormat + } return nil } diff --git a/backend/internal/resource/service_test.go b/backend/internal/resource/service_test.go index 7b9ae451ff..cbf75fdae2 100644 --- a/backend/internal/resource/service_test.go +++ b/backend/internal/resource/service_test.go @@ -670,6 +670,18 @@ func (suite *ResourceServiceTestSuite) TestUpdateResourceServer_ValidationErrors resourceServer: providers.ResourceServer{Name: "test-rs", OUID: ""}, expectedError: ErrorInvalidRequestFormat, }, + { + name: "UnsupportedAuthorizationEngine", + id: "rs-123", + resourceServer: providers.ResourceServer{ + Name: "test-rs", + OUID: "ou-123", + AuthorizationEngine: providers.AuthorizationEngineConfig{ + Type: "unsupported", + }, + }, + expectedError: ErrorInvalidRequestFormat, + }, } for _, tc := range testCases { diff --git a/backend/internal/resource/store.go b/backend/internal/resource/store.go index 9838c61ed2..e72ac2b1f5 100644 --- a/backend/internal/resource/store.go +++ b/backend/internal/resource/store.go @@ -72,7 +72,8 @@ type resourceStore struct { // resourceServerProperties represents the JSON structure of PROPERTIES column. type resourceServerProperties struct { - Delimiter string `json:"delimiter"` + Delimiter string `json:"delimiter"` + AuthorizationEngine *providers.AuthorizationEngineConfig `json:"authorizationEngine,omitempty"` } // actionProperties represents the JSON structure of the ACTION.PROPERTIES column. @@ -932,6 +933,11 @@ func parseBoolFromCount(results []map[string]interface{}) (bool, error) { // resolveProperties extracts and sets the properties from the PROPERTIES column. func resolveProperties(row map[string]interface{}, rs *providers.ResourceServer) { + defer func() { + if rs.AuthorizationEngine.Type == "" { + rs.AuthorizationEngine.Type = providers.AuthorizationEngineTypeRBAC + } + }() if propsVal, ok := row["properties"]; ok && propsVal != nil { var props resourceServerProperties var propsBytes []byte @@ -946,6 +952,9 @@ func resolveProperties(row map[string]interface{}, rs *providers.ResourceServer) if len(propsBytes) > 0 { if err := json.Unmarshal(propsBytes, &props); err == nil { rs.Delimiter = props.Delimiter + if props.AuthorizationEngine != nil { + rs.AuthorizationEngine = *props.AuthorizationEngine + } } } } @@ -954,6 +963,9 @@ func resolveProperties(row map[string]interface{}, rs *providers.ResourceServer) // buildPropertiesJSON builds the PROPERTIES JSON for a providers.ResourceServer. func buildPropertiesJSON(rs providers.ResourceServer) interface{} { properties := resourceServerProperties{Delimiter: rs.Delimiter} + if rs.AuthorizationEngine.Type != "" || rs.AuthorizationEngine.Properties.PDPConnectionID != "" { + properties.AuthorizationEngine = &rs.AuthorizationEngine + } if propsJSON, err := json.Marshal(properties); err == nil { return propsJSON } diff --git a/backend/internal/system/config/config.go b/backend/internal/system/config/config.go index aee9cb3ac1..643b991351 100644 --- a/backend/internal/system/config/config.go +++ b/backend/internal/system/config/config.go @@ -622,8 +622,23 @@ func (c OAuthConfig) ToEngineConfig() engineconfig.OAuthConfig { } } +// AuthZENPDPConfig holds server defaults for AuthZEN PDP connections. +type AuthZENPDPConfig struct { + TimeoutMS int `yaml:"timeout_ms" json:"timeout_ms"` + RetryCount *int `yaml:"retry_count" json:"retry_count"` +} + +// Validate checks the default timeout and retry count. +func (c AuthZENPDPConfig) Validate() error { + if c.TimeoutMS < 0 || (c.RetryCount != nil && *c.RetryCount < 0) { + return fmt.Errorf("AuthZEN PDP timeout and retry defaults must not be negative") + } + return nil +} + // Config holds the complete configuration details of the server. type Config struct { + AuthZENPDP AuthZENPDPConfig `yaml:"authzen_pdp" json:"authzen_pdp"` Server engineconfig.ServerConfig `yaml:"server" json:"server"` Log LogConfig `yaml:"log" json:"log"` GateClient engineconfig.GateClientConfig `yaml:"gate_client" json:"gate_client"` @@ -759,6 +774,9 @@ func LoadConfig(configPath string, defaultPath string, serverHome string) (*Conf if err := cfg.Notification.Validate(); err != nil { return nil, err } + if err := cfg.AuthZENPDP.Validate(); err != nil { + return nil, err + } return &cfg, nil } diff --git a/backend/internal/system/config/config_test.go b/backend/internal/system/config/config_test.go index 014947eebb..9d6d21a222 100644 --- a/backend/internal/system/config/config_test.go +++ b/backend/internal/system/config/config_test.go @@ -17,6 +17,19 @@ import ( engineconfig "github.com/thunder-id/thunderid/pkg/thunderidengine/config" ) +func TestAuthZENPDPDefaultsMergeAndValidation(t *testing.T) { + defaultRetries, zeroRetries := 1, 0 + base := Config{AuthZENPDP: AuthZENPDPConfig{TimeoutMS: 500, RetryCount: &defaultRetries}} + user := Config{AuthZENPDP: AuthZENPDPConfig{TimeoutMS: 2000, RetryCount: &zeroRetries}} + mergeConfigs(&base, &user) + assert.Equal(t, 2000, base.AuthZENPDP.TimeoutMS) + assert.Equal(t, 0, *base.AuthZENPDP.RetryCount) + assert.NoError(t, base.AuthZENPDP.Validate()) + negative := -1 + assert.Error(t, (AuthZENPDPConfig{TimeoutMS: -1}).Validate()) + assert.Error(t, (AuthZENPDPConfig{RetryCount: &negative}).Validate()) +} + type ConfigTestSuite struct { suite.Suite originalEnvVars map[string]string diff --git a/backend/internal/system/i18n/core/defaults.go b/backend/internal/system/i18n/core/defaults.go index fa81adba44..774dca7826 100644 --- a/backend/internal/system/i18n/core/defaults.go +++ b/backend/internal/system/i18n/core/defaults.go @@ -387,6 +387,8 @@ var defaultMessages = map[string]string{ "error.authoidcservice.invalid_id_token_description": "The ID token is invalid or malformed", "error.authoidcservice.invalid_id_token_signature": "Invalid ID token signature", "error.authoidcservice.invalid_id_token_signature_description": "The ID token signature verification failed", + "error.authorization.invalid_request": "Invalid authorization request", + "error.authorization.invalid_request_description": "The authorization request is missing required policy evaluation data", "error.authzen.invalid_action": "Invalid action", "error.authzen.invalid_action_description": "Action name is not registered on the resource server", "error.authzen.invalid_request_format": "Invalid request format", @@ -421,12 +423,22 @@ var defaultMessages = map[string]string{ "error.certservice.invalid_reference_type_description": "The provided certificate reference type is invalid", "error.certservice.reference_update_not_allowed": "Reference update is not allowed", "error.certservice.reference_update_not_allowed_description": "Updating the reference type or ID of an existing certificate is not allowed", + "error.connectionservice.authzen_pdp_already_exists": "An AuthZEN PDP connection with the same name already exists", + "error.connectionservice.authzen_pdp_already_exists_description": "Choose a different name for the AuthZEN PDP connection", + "error.connectionservice.connection_has_blocking_dependencies": "Connection cannot be deleted", + "error.connectionservice.connection_has_blocking_dependencies_description": "The connection cannot be deleted because other resources depend on it. Remove or reassign them first.", + "error.connectionservice.connection_not_found": "Connection not found", + "error.connectionservice.connection_not_found_description": "No connection exists for the supplied identifier", + "error.connectionservice.invalid_authzen_pdp_endpoint": "Invalid AuthZEN PDP endpoint", + "error.connectionservice.invalid_authzen_pdp_endpoint_description": "The single and batch evaluation endpoints must be absolute URLs.", "error.connectionservice.invalid_category": "Invalid connection category", - "error.connectionservice.invalid_category_description": "The category must be one of: identity-provider, sms-provider", + "error.connectionservice.invalid_category_description": "The category must be one of: identity-provider, sms-provider, authorization-pdp", "error.connectionservice.invalid_limit_parameter": "Invalid limit parameter", "error.connectionservice.invalid_limit_parameter_description": "The limit parameter must be a positive integer", "error.connectionservice.invalid_offset_parameter": "Invalid offset parameter", "error.connectionservice.invalid_offset_parameter_description": "The offset parameter must be a non-negative integer", + "error.connectionservice.invalid_request_format": "Invalid request format", + "error.connectionservice.invalid_request_format_description": "The request body is malformed or contains invalid data", "error.consentenforcerservice.consent_create_failed": "Failed to create consent record", "error.consentenforcerservice.consent_create_failed_description": "Error while creating consent record in the consent service", "error.consentenforcerservice.consent_search_failed": "Failed to search consent records", diff --git a/backend/internal/system/importer/init.go b/backend/internal/system/importer/init.go index 148af4fcfc..fe6bd93ba7 100644 --- a/backend/internal/system/importer/init.go +++ b/backend/internal/system/importer/init.go @@ -8,6 +8,7 @@ import ( "github.com/thunder-id/thunderid/internal/agent" "github.com/thunder-id/thunderid/internal/application" + "github.com/thunder-id/thunderid/internal/connection/authzenpdp" layoutmgt "github.com/thunder-id/thunderid/internal/design/layout/mgt" thememgt "github.com/thunder-id/thunderid/internal/design/theme/mgt" "github.com/thunder-id/thunderid/internal/entitytype" @@ -67,6 +68,7 @@ func Initialize( presentationDefinitionService, credentialConfigurationService, serverConfigService, + authzenpdp.NewService(authzenpdp.NewStore()), ) importHandler := newImportHandler(importService) diff --git a/backend/internal/system/importer/service.go b/backend/internal/system/importer/service.go index 6e5cdeb2bb..676baafbf7 100644 --- a/backend/internal/system/importer/service.go +++ b/backend/internal/system/importer/service.go @@ -16,6 +16,7 @@ import ( agentmodel "github.com/thunder-id/thunderid/internal/agent/model" appmodel "github.com/thunder-id/thunderid/internal/application/model" "github.com/thunder-id/thunderid/internal/connection" + "github.com/thunder-id/thunderid/internal/connection/authzenpdp" layoutmgt "github.com/thunder-id/thunderid/internal/design/layout/mgt" thememgt "github.com/thunder-id/thunderid/internal/design/theme/mgt" "github.com/thunder-id/thunderid/internal/entitytype" @@ -65,6 +66,13 @@ type senderAdapter interface { ) } +// authZENPDPAdapter defines the AuthZEN PDP connection operations required by the importer. +type authZENPDPAdapter interface { + Create(ctx context.Context, connection authzenpdp.AuthZENPDPConnection) error + Get(ctx context.Context, id string) (*authzenpdp.AuthZENPDPConnection, error) + Update(ctx context.Context, id string, connection authzenpdp.AuthZENPDPConnection) error +} + type flowAdapter interface { CreateFlow(ctx context.Context, flowDef *flowmgt.FlowDefinition) ( *providers.CompleteFlowDefinition, @@ -218,6 +226,7 @@ type importService struct { applicationService applicationAdapter idpService idpAdapter senderService senderAdapter + authZENPDPService authZENPDPAdapter flowService flowAdapter ouService ouAdapter entityTypeService entityTypeAdapter @@ -254,11 +263,17 @@ func newImportService( presentationDefinitionService presentationDefinitionAdapter, credentialConfigurationService credentialConfigurationAdapter, serverConfigService serverConfigAdapter, + authZENPDPServices ...authZENPDPAdapter, ) ImportServiceInterface { + var authZENPDPService authZENPDPAdapter + if len(authZENPDPServices) > 0 { + authZENPDPService = authZENPDPServices[0] + } return &importService{ applicationService: applicationService, idpService: idpService, senderService: senderService, + authZENPDPService: authZENPDPService, flowService: flowService, ouService: ouService, entityTypeService: entityTypeService, @@ -450,6 +465,17 @@ func (s *importService) importDocument( func (s *importService) importConnection( ctx context.Context, doc parsedDocument, options *ImportOptions, dryRun bool, ) ImportItemOutcome { + if authZENPDP, err := connection.ParseAuthZENPDPConnectionFromNode(doc.Node); err != nil { + return ImportItemOutcome{ + ResourceType: resourceTypeConnection, + Status: statusFailed, + Code: ErrorInvalidYAMLContent.Code, + Message: fmt.Sprintf("failed to decode connection document: %v", err), + } + } else if authZENPDP != nil { + return s.importConnectionAuthZENPDP(ctx, authZENPDP, options, dryRun) + } + idpDTO, senderDTO, err := connection.ParseConnectionFromNode(doc.Node) if err != nil { return ImportItemOutcome{ @@ -466,6 +492,52 @@ func (s *importService) importConnection( return s.importConnectionSender(ctx, senderDTO, options, dryRun) } +// importConnectionAuthZENPDP creates or updates an external AuthZEN PDP connection from an import document. +func (s *importService) importConnectionAuthZENPDP( + ctx context.Context, req *authzenpdp.AuthZENPDPConnection, options *ImportOptions, dryRun bool, +) ImportItemOutcome { + if s.authZENPDPService == nil { + return unsupportedAdapterOutcome(resourceTypeConnection, "external AuthZEN PDP") + } + if options.IsUpsertEnabled() && req.ID != "" { + existing, err := s.authZENPDPService.Get(ctx, req.ID) + if err != nil { + return authZENPDPImportError(req, operationUpdate, err) + } + if existing != nil { + if dryRun { + return successOutcome(resourceTypeConnection, req.ID, req.Name, operationUpdate) + } + if err := s.authZENPDPService.Update(ctx, req.ID, *req); err != nil { + return authZENPDPImportError(req, operationUpdate, err) + } + return successOutcome(resourceTypeConnection, req.ID, req.Name, operationUpdate) + } + } + if dryRun { + return successOutcome(resourceTypeConnection, req.ID, req.Name, operationCreate) + } + if err := s.authZENPDPService.Create(ctx, *req); err != nil { + return authZENPDPImportError(req, operationCreate, err) + } + return successOutcome(resourceTypeConnection, req.ID, req.Name, operationCreate) +} + +// authZENPDPImportError converts an AuthZEN PDP connection error into an import outcome. +func authZENPDPImportError( + req *authzenpdp.AuthZENPDPConnection, operation string, err error, +) ImportItemOutcome { + return ImportItemOutcome{ + ResourceType: resourceTypeConnection, + ResourceID: req.ID, + ResourceName: req.Name, + Operation: operation, + Status: statusFailed, + Code: ErrorInvalidImportRequest.Code, + Message: err.Error(), + } +} + func (s *importService) importConnectionIDP( ctx context.Context, req *providers.IDPDTO, options *ImportOptions, dryRun bool, ) ImportItemOutcome { diff --git a/backend/pkg/thunderidengine/providers/model.go b/backend/pkg/thunderidengine/providers/model.go index 0fe784617b..c8aab8be3d 100644 --- a/backend/pkg/thunderidengine/providers/model.go +++ b/backend/pkg/thunderidengine/providers/model.go @@ -191,16 +191,39 @@ type Resource struct { // ResourceServer represents a resource server in both declarative resources and service layer. type ResourceServer struct { - ID string `yaml:"id" json:"-"` - Name string `yaml:"name" json:"name"` - Description string `yaml:"description,omitempty" json:"description,omitempty"` - Identifier string `yaml:"identifier" json:"identifier"` - Type ResourceServerType `yaml:"type,omitempty" json:"type,omitempty"` - OUID string `yaml:"ouId,omitempty" json:"ouId"` - OUHandle string `yaml:"ouHandle,omitempty" json:"-"` - Delimiter string `yaml:"delimiter,omitempty" json:"delimiter,omitempty" yamlfmt:"quoted"` - IsReadOnly bool `yaml:"-" json:"-"` - Resources []Resource `yaml:"resources,omitempty" json:"resources,omitempty"` + ID string `yaml:"id" json:"-"` + Name string `yaml:"name" json:"name"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Identifier string `yaml:"identifier" json:"identifier"` + Type ResourceServerType `yaml:"type,omitempty" json:"type,omitempty"` + OUID string `yaml:"ouId,omitempty" json:"ouId"` + OUHandle string `yaml:"ouHandle,omitempty" json:"-"` + Delimiter string `yaml:"delimiter,omitempty" json:"delimiter,omitempty" yamlfmt:"quoted"` + AuthorizationEngine AuthorizationEngineConfig `yaml:"authorizationEngine,omitempty" json:"authorizationEngine,omitempty"` + IsReadOnly bool `yaml:"-" json:"-"` + Resources []Resource `yaml:"resources,omitempty" json:"resources,omitempty"` +} + +// AuthorizationEngineTypeExternalAuthZENPDP identifies the external AuthZEN PDP authorization engine. +const AuthorizationEngineTypeExternalAuthZENPDP = "authzen_pdp" + +// AuthorizationEngineTypeRBAC identifies the default role-based authorization engine. +const AuthorizationEngineTypeRBAC = "rbac" + +// AuthorizationEngineConfig selects the authorization engine for a resource server. +type AuthorizationEngineConfig struct { + Type string `yaml:"type,omitempty" json:"type,omitempty"` + Properties AuthorizationEngineProperties `yaml:"properties,omitempty" json:"properties,omitempty"` +} + +// IsZero reports whether no authorization engine is configured. +func (c AuthorizationEngineConfig) IsZero() bool { + return c.Type == "" && c.Properties.PDPConnectionID == "" +} + +// AuthorizationEngineProperties configures the selected authorization engine. +type AuthorizationEngineProperties struct { + PDPConnectionID string `yaml:"pdpConnectionId,omitempty" json:"pdpConnectionId,omitempty"` } // CompleteFlowDefinition represents a complete flow definition with all details. @@ -1244,6 +1267,7 @@ type Subject struct { // AccessEvaluationResourceServer identifies the resource server for an access evaluation. type AccessEvaluationResourceServer struct { ID string `json:"id,omitempty"` + ResourceID string `json:"resourceId,omitempty"` Properties map[string]interface{} `json:"properties,omitempty"` } diff --git a/tests/integration/authzen/authzen_api_test.go b/tests/integration/authzen/authzen_api_test.go index 7ccdfb1241..04fae790b8 100644 --- a/tests/integration/authzen/authzen_api_test.go +++ b/tests/integration/authzen/authzen_api_test.go @@ -39,6 +39,7 @@ type AuthZENAPITestSuite struct { readPermission string writePermission string approvePermission string + approveActionName string otherPermission string } @@ -138,6 +139,7 @@ func (ts *AuthZENAPITestSuite) SetupSuite() { ) ts.Require().NoError(err, "create approve action") ts.approvePermission = approveAction.Permission + ts.approveActionName = approveAction.Handle roleID, err := testutils.CreateRole(testutils.Role{ Name: "AuthZEN Booking Reader", @@ -900,7 +902,7 @@ func (ts *AuthZENAPITestSuite) TestSearchActionReturnsAllowedActionsOnly() { ts.Require().NoError(json.Unmarshal(body, &result)) ts.ElementsMatch([]action{ {Name: ts.readPermission}, - {Name: ts.approvePermission}, + {Name: ts.approveActionName}, }, result.Results) } @@ -943,7 +945,7 @@ func (ts *AuthZENAPITestSuite) TestSearchActionResourceIDDoesNotScopeCurrentResu ts.Require().NoError(json.Unmarshal(body, &result)) ts.ElementsMatch([]action{ {Name: ts.readPermission}, - {Name: ts.approvePermission}, + {Name: ts.approveActionName}, }, result.Results) }) } diff --git a/tests/integration/authzen/external_authzen_pdp_test.go b/tests/integration/authzen/external_authzen_pdp_test.go new file mode 100644 index 0000000000..2d14fa188d --- /dev/null +++ b/tests/integration/authzen/external_authzen_pdp_test.go @@ -0,0 +1,1298 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package authzen + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/tests/integration/testutils" +) + +type externalAuthZENPDPConnectionResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Type string `json:"type"` + Endpoint string `json:"endpoint"` + BatchEndpoint string `json:"batchEndpoint,omitempty"` + TimeoutMS int `json:"timeoutMs"` + RetryCount int `json:"retryCount"` + SubjectProperties string `json:"subjectProperties,omitempty"` + SubjectPropertyMappings string `json:"subjectPropertyMappings,omitempty"` + SubjectAttributeMappings []externalAuthZENPDPSubjectAttributeMapping `json:"subjectAttributeMappings,omitempty"` +} + +type externalAuthZENPDPSubjectAttributeMapping struct { + UserType string `json:"userType"` + Attributes []externalAuthZENPDPSubjectAttributeRow `json:"attributes"` +} + +type externalAuthZENPDPSubjectAttributeRow struct { + Attribute string `json:"attribute"` + PDPAttribute string `json:"pdpAttribute,omitempty"` +} + +type externalAuthZENPDPConnectionSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +type externalAuthZENPDPDependenciesResponse struct { + TotalResults int `json:"totalResults"` + Count int `json:"count"` + Summary map[string]int `json:"summary"` + Usages []externalAuthZENPDPResourceUsage `json:"usages"` +} + +type externalAuthZENPDPResourceUsage struct { + ResourceType string `json:"resourceType"` + ID string `json:"id"` + DisplayName string `json:"displayName"` + BehaviorOnDelete string `json:"behaviorOnDelete"` +} + +const externalAuthZENPDPResourceIdentifier = "https://authzen-external-api.example.com" + +type externalAuthZENPDPResourceServerUpdate struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Identifier string `json:"identifier,omitempty"` + OUID string `json:"ouId"` + AuthorizationEngine externalAuthZENPDPAuthorizationEngineConfig `json:"authorizationEngine,omitempty"` +} + +type externalAuthZENPDPAuthorizationEngineConfig struct { + Type string `json:"type,omitempty"` + Properties externalAuthZENPDPAuthorizationEngineProps `json:"properties,omitempty"` +} + +type externalAuthZENPDPAuthorizationEngineProps struct { + PDPConnectionID string `json:"pdpConnectionId,omitempty"` +} + +type externalAuthZENPDPBatchRequest struct { + Evaluations []struct { + Subject map[string]interface{} `json:"subject"` + Resource map[string]interface{} `json:"resource"` + Action map[string]interface{} `json:"action"` + Context map[string]interface{} `json:"context,omitempty"` + } `json:"evaluations"` +} + +type externalAuthZENPDPBatchResponse struct { + Evaluations []evaluationResponse `json:"evaluations"` +} + +func TestExternalAuthZENPDPIntegrationSuite(t *testing.T) { + suite.Run(t, new(ExternalAuthZENPDPIntegrationSuite)) +} + +type ExternalAuthZENPDPIntegrationSuite struct { + suite.Suite + pdpServer *httptest.Server + ouID string + userTypeID string + userID string + rsID string + actionIDs []string + connectionID string + appID string + roleID string +} + +func (s *ExternalAuthZENPDPIntegrationSuite) SetupSuite() { + s.pdpServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/access/v1/evaluations": + var request externalAuthZENPDPBatchRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + response := externalAuthZENPDPBatchResponse{ + Evaluations: make([]evaluationResponse, 0, len(request.Evaluations)), + } + for _, evaluation := range request.Evaluations { + s.Contains( + []string{s.rsID, externalAuthZENPDPResourceIdentifier}, + evaluation.Resource["type"], + ) + actionName, _ := evaluation.Action["name"].(string) + decision := actionName == "read" || actionName == "write" + if actionName == "attribute-check" { + subjectProperties, _ := evaluation.Subject["properties"].(map[string]interface{}) + resourceProperties, _ := evaluation.Resource["properties"].(map[string]interface{}) + actionProperties, _ := evaluation.Action["properties"].(map[string]interface{}) + decision = subjectProperties["preferred_username"] == "external-authzen-user" && + resourceProperties["classification"] == "confidential" && + actionProperties["risk"] == "low" && evaluation.Context["tenant"] == "acme" + } + response.Evaluations = append(response.Evaluations, evaluationResponse{ + Decision: decision, + Context: map[string]interface{}{"source": "external-pdp"}, + }) + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + case "/unavailable": + http.Error(w, "PDP unavailable", http.StatusServiceUnavailable) + default: + http.NotFound(w, r) + } + })) + + ouID, err := testutils.CreateOrganizationUnit(testutils.OrganizationUnit{ + Handle: "authzen-pdp-test-ou", + Name: "External AuthZEN PDP Test OU", + }) + s.Require().NoError(err) + s.ouID = ouID + userTypeID, err := testutils.CreateUserType(testutils.UserType{ + Name: "external-authzen-person", + OUID: s.ouID, + Schema: map[string]interface{}{ + "username": map[string]interface{}{"type": "string"}, + }, + }) + s.Require().NoError(err) + s.userTypeID = userTypeID + userID, err := testutils.CreateUser(testutils.User{ + Type: "external-authzen-person", + OUID: s.ouID, + Attributes: json.RawMessage(`{"username":"external-authzen-user"}`), + }) + s.Require().NoError(err) + s.userID = userID + + resourceServer, err := createResourceServer(testutils.ResourceServer{ + Name: "External AuthZEN API", + Identifier: externalAuthZENPDPResourceIdentifier, + OUID: s.ouID, + }) + s.Require().NoError(err) + s.Require().Equal(externalAuthZENPDPResourceIdentifier, resourceServer.Identifier) + s.rsID = resourceServer.ID + for _, actionConfig := range []testutils.Action{ + {Name: "Read external bookings", Handle: "read"}, + {Name: "Write external bookings", Handle: "write"}, + {Name: "Delete external bookings", Handle: "delete"}, + {Name: "Evaluate booking attributes", Handle: "attribute-check"}, + } { + createdAction, actionErr := createAction(s.rsID, "", actionConfig) + s.Require().NoError(actionErr) + s.actionIDs = append(s.actionIDs, createdAction.ID) + } + + connectionBody := map[string]interface{}{ + "name": "External AuthZEN PDP", + "description": "External PDP used by the AuthZEN integration suite", + "endpoint": s.pdpServer.URL + "/access/v1/evaluation", + "batchEndpoint": s.pdpServer.URL + "/access/v1/evaluations", + "timeoutMs": 750, + "retryCount": 2, + "subjectProperties": "username", + "subjectPropertyMappings": "username: preferred_username", + "subjectAttributeMappings": []externalAuthZENPDPSubjectAttributeMapping{{ + UserType: "user", + Attributes: []externalAuthZENPDPSubjectAttributeRow{{ + Attribute: "username", + PDPAttribute: "preferred_username", + }}, + }}, + } + connectionPayload, err := json.Marshal(connectionBody) + s.Require().NoError(err) + request, err := http.NewRequest(http.MethodPost, testutils.TestServerURL+"/connections/authzen-pdp", bytes.NewReader(connectionPayload)) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err := testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + s.Require().Equal(http.StatusCreated, response.StatusCode, string(body)) + var connection externalAuthZENPDPConnectionResponse + s.Require().NoError(json.Unmarshal(body, &connection)) + s.connectionID = connection.ID + + updateBody, err := json.Marshal(externalAuthZENPDPResourceServerUpdate{ + Name: "External AuthZEN API", + Identifier: externalAuthZENPDPResourceIdentifier, + OUID: s.ouID, + AuthorizationEngine: externalAuthZENPDPAuthorizationEngineConfig{ + Type: "authzen_pdp", + Properties: externalAuthZENPDPAuthorizationEngineProps{ + PDPConnectionID: s.connectionID, + }, + }, + }) + s.Require().NoError(err) + request, err = http.NewRequest(http.MethodPut, testutils.TestServerURL+"/resource-servers/"+s.rsID, bytes.NewReader(updateBody)) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err = testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err = io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) + + s.appID, err = testutils.CreateApplication(testutils.Application{ + Name: "External AuthZEN Token Test App", + Description: "M2M application for external AuthZEN token issuance testing", + OUID: s.ouID, + Type: "m2m", + ClientID: "external_authzen_token_test_client", + ClientSecret: "external_authzen_token_test_secret", + InboundAuthConfig: []map[string]interface{}{{ + "type": "oauth2", + "config": map[string]interface{}{ + "clientId": "external_authzen_token_test_client", + "clientSecret": "external_authzen_token_test_secret", + "grantTypes": []string{"client_credentials"}, + "tokenEndpointAuthMethod": "client_secret_basic", + }, + }}, + }) + s.Require().NoError(err) + + s.roleID, err = testutils.CreateRole(testutils.Role{ + Name: "External AuthZEN Token Test Role", + Description: "Permission for external AuthZEN token issuance testing", + OUID: s.ouID, + Permissions: []testutils.ResourcePermissions{{ + ResourceServerID: s.rsID, + Permissions: []string{"read", "write", "delete", "attribute-check"}, + }}, + Assignments: []testutils.Assignment{{ID: s.appID, Type: "app"}}, + }) + s.Require().NoError(err) +} + +func (s *ExternalAuthZENPDPIntegrationSuite) TearDownSuite() { + if s.roleID != "" { + _ = testutils.DeleteRole(s.roleID) + } + if s.appID != "" { + _ = testutils.DeleteApplication(s.appID) + } + for _, actionID := range s.actionIDs { + request, err := http.NewRequest( + http.MethodDelete, + testutils.TestServerURL+"/resource-servers/"+s.rsID+"/actions/"+actionID, + nil, + ) + if err == nil { + response, requestErr := testutils.GetHTTPClient().Do(request) + if requestErr == nil { + _ = response.Body.Close() + } + } + } + if s.rsID != "" { + _ = testutils.DeleteResourceServer(s.rsID) + } + if s.userID != "" { + _ = testutils.DeleteUser(s.userID) + } + if s.userTypeID != "" { + _ = testutils.DeleteUserType(s.userTypeID) + } + if s.connectionID != "" { + request, err := http.NewRequest(http.MethodDelete, testutils.TestServerURL+"/connections/authzen-pdp/"+s.connectionID, nil) + if err == nil { + response, requestErr := testutils.GetHTTPClient().Do(request) + if requestErr == nil { + _ = response.Body.Close() + } + } + } + if s.ouID != "" { + _ = testutils.DeleteOrganizationUnit(s.ouID) + } + if s.pdpServer != nil { + s.pdpServer.Close() + } +} + +func (s *ExternalAuthZENPDPIntegrationSuite) TestExternalPDPConnectionEvaluatesAccess() { + payload := mustJSON(evaluationsRequest{Evaluations: []evaluationRequest{{ + Subject: subject{Type: "user", ID: s.userID}, + Resource: resource{Type: externalAuthZENPDPResourceIdentifier, ID: "booking-1"}, + Action: action{Name: "read"}, + }}}) + + request, err := http.NewRequest(http.MethodPost, testutils.TestServerURL+"/access/v1/evaluations", bytes.NewReader(payload)) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err := testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) + + var result evaluationsResponse + s.Require().NoError(json.Unmarshal(body, &result)) + s.Require().Len(result.Evaluations, 1) + s.True(result.Evaluations[0].Decision) +} + +func (s *ExternalAuthZENPDPIntegrationSuite) TestExternalPDPBatchContinuesForUnknownUser() { + payload := mustJSON(evaluationsRequest{Evaluations: []evaluationRequest{ + { + Subject: subject{Type: "user", ID: "unknown-external-authzen-user"}, + Resource: resource{Type: externalAuthZENPDPResourceIdentifier, ID: "booking-unknown-user"}, + Action: action{Name: "read"}, + }, + { + Subject: subject{Type: "user", ID: s.userID}, + Resource: resource{Type: externalAuthZENPDPResourceIdentifier, ID: "booking-known-user"}, + Action: action{Name: "write"}, + }, + }}) + + request, err := http.NewRequest( + http.MethodPost, + testutils.TestServerURL+"/access/v1/evaluations", + bytes.NewReader(payload), + ) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err := testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) + + var result evaluationsResponse + s.Require().NoError(json.Unmarshal(body, &result)) + s.Require().Len(result.Evaluations, 2) + s.False(result.Evaluations[0].Decision) + s.True(result.Evaluations[1].Decision) +} + +func (s *ExternalAuthZENPDPIntegrationSuite) TestExternalPDPAllDeniedIssuesTokenWithoutPermissions() { + status, body, tokenResponse := s.requestClientCredentialsToken("delete") + s.Require().Equal(http.StatusOK, status, string(body)) + s.NotEmpty(tokenResponse.AccessToken) + s.Empty(tokenResponse.Scope) +} + +func (s *ExternalAuthZENPDPIntegrationSuite) TestExternalPDPFiltersMixedTokenPermissions() { + status, body, tokenResponse := s.requestClientCredentialsToken("read write delete") + s.Require().Equal(http.StatusOK, status, string(body)) + s.NotEmpty(tokenResponse.AccessToken) + s.ElementsMatch([]string{"read", "write"}, strings.Fields(tokenResponse.Scope)) + + claims, err := testutils.DecodeJWT(tokenResponse.AccessToken) + s.Require().NoError(err) + s.Equal(externalAuthZENPDPResourceIdentifier, claims.Aud) +} + +func (s *ExternalAuthZENPDPIntegrationSuite) TestExternalPDPForwardsEvaluationAttributesAndContext() { + payload := mustJSON(evaluationsRequest{Evaluations: []evaluationRequest{{ + Subject: subject{Type: "user", ID: s.userID}, + Resource: resource{ + Type: externalAuthZENPDPResourceIdentifier, + ID: "booking-attributes", + Properties: map[string]interface{}{"classification": "confidential"}, + }, + Action: action{ + Name: "attribute-check", + Properties: map[string]interface{}{"risk": "low"}, + }, + Context: map[string]interface{}{"tenant": "acme"}, + }}}) + + request, err := http.NewRequest( + http.MethodPost, + testutils.TestServerURL+"/access/v1/evaluations", + bytes.NewReader(payload), + ) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err := testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) + + var result evaluationsResponse + s.Require().NoError(json.Unmarshal(body, &result)) + s.Require().Len(result.Evaluations, 1) + s.True(result.Evaluations[0].Decision) + s.Equal("external-pdp", result.Evaluations[0].Context["source"]) +} + +func (s *ExternalAuthZENPDPIntegrationSuite) TestExternalPDPConnectionSettingsAndUsagePersist() { + request, err := http.NewRequest( + http.MethodGet, + testutils.TestServerURL+"/connections/authzen-pdp/"+s.connectionID, + nil, + ) + s.Require().NoError(err) + response, err := testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) + + var connection externalAuthZENPDPConnectionResponse + s.Require().NoError(json.Unmarshal(body, &connection)) + s.Equal(s.connectionID, connection.ID) + s.Equal("External AuthZEN PDP", connection.Name) + s.Equal("External PDP used by the AuthZEN integration suite", connection.Description) + s.Equal("authzen-pdp", connection.Type) + s.Equal(s.pdpServer.URL+"/access/v1/evaluation", connection.Endpoint) + s.Equal(s.pdpServer.URL+"/access/v1/evaluations", connection.BatchEndpoint) + s.Equal(750, connection.TimeoutMS) + s.Equal(2, connection.RetryCount) + s.Equal("username", connection.SubjectProperties) + s.Equal("username: preferred_username", connection.SubjectPropertyMappings) + s.Require().Len(connection.SubjectAttributeMappings, 1) + s.Equal("user", connection.SubjectAttributeMappings[0].UserType) + s.Require().Len(connection.SubjectAttributeMappings[0].Attributes, 1) + s.Equal("preferred_username", connection.SubjectAttributeMappings[0].Attributes[0].PDPAttribute) + + updatedPayload := mustJSON(map[string]interface{}{ + "name": "External AuthZEN PDP", + "description": "Updated external PDP connection", + "endpoint": s.pdpServer.URL + "/access/v1/evaluation", + "batchEndpoint": s.pdpServer.URL + "/access/v1/evaluations", + "timeoutMs": 900, + "retryCount": 3, + "subjectProperties": "username ouId", + "subjectPropertyMappings": "username: preferred_username", + "subjectAttributeMappings": []externalAuthZENPDPSubjectAttributeMapping{{ + UserType: "user", + Attributes: []externalAuthZENPDPSubjectAttributeRow{{ + Attribute: "username", + PDPAttribute: "preferred_username", + }}, + }}, + }) + request, err = http.NewRequest( + http.MethodPut, + testutils.TestServerURL+"/connections/authzen-pdp/"+s.connectionID, + bytes.NewReader(updatedPayload), + ) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err = testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err = io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) + + request, err = http.NewRequest( + http.MethodGet, + testutils.TestServerURL+"/connections/authzen-pdp/"+s.connectionID, + nil, + ) + s.Require().NoError(err) + response, err = testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err = io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) + var updatedConnection externalAuthZENPDPConnectionResponse + s.Require().NoError(json.Unmarshal(body, &updatedConnection)) + s.Equal("Updated external PDP connection", updatedConnection.Description) + s.Equal(900, updatedConnection.TimeoutMS) + s.Equal(3, updatedConnection.RetryCount) + s.Equal("username ouId", updatedConnection.SubjectProperties) + + request, err = http.NewRequest( + http.MethodGet, + testutils.TestServerURL+"/connections/authzen-pdp", + nil, + ) + s.Require().NoError(err) + response, err = testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err = io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) + + var summaries []externalAuthZENPDPConnectionSummary + s.Require().NoError(json.Unmarshal(body, &summaries)) + s.True(containsExternalAuthZENPDPSummary(summaries, s.connectionID)) + + request, err = http.NewRequest( + http.MethodGet, + testutils.TestServerURL+"/resource-servers/"+s.rsID, + nil, + ) + s.Require().NoError(err) + response, err = testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err = io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) + + var resourceServer externalAuthZENPDPResourceServerUpdate + s.Require().NoError(json.Unmarshal(body, &resourceServer)) + s.Equal("authzen_pdp", resourceServer.AuthorizationEngine.Type) + s.Equal(s.connectionID, resourceServer.AuthorizationEngine.Properties.PDPConnectionID) + + request, err = http.NewRequest( + http.MethodGet, + testutils.TestServerURL+"/connections/authzen-pdp/"+s.connectionID+"/usages", + nil, + ) + s.Require().NoError(err) + response, err = testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err = io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) + + var dependencies externalAuthZENPDPDependenciesResponse + s.Require().NoError(json.Unmarshal(body, &dependencies)) + s.Equal(1, dependencies.TotalResults) + s.Equal(1, dependencies.Count) + s.Equal(1, dependencies.Summary["resourceServer"]) + s.Require().Len(dependencies.Usages, 1) + s.Equal(s.rsID, dependencies.Usages[0].ID) + s.Equal("resourceServer", dependencies.Usages[0].ResourceType) + s.Equal("restrict", dependencies.Usages[0].BehaviorOnDelete) + + request, err = http.NewRequest( + http.MethodDelete, + testutils.TestServerURL+"/connections/authzen-pdp/"+s.connectionID, + nil, + ) + s.Require().NoError(err) + response, err = testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, readErr := io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(readErr) + s.Equal(http.StatusConflict, response.StatusCode, string(body)) +} + +func (s *ExternalAuthZENPDPIntegrationSuite) TestExternalPDPConnectionValidationAndNotFound() { + tests := []struct { + name string + method string + path string + body string + status int + }{ + { + name: "malformed create body", + method: http.MethodPost, + path: "/connections/authzen-pdp", + body: "{", + status: http.StatusBadRequest, + }, + { + name: "missing batch endpoint", + method: http.MethodPost, + path: "/connections/authzen-pdp", + body: string(mustJSON(map[string]interface{}{ + "name": "Missing Batch Endpoint PDP", + "endpoint": s.pdpServer.URL + "/access/v1/evaluation", + })), + status: http.StatusBadRequest, + }, + { + name: "invalid connection category", + method: http.MethodGet, + path: "/connections?category=unsupported", + status: http.StatusBadRequest, + }, + { + name: "invalid list limit", + method: http.MethodGet, + path: "/connections?category=authorization-pdp&limit=invalid", + status: http.StatusBadRequest, + }, + { + name: "unknown connection", + method: http.MethodGet, + path: "/connections/authzen-pdp/unknown-connection", + status: http.StatusNotFound, + }, + { + name: "unknown connection update", + method: http.MethodPut, + path: "/connections/authzen-pdp/unknown-connection", + body: string(mustJSON(map[string]interface{}{ + "name": "Unknown Connection", + "endpoint": s.pdpServer.URL + "/access/v1/evaluation", + "batchEndpoint": s.pdpServer.URL + "/access/v1/evaluations", + })), + status: http.StatusNotFound, + }, + { + name: "unknown connection usages", + method: http.MethodGet, + path: "/connections/authzen-pdp/unknown-connection/usages", + status: http.StatusNotFound, + }, + { + name: "unknown connection delete", + method: http.MethodDelete, + path: "/connections/authzen-pdp/unknown-connection", + status: http.StatusNotFound, + }, + } + + for _, test := range tests { + s.Run(test.name, func() { + var bodyReader io.Reader + if test.body != "" { + bodyReader = strings.NewReader(test.body) + } + request, err := http.NewRequest(test.method, testutils.TestServerURL+test.path, bodyReader) + s.Require().NoError(err) + if test.body != "" { + request.Header.Set("Content-Type", "application/json") + } + response, err := testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, readErr := io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(readErr) + s.Equal(test.status, response.StatusCode, string(body)) + }) + } +} + +func (s *ExternalAuthZENPDPIntegrationSuite) TestExternalPDPConnectionListSupportsCategoryAndPagination() { + request, err := http.NewRequest( + http.MethodGet, + testutils.TestServerURL+"/connections?category=authorization-pdp&limit=1&offset=0", + nil, + ) + s.Require().NoError(err) + response, err := testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, readErr := io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(readErr) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) + + var result struct { + TotalResults int `json:"totalResults"` + StartIndex int `json:"startIndex"` + Count int `json:"count"` + Connections []struct { + ID string `json:"id"` + Type string `json:"type"` + Categories []string `json:"categories"` + } `json:"connections"` + } + s.Require().NoError(json.Unmarshal(body, &result)) + s.GreaterOrEqual(result.TotalResults, 1) + s.Equal(1, result.StartIndex) + s.Equal(1, result.Count) + s.Require().Len(result.Connections, 1) + s.Equal(s.connectionID, result.Connections[0].ID) + s.Equal("authzen-pdp", result.Connections[0].Type) + s.Equal([]string{"authorization-pdp"}, result.Connections[0].Categories) +} + +func (s *ExternalAuthZENPDPIntegrationSuite) TestExternalPDPConnectionUpdateRejectsInvalidEndpoint() { + payload := mustJSON(map[string]interface{}{ + "name": "Invalid Updated External PDP", + "endpoint": "ftp://pdp.example.com/access/v1/evaluation", + "batchEndpoint": s.pdpServer.URL + "/access/v1/evaluations", + }) + request, err := http.NewRequest( + http.MethodPut, + testutils.TestServerURL+"/connections/authzen-pdp/"+s.connectionID, + bytes.NewReader(payload), + ) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err := testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, readErr := io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(readErr) + s.Equal(http.StatusBadRequest, response.StatusCode, string(body)) + + connection := s.getExternalPDPConnection() + s.Equal("External AuthZEN PDP", connection.Name) +} + +func (s *ExternalAuthZENPDPIntegrationSuite) TestExternalPDPConnectionExportImportRoundTrip() { + payload := mustJSON(map[string]interface{}{ + "name": "Declarative External AuthZEN PDP", + "description": "External PDP declarative round trip", + "endpoint": s.pdpServer.URL + "/access/v1/evaluation", + "batchEndpoint": s.pdpServer.URL + "/access/v1/evaluations", + "timeoutMs": 1250, + "retryCount": 4, + }) + request, err := http.NewRequest( + http.MethodPost, + testutils.TestServerURL+"/connections/authzen-pdp", + bytes.NewReader(payload), + ) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err := testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, readErr := io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(readErr) + s.Require().Equal(http.StatusCreated, response.StatusCode, string(body)) + + var connection externalAuthZENPDPConnectionResponse + s.Require().NoError(json.Unmarshal(body, &connection)) + s.Require().NotEmpty(connection.ID) + defer func() { + request, requestErr := http.NewRequest( + http.MethodDelete, + testutils.TestServerURL+"/connections/authzen-pdp/"+connection.ID, + nil, + ) + if requestErr == nil { + response, responseErr := testutils.GetHTTPClient().Do(request) + if responseErr == nil { + _ = response.Body.Close() + } + } + }() + + exportPayload := mustJSON(map[string]interface{}{ + "connections": []string{connection.ID}, + }) + request, err = http.NewRequest( + http.MethodPost, + testutils.TestServerURL+"/export", + bytes.NewReader(exportPayload), + ) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err = testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, readErr = io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(readErr) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) + + var exportResponse struct { + Resources string `json:"resources"` + } + s.Require().NoError(json.Unmarshal(body, &exportResponse)) + s.Contains(exportResponse.Resources, "resource_type: connection") + s.Contains(exportResponse.Resources, "type: authzen-pdp") + s.Contains(exportResponse.Resources, "batchEndpoint:") + + deleteRequest, err := http.NewRequest( + http.MethodDelete, + testutils.TestServerURL+"/connections/authzen-pdp/"+connection.ID, + nil, + ) + s.Require().NoError(err) + deleteResponse, err := testutils.GetHTTPClient().Do(deleteRequest) + s.Require().NoError(err) + _ = deleteResponse.Body.Close() + s.Require().Equal(http.StatusNoContent, deleteResponse.StatusCode) + + importOptions := map[string]interface{}{ + "upsert": false, + "continueOnError": false, + "target": "runtime", + } + importPayload := mustJSON(map[string]interface{}{ + "content": exportResponse.Resources, + "options": importOptions, + }) + request, err = http.NewRequest( + http.MethodPost, + testutils.TestServerURL+"/import", + bytes.NewReader(importPayload), + ) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err = testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, readErr = io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(readErr) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) + + var importResponse struct { + Summary struct { + Imported int `json:"imported"` + Failed int `json:"failed"` + } `json:"summary"` + } + s.Require().NoError(json.Unmarshal(body, &importResponse)) + s.Equal(1, importResponse.Summary.Imported) + s.Equal(0, importResponse.Summary.Failed) + + imported := s.getExternalPDPConnectionByID(connection.ID) + s.Equal(connection.BatchEndpoint, imported.BatchEndpoint) + s.Equal(1250, imported.TimeoutMS) + s.Equal(4, imported.RetryCount) + + importOptions["upsert"] = true + request, err = http.NewRequest( + http.MethodPost, + testutils.TestServerURL+"/import", + bytes.NewReader(mustJSON(map[string]interface{}{ + "content": exportResponse.Resources, + "dryRun": true, + "options": importOptions, + })), + ) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err = testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, readErr = io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(readErr) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) + + request, err = http.NewRequest( + http.MethodPost, + testutils.TestServerURL+"/import", + bytes.NewReader(mustJSON(map[string]interface{}{ + "content": exportResponse.Resources, + "dryRun": true, + "options": map[string]interface{}{ + "upsert": false, + "continueOnError": false, + "target": "runtime", + }, + })), + ) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err = testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, readErr = io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(readErr) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) + + var dryRunResponse struct { + Summary struct { + Imported int `json:"imported"` + Failed int `json:"failed"` + } `json:"summary"` + } + s.Require().NoError(json.Unmarshal(body, &dryRunResponse)) + s.Equal(1, dryRunResponse.Summary.Imported) + s.Equal(0, dryRunResponse.Summary.Failed) + + invalidImport := strings.Replace( + exportResponse.Resources, + "endpoint: "+connection.Endpoint, + "endpoint: ftp://pdp.example.com/access/v1/evaluation", + 1, + ) + invalidImportPayload := mustJSON(map[string]interface{}{ + "content": invalidImport, + "options": map[string]interface{}{ + "upsert": false, + "continueOnError": true, + "target": "runtime", + }, + }) + request, err = http.NewRequest( + http.MethodPost, + testutils.TestServerURL+"/import", + bytes.NewReader(invalidImportPayload), + ) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err = testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, readErr = io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(readErr) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) + var invalidImportResponse struct { + Summary struct { + Failed int `json:"failed"` + } `json:"summary"` + } + s.Require().NoError(json.Unmarshal(body, &invalidImportResponse)) + s.Equal(1, invalidImportResponse.Summary.Failed) + + malformedImportPayload := mustJSON(map[string]interface{}{ + "content": "resource_type: connection\ntype: authzen-pdp\nname:\n - invalid\n", + "options": map[string]interface{}{ + "upsert": false, + "continueOnError": true, + "target": "runtime", + }, + }) + request, err = http.NewRequest( + http.MethodPost, + testutils.TestServerURL+"/import", + bytes.NewReader(malformedImportPayload), + ) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err = testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, readErr = io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(readErr) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) + s.Require().NoError(json.Unmarshal(body, &invalidImportResponse)) + s.Equal(1, invalidImportResponse.Summary.Failed) +} + +func (s *ExternalAuthZENPDPIntegrationSuite) TestExternalPDPUnusedConnectionCanBeDeleted() { + payload := mustJSON(map[string]interface{}{ + "name": "Unused External AuthZEN PDP", + "endpoint": s.pdpServer.URL + "/access/v1/evaluation", + "batchEndpoint": s.pdpServer.URL + "/access/v1/evaluations", + }) + request, err := http.NewRequest( + http.MethodPost, + testutils.TestServerURL+"/connections/authzen-pdp", + bytes.NewReader(payload), + ) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err := testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + s.Require().Equal(http.StatusCreated, response.StatusCode, string(body)) + + var connection externalAuthZENPDPConnectionResponse + s.Require().NoError(json.Unmarshal(body, &connection)) + s.NotEmpty(connection.ID) + + request, err = http.NewRequest( + http.MethodDelete, + testutils.TestServerURL+"/connections/authzen-pdp/"+connection.ID, + nil, + ) + s.Require().NoError(err) + response, err = testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err = io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + s.Equal(http.StatusNoContent, response.StatusCode, string(body)) + + request, err = http.NewRequest( + http.MethodGet, + testutils.TestServerURL+"/connections/authzen-pdp/"+connection.ID, + nil, + ) + s.Require().NoError(err) + response, err = testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + _ = response.Body.Close() + s.Equal(http.StatusNotFound, response.StatusCode) +} + +func (s *ExternalAuthZENPDPIntegrationSuite) TestResourceServerCanSwitchToRBAC() { + update := func(engineType, connectionID string) { + payload := mustJSON(externalAuthZENPDPResourceServerUpdate{ + Name: "External AuthZEN API", Identifier: externalAuthZENPDPResourceIdentifier, OUID: s.ouID, + AuthorizationEngine: externalAuthZENPDPAuthorizationEngineConfig{ + Type: engineType, + Properties: externalAuthZENPDPAuthorizationEngineProps{PDPConnectionID: connectionID}, + }, + }) + request, err := http.NewRequest(http.MethodPut, + testutils.TestServerURL+"/resource-servers/"+s.rsID, bytes.NewReader(payload)) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err := testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) + var result externalAuthZENPDPResourceServerUpdate + s.Require().NoError(json.Unmarshal(body, &result)) + s.Equal(engineType, result.AuthorizationEngine.Type) + if engineType == "rbac" { + s.Empty(result.AuthorizationEngine.Properties.PDPConnectionID) + } + } + defer update("authzen_pdp", s.connectionID) + update("rbac", s.connectionID) + request, err := http.NewRequest(http.MethodGet, testutils.TestServerURL+"/resource-servers/"+s.rsID, nil) + s.Require().NoError(err) + response, err := testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + defer response.Body.Close() + var result externalAuthZENPDPResourceServerUpdate + s.Require().Equal(http.StatusOK, response.StatusCode) + s.Require().NoError(json.NewDecoder(response.Body).Decode(&result)) + s.Equal("rbac", result.AuthorizationEngine.Type) + s.Empty(result.AuthorizationEngine.Properties.PDPConnectionID) +} + +func (s *ExternalAuthZENPDPIntegrationSuite) TestExternalPDPUnavailableFailsClosed() { + original := s.getExternalPDPConnection() + unavailable := original + unavailable.Endpoint = s.pdpServer.URL + "/unavailable" + unavailable.BatchEndpoint = s.pdpServer.URL + "/unavailable" + s.updateExternalPDPConnection(unavailable) + defer s.updateExternalPDPConnection(original) + + payload := mustJSON(evaluationsRequest{Evaluations: []evaluationRequest{{ + Subject: subject{Type: "user", ID: s.userID}, + Resource: resource{Type: externalAuthZENPDPResourceIdentifier, ID: "booking-unavailable"}, + Action: action{Name: "read"}, + }}}) + request, err := http.NewRequest( + http.MethodPost, + testutils.TestServerURL+"/access/v1/evaluations", + bytes.NewReader(payload), + ) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err := testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + s.Equal(http.StatusInternalServerError, response.StatusCode, string(body)) +} + +func (s *ExternalAuthZENPDPIntegrationSuite) TestResourceServerWithoutExternalPDPUsesDefaultEngine() { + resourceServer, err := createResourceServer(testutils.ResourceServer{ + Name: "Default Authorization Engine API", + Identifier: "https://default-authz-api.example.com", + OUID: s.ouID, + }) + s.Require().NoError(err) + defer func() { _ = testutils.DeleteResourceServer(resourceServer.ID) }() + + createdAction, err := createAction(resourceServer.ID, "", testutils.Action{ + Name: "Read default-engine resource", + Handle: "read", + }) + s.Require().NoError(err) + defer deleteExternalAuthZENPDPAction(resourceServer.ID, createdAction.ID) + + roleID, err := testutils.CreateRole(testutils.Role{ + Name: "Default Authorization Engine Test Role", + Description: "Permission evaluated by the default authorization engine", + OUID: s.ouID, + Permissions: []testutils.ResourcePermissions{{ + ResourceServerID: resourceServer.ID, + Permissions: []string{"read"}, + }}, + Assignments: []testutils.Assignment{{ID: s.userID, Type: "user"}}, + }) + s.Require().NoError(err) + defer func() { _ = testutils.DeleteRole(roleID) }() + + payload := mustJSON(evaluationsRequest{Evaluations: []evaluationRequest{{ + Subject: subject{Type: "user", ID: s.userID}, + Resource: resource{Type: resourceServer.Identifier, ID: "default-resource"}, + Action: action{Name: "read"}, + }}}) + request, err := http.NewRequest( + http.MethodPost, + testutils.TestServerURL+"/access/v1/evaluations", + bytes.NewReader(payload), + ) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err := testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) + + var result evaluationsResponse + s.Require().NoError(json.Unmarshal(body, &result)) + s.Require().Len(result.Evaluations, 1) + s.True(result.Evaluations[0].Decision) +} + +func (s *ExternalAuthZENPDPIntegrationSuite) TestExternalPDPConnectionRejectsInvalidEndpoint() { + payload := mustJSON(map[string]interface{}{ + "name": "Invalid External AuthZEN PDP", + "endpoint": "not-an-absolute-url", + "batchEndpoint": s.pdpServer.URL + "/access/v1/evaluations", + }) + request, err := http.NewRequest( + http.MethodPost, + testutils.TestServerURL+"/connections/authzen-pdp", + bytes.NewReader(payload), + ) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err := testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + s.Equal(http.StatusBadRequest, response.StatusCode, string(body)) +} + +func (s *ExternalAuthZENPDPIntegrationSuite) TestExternalPDPConnectionRejectsUnsupportedEndpointScheme() { + payload := mustJSON(map[string]interface{}{ + "name": "Unsupported Scheme External AuthZEN PDP", + "endpoint": "ftp://pdp.example.com/access/v1/evaluation", + "batchEndpoint": s.pdpServer.URL + "/access/v1/evaluations", + }) + request, err := http.NewRequest( + http.MethodPost, + testutils.TestServerURL+"/connections/authzen-pdp", + bytes.NewReader(payload), + ) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err := testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + s.Equal(http.StatusBadRequest, response.StatusCode, string(body)) +} + +func (s *ExternalAuthZENPDPIntegrationSuite) TestExternalPDPDecisionAllowsClientCredentialsToken() { + status, body, tokenResponse := s.requestClientCredentialsToken("read") + s.Require().Equal(http.StatusOK, status, string(body)) + s.NotEmpty(tokenResponse.AccessToken) + s.Equal("read", tokenResponse.Scope) + + claims, err := testutils.DecodeJWT(tokenResponse.AccessToken) + s.Require().NoError(err) + s.Equal(externalAuthZENPDPResourceIdentifier, claims.Aud) +} + +func (s *ExternalAuthZENPDPIntegrationSuite) requestClientCredentialsToken( + scope string, +) (int, []byte, testutils.TokenResponse) { + form := "grant_type=client_credentials&resource=" + url.QueryEscape(externalAuthZENPDPResourceIdentifier) + + "&scope=" + url.QueryEscape(scope) + request, err := http.NewRequest( + http.MethodPost, + testutils.TestServerURL+"/oauth2/token", + strings.NewReader(form), + ) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.SetBasicAuth("external_authzen_token_test_client", "external_authzen_token_test_secret") + + response, err := testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + + var tokenResponse testutils.TokenResponse + if response.StatusCode == http.StatusOK { + s.Require().NoError(json.Unmarshal(body, &tokenResponse)) + } + return response.StatusCode, body, tokenResponse +} + +func (s *ExternalAuthZENPDPIntegrationSuite) getExternalPDPConnection() externalAuthZENPDPConnectionResponse { + return s.getExternalPDPConnectionByID(s.connectionID) +} + +func (s *ExternalAuthZENPDPIntegrationSuite) getExternalPDPConnectionByID( + connectionID string, +) externalAuthZENPDPConnectionResponse { + request, err := http.NewRequest( + http.MethodGet, + testutils.TestServerURL+"/connections/authzen-pdp/"+connectionID, + nil, + ) + s.Require().NoError(err) + response, err := testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) + + var connection externalAuthZENPDPConnectionResponse + s.Require().NoError(json.Unmarshal(body, &connection)) + return connection +} + +func (s *ExternalAuthZENPDPIntegrationSuite) updateExternalPDPConnection( + connection externalAuthZENPDPConnectionResponse, +) { + payload := mustJSON(map[string]interface{}{ + "name": connection.Name, + "description": connection.Description, + "endpoint": connection.Endpoint, + "batchEndpoint": connection.BatchEndpoint, + "timeoutMs": connection.TimeoutMS, + "retryCount": connection.RetryCount, + "subjectProperties": connection.SubjectProperties, + "subjectPropertyMappings": connection.SubjectPropertyMappings, + "subjectAttributeMappings": connection.SubjectAttributeMappings, + }) + request, err := http.NewRequest( + http.MethodPut, + testutils.TestServerURL+"/connections/authzen-pdp/"+s.connectionID, + bytes.NewReader(payload), + ) + s.Require().NoError(err) + request.Header.Set("Content-Type", "application/json") + response, err := testutils.GetHTTPClient().Do(request) + s.Require().NoError(err) + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, response.StatusCode, string(body)) +} + +func deleteExternalAuthZENPDPAction(resourceServerID, actionID string) { + request, err := http.NewRequest( + http.MethodDelete, + testutils.TestServerURL+"/resource-servers/"+resourceServerID+"/actions/"+actionID, + nil, + ) + if err != nil { + return + } + response, err := testutils.GetHTTPClient().Do(request) + if err == nil { + _ = response.Body.Close() + } +} + +func containsExternalAuthZENPDPSummary( + summaries []externalAuthZENPDPConnectionSummary, + connectionID string, +) bool { + for _, summary := range summaries { + if summary.ID == connectionID && summary.Name == "External AuthZEN PDP" { + return true + } + } + return false +} diff --git a/tests/integration/authzen/model.go b/tests/integration/authzen/model.go index a7df583299..d06664560a 100644 --- a/tests/integration/authzen/model.go +++ b/tests/integration/authzen/model.go @@ -4,23 +4,27 @@ package authzen type subject struct { - Type string `json:"type,omitempty"` - ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + ID string `json:"id,omitempty"` + Properties map[string]interface{} `json:"properties,omitempty"` } type resource struct { - Type string `json:"type,omitempty"` - ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + ID string `json:"id,omitempty"` + Properties map[string]interface{} `json:"properties,omitempty"` } type action struct { - Name string `json:"name,omitempty"` + Name string `json:"name,omitempty"` + Properties map[string]interface{} `json:"properties,omitempty"` } type evaluationRequest struct { - Subject subject `json:"subject"` - Resource resource `json:"resource"` - Action action `json:"action"` + Subject subject `json:"subject"` + Resource resource `json:"resource"` + Action action `json:"action"` + Context map[string]interface{} `json:"context,omitempty"` } type evaluationResponse struct {