This is an automated email from the ASF dual-hosted git repository.

lahirujayathilake pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git

commit 5ee5dfccbc69d41c84842adfce21691412369b47
Author: lahiruj <[email protected]>
AuthorDate: Mon Apr 6 14:29:06 2026 -0400

    Add source of truth validation against LDAP and caching logic
---
 signer/go.mod                                 |  12 +-
 signer/go.sum                                 |  17 ++
 signer/internal/config/config.go              |  17 +-
 signer/internal/handler/sign.go               |   6 +-
 signer/internal/store/client_config.go        |   5 +-
 signer/internal/validation/dispatcher.go      | 172 ++++++++++++++++++
 signer/internal/validation/dispatcher_test.go | 219 +++++++++++++++++++++++
 signer/internal/validation/ldap.go            | 155 +++++++++++++++++
 signer/internal/validation/ldap_test.go       | 241 ++++++++++++++++++++++++++
 signer/internal/vault/client.go               |  51 ++++++
 signer/main.go                                |  21 ++-
 signer/migrations/001_initial_schema.up.sql   |   1 +
 12 files changed, 886 insertions(+), 31 deletions(-)

diff --git a/signer/go.mod b/signer/go.mod
index 4165ac052..33c57afdd 100644
--- a/signer/go.mod
+++ b/signer/go.mod
@@ -9,18 +9,22 @@ require (
        github.com/hashicorp/vault/api v1.15.0
        github.com/lestrrat-go/jwx/v2 v2.1.3
        github.com/prometheus/client_golang v1.20.5
-       golang.org/x/crypto v0.45.0
+       golang.org/x/crypto v0.48.0
        gopkg.in/yaml.v3 v3.0.1
 )
 
 require (
        filippo.io/edwards25519 v1.1.0 // indirect
+       github.com/Azure/go-ntlmssp v0.1.0 // indirect
        github.com/beorn7/perks v1.0.1 // indirect
        github.com/cenkalti/backoff/v4 v4.3.0 // indirect
        github.com/cespare/xxhash/v2 v2.3.0 // indirect
        github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
+       github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // 
indirect
        github.com/go-jose/go-jose/v4 v4.0.5 // indirect
+       github.com/go-ldap/ldap/v3 v3.4.13 // indirect
        github.com/goccy/go-json v0.10.3 // indirect
+       github.com/google/uuid v1.6.0 // indirect
        github.com/hashicorp/errwrap v1.1.0 // indirect
        github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
        github.com/hashicorp/go-multierror v1.1.1 // indirect
@@ -46,10 +50,10 @@ require (
        github.com/rogpeppe/go-internal v1.12.0 // indirect
        github.com/ryanuber/go-glob v1.0.0 // indirect
        github.com/segmentio/asm v1.2.0 // indirect
-       golang.org/x/net v0.47.0 // indirect
+       golang.org/x/net v0.50.0 // indirect
        golang.org/x/sync v0.20.0 // indirect
-       golang.org/x/sys v0.38.0 // indirect
-       golang.org/x/text v0.31.0 // indirect
+       golang.org/x/sys v0.41.0 // indirect
+       golang.org/x/text v0.34.0 // indirect
        golang.org/x/time v0.12.0 // indirect
        google.golang.org/protobuf v1.36.7 // indirect
 )
diff --git a/signer/go.sum b/signer/go.sum
index d12cd5b9b..e0742d015 100644
--- a/signer/go.sum
+++ b/signer/go.sum
@@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.1.0 
h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
 filippo.io/edwards25519 v1.1.0/go.mod 
h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
 github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 
h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
 github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod 
h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/Azure/go-ntlmssp v0.1.0 
h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
+github.com/Azure/go-ntlmssp v0.1.0/go.mod 
h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
 github.com/Microsoft/go-winio v0.6.2 
h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
 github.com/Microsoft/go-winio v0.6.2/go.mod 
h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
 github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod 
h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
@@ -38,10 +40,14 @@ github.com/fatih/color v1.16.0 
h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
 github.com/fatih/color v1.16.0/go.mod 
h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
 github.com/felixge/httpsnoop v1.0.4 
h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
 github.com/felixge/httpsnoop v1.0.4/go.mod 
h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 
h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
+github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod 
h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
 github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
 github.com/go-chi/chi/v5 v5.1.0/go.mod 
h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
 github.com/go-jose/go-jose/v4 v4.0.5 
h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
 github.com/go-jose/go-jose/v4 v4.0.5/go.mod 
h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
+github.com/go-ldap/ldap/v3 v3.4.13 
h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
+github.com/go-ldap/ldap/v3 v3.4.13/go.mod 
h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
 github.com/go-logr/logr v1.4.3/go.mod 
h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -58,6 +64,8 @@ github.com/golang-migrate/migrate/v4 v4.19.1 
h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7g
 github.com/golang-migrate/migrate/v4 v4.19.1/go.mod 
h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 github.com/google/go-cmp v0.6.0/go.mod 
h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod 
h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/hashicorp/errwrap v1.0.0/go.mod 
h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 github.com/hashicorp/errwrap v1.1.0 
h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
 github.com/hashicorp/errwrap v1.1.0/go.mod 
h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -169,17 +177,26 @@ go.opentelemetry.io/otel/trace v1.37.0 
h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
 go.opentelemetry.io/otel/trace v1.37.0/go.mod 
h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
 golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
 golang.org/x/crypto v0.45.0/go.mod 
h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod 
h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
 golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
 golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
+golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
 golang.org/x/sync v0.20.0/go.mod 
h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
 golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod 
h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
 golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
 golang.org/x/term v0.37.0/go.mod 
h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
+golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
 golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
 golang.org/x/text v0.31.0/go.mod 
h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+golang.org/x/text v0.34.0/go.mod 
h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
 golang.org/x/time v0.12.0/go.mod 
h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
 google.golang.org/protobuf v1.36.7 
h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
diff --git a/signer/internal/config/config.go b/signer/internal/config/config.go
index 87736d22e..75db37905 100644
--- a/signer/internal/config/config.go
+++ b/signer/internal/config/config.go
@@ -109,15 +109,8 @@ type OIDCConfig struct {
 }
 
 type ValidationConfig struct {
-       PrincipalValidator string         `yaml:"principal_validator"`
-       COmanage           COmanageConfig `yaml:"comanage"`
-}
-
-type COmanageConfig struct {
-       RegistryURL    string `yaml:"registry_url"`
-       APIPath        string `yaml:"api_path"`
-       TimeoutSeconds int    `yaml:"timeout_seconds"`
-       VerifySSL      bool   `yaml:"verify_ssl"`
+       PrincipalValidator string `yaml:"principal_validator"`
+       CacheTTLSeconds    int    `yaml:"cache_ttl_seconds"`
 }
 
 type LoggingConfig struct {
@@ -178,11 +171,7 @@ func DefaultConfig() *Config {
                        },
                        Validation: ValidationConfig{
                                PrincipalValidator: "noop",
-                               COmanage: COmanageConfig{
-                                       APIPath:        
"/registry/co_people.json",
-                                       TimeoutSeconds: 10,
-                                       VerifySSL:      true,
-                               },
+                               CacheTTLSeconds:    300, // 5 minutes cache
                        },
                },
                Logging: LoggingConfig{
diff --git a/signer/internal/handler/sign.go b/signer/internal/handler/sign.go
index a8ee07b5f..885fbcab4 100644
--- a/signer/internal/handler/sign.go
+++ b/signer/internal/handler/sign.go
@@ -61,7 +61,7 @@ type SignResponse struct {
 type SignHandler struct {
        oidcValidator      *auth.OIDCValidator
        policyEnforcer     *policy.Enforcer
-       principalValidator validation.PrincipalValidator
+       principalValidator *validation.ValidatorDispatcher
        vaultClient        *vaultpkg.Client
        auditLogger        *audit.Logger
        logger             *slog.Logger
@@ -70,7 +70,7 @@ type SignHandler struct {
 func NewSignHandler(
        oidcValidator *auth.OIDCValidator,
        policyEnforcer *policy.Enforcer,
-       principalValidator validation.PrincipalValidator,
+       principalValidator *validation.ValidatorDispatcher,
        vaultClient *vaultpkg.Client,
        auditLogger *audit.Logger,
        logger *slog.Logger,
@@ -169,7 +169,7 @@ func (h *SignHandler) Handle(w http.ResponseWriter, r 
*http.Request) {
        extensionsMap := cert.ExtensionsToMap(grantedExts)
        grantedExtNames := cert.ExtensionNames(grantedExts)
 
-       valResult, err := h.principalValidator.Validate(tenantID, clientID, 
req.Principal, identity.Subject)
+       valResult, err := h.principalValidator.ValidateForClient(r.Context(), 
tenantID, clientID, req.Principal, identity.Subject, clientCfg.PrincipalSource)
        if err != nil {
                metrics.SignRequestsTotal.WithLabelValues(tenantID, 
"error").Inc()
                if valErr, ok := err.(*validation.ValidationError); ok {
diff --git a/signer/internal/store/client_config.go 
b/signer/internal/store/client_config.go
index 606c2e32d..0b29d0380 100644
--- a/signer/internal/store/client_config.go
+++ b/signer/internal/store/client_config.go
@@ -32,6 +32,7 @@ type ClientConfig struct {
        AllowedKeyTypes          []string
        SourceAddressRestriction *string
        DeniedExtensions         []string
+       PrincipalSource          string
        Enabled                  bool
 }
 
@@ -46,14 +47,14 @@ func (d *DB) GetClientConfig(ctx context.Context, tenantID, 
clientID string) (*C
        err := d.QueryRowContext(ctx,
                `SELECT tenant_id, client_id, client_secret, target_host, 
target_port,
                        max_ttl_seconds, allowed_key_types, 
source_address_restriction,
-                       denied_extensions, enabled
+                       denied_extensions, principal_source, enabled
                 FROM client_ssh_configs
                 WHERE tenant_id = ? AND client_id = ?`,
                tenantID, clientID,
        ).Scan(
                &cc.TenantID, &cc.ClientID, &cc.ClientSecret, &cc.TargetHost, 
&cc.TargetPort,
                &cc.MaxTTLSeconds, &allowedKeyTypesJSON, 
&sourceAddressRestriction,
-               &deniedExtensionsJSON, &cc.Enabled,
+               &deniedExtensionsJSON, &cc.PrincipalSource, &cc.Enabled,
        )
        if err != nil {
                if err == sql.ErrNoRows {
diff --git a/signer/internal/validation/dispatcher.go 
b/signer/internal/validation/dispatcher.go
new file mode 100644
index 000000000..a75a725bb
--- /dev/null
+++ b/signer/internal/validation/dispatcher.go
@@ -0,0 +1,172 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package validation
+
+import (
+       "context"
+       "log/slog"
+       "sync"
+       "time"
+
+       "github.com/apache/airavata-custos/signer/internal/vault"
+)
+
+// CredentialFetcher abstracts Vault credential reads for validating.
+type CredentialFetcher interface {
+       GetValidationCredentials(ctx context.Context, tenantID, clientID 
string) (*vault.ValidationCredentials, error)
+}
+
+type cachedCreds struct {
+       creds     *vault.ValidationCredentials
+       err       error
+       fetchedAt time.Time
+}
+
+// ValidatorDispatcher resolves the correct principal validator per client and
+// delegates to the appropriate source of truth validator (noop, ldap, 
comanage).
+type ValidatorDispatcher struct {
+       credFetcher    CredentialFetcher
+       ldapConnector  LDAPConnector
+       fallbackSource string
+       cacheTTL       time.Duration
+       negativeTTL    time.Duration
+       cache          map[string]*cachedCreds
+       mu             sync.RWMutex
+       logger         *slog.Logger
+}
+
+func NewValidatorDispatcher(
+       credFetcher CredentialFetcher,
+       ldapConnector LDAPConnector,
+       fallbackSource string,
+       cacheTTL time.Duration,
+       logger *slog.Logger,
+) *ValidatorDispatcher {
+       if cacheTTL <= 0 {
+               cacheTTL = 5 * time.Minute
+       }
+       return &ValidatorDispatcher{
+               credFetcher:    credFetcher,
+               ldapConnector:  ldapConnector,
+               fallbackSource: fallbackSource,
+               cacheTTL:       cacheTTL,
+               negativeTTL:    30 * time.Second,
+               cache:          make(map[string]*cachedCreds),
+               logger:         logger,
+       }
+}
+
+// ValidateForClient dispatches principal validation based on the client's
+// configured principal_source. Called directly by the sign handler.
+func (d *ValidatorDispatcher) ValidateForClient(
+       ctx context.Context,
+       tenantID, clientID, principal, identitySubject, principalSource string,
+) (*ValidationResult, error) {
+       source := principalSource
+       if source == "" {
+               source = d.fallbackSource
+       }
+
+       switch source {
+       case "noop", "":
+               return &ValidationResult{
+                       Allowed:            true,
+                       ValidatedPrincipal: principal,
+               }, nil
+
+       case "ldap":
+               return d.validateLDAP(ctx, tenantID, clientID, principal, 
identitySubject)
+
+       case "comanage":
+               return nil, &ValidationError{
+                       Message:    "COmanage principal validation is not yet 
implemented",
+                       ReasonCode: "COMANAGE_NOT_IMPLEMENTED",
+               }
+
+       default:
+               return nil, &ValidationError{
+                       Message:    "Unknown principal source: " + source,
+                       ReasonCode: "UNKNOWN_PRINCIPAL_SOURCE",
+               }
+       }
+}
+
+func (d *ValidatorDispatcher) validateLDAP(
+       ctx context.Context,
+       tenantID, clientID, principal, identitySubject string,
+) (*ValidationResult, error) {
+       creds, err := d.fetchCredentials(ctx, tenantID, clientID)
+       if err != nil {
+               d.logger.Error("failed to fetch validation credentials",
+                       "error", err, "tenant_id", tenantID, "client_id", 
clientID)
+               return nil, &ValidationError{
+                       Message:    "Principal validation unavailable: 
credential fetch failed",
+                       ReasonCode: "LDAP_UNAVAILABLE",
+               }
+       }
+       if creds == nil {
+               return nil, &ValidationError{
+                       Message:    "LDAP validation not configured for this 
client",
+                       ReasonCode: "VALIDATION_NOT_CONFIGURED",
+               }
+       }
+
+       verifySSL := true
+       if creds.VerifySSL != nil {
+               verifySSL = *creds.VerifySSL
+       }
+
+       validator := NewLDAPValidator(LDAPConfig{
+               URL:               creds.LDAPUrl,
+               BindDN:            creds.BindDN,
+               BindPassword:      creds.BindPassword,
+               BaseDN:            creds.BaseDN,
+               SearchFilter:      creds.SearchFilter,
+               UsernameAttribute: creds.UsernameAttribute,
+               VerifySSL:         verifySSL,
+       }, d.ldapConnector)
+
+       return validator.Validate(tenantID, clientID, principal, 
identitySubject)
+}
+
+func (d *ValidatorDispatcher) fetchCredentials(ctx context.Context, tenantID, 
clientID string) (*vault.ValidationCredentials, error) {
+       key := tenantID + ":" + clientID
+
+       d.mu.RLock()
+       if entry, ok := d.cache[key]; ok {
+               ttl := d.cacheTTL
+               if entry.err != nil {
+                       ttl = d.negativeTTL
+               }
+               if time.Since(entry.fetchedAt) < ttl {
+                       d.mu.RUnlock()
+                       return entry.creds, entry.err
+               }
+       }
+       d.mu.RUnlock()
+
+       creds, err := d.credFetcher.GetValidationCredentials(ctx, tenantID, 
clientID)
+
+       d.mu.Lock()
+       d.cache[key] = &cachedCreds{
+               creds:     creds,
+               err:       err,
+               fetchedAt: time.Now(),
+       }
+       d.mu.Unlock()
+
+       return creds, err
+}
diff --git a/signer/internal/validation/dispatcher_test.go 
b/signer/internal/validation/dispatcher_test.go
new file mode 100644
index 000000000..b81bb5372
--- /dev/null
+++ b/signer/internal/validation/dispatcher_test.go
@@ -0,0 +1,219 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package validation
+
+import (
+       "context"
+       "errors"
+       "log/slog"
+       "os"
+       "testing"
+       "time"
+
+       "github.com/apache/airavata-custos/signer/internal/vault"
+       "github.com/go-ldap/ldap/v3"
+)
+
+// mockCredentialFetcher implements CredentialFetcher for testing.
+type mockCredentialFetcher struct {
+       creds     *vault.ValidationCredentials
+       err       error
+       callCount int
+}
+
+func (m *mockCredentialFetcher) GetValidationCredentials(ctx context.Context, 
tenantID, clientID string) (*vault.ValidationCredentials, error) {
+       m.callCount++
+       return m.creds, m.err
+}
+
+func testLogger() *slog.Logger {
+       return slog.New(slog.NewTextHandler(os.Stderr, 
&slog.HandlerOptions{Level: slog.LevelError}))
+}
+
+func TestDispatcher_NoopSource(t *testing.T) {
+       d := NewValidatorDispatcher(nil, nil, "noop", time.Minute, testLogger())
+
+       result, err := d.ValidateForClient(context.Background(), "t1", "c1", 
"jdoe", "sub1", "noop")
+       if err != nil {
+               t.Fatalf("unexpected error: %v", err)
+       }
+       if !result.Allowed {
+               t.Error("expected Allowed=true for noop")
+       }
+       if result.ValidatedPrincipal != "jdoe" {
+               t.Errorf("expected principal jdoe, got %s", 
result.ValidatedPrincipal)
+       }
+}
+
+func TestDispatcher_EmptySourceUsesFallback(t *testing.T) {
+       d := NewValidatorDispatcher(nil, nil, "noop", time.Minute, testLogger())
+
+       result, err := d.ValidateForClient(context.Background(), "t1", "c1", 
"jdoe", "sub1", "")
+       if err != nil {
+               t.Fatalf("unexpected error: %v", err)
+       }
+       if !result.Allowed {
+               t.Error("expected Allowed=true for noop fallback")
+       }
+}
+
+func TestDispatcher_LDAPSource_PrincipalFound(t *testing.T) {
+       boolTrue := true
+       fetcher := &mockCredentialFetcher{
+               creds: &vault.ValidationCredentials{
+                       Type:         "ldap",
+                       LDAPUrl:      "ldaps://ldap.test:636",
+                       BindDN:       "cn=reader,dc=test",
+                       BindPassword: "secret",
+                       BaseDN:       "ou=people,dc=test",
+                       SearchFilter: "(&(uid=%s)(objectClass=posixAccount))",
+                       VerifySSL:    &boolTrue,
+               },
+       }
+       conn := &mockLDAPConn{
+               searchRes: &ldap.SearchResult{
+                       Entries: 
[]*ldap.Entry{ldap.NewEntry("uid=jdoe,ou=people,dc=test", 
map[string][]string{"uid": {"jdoe"}})},
+               },
+       }
+       connector := &mockLDAPConnector{conn: conn}
+       d := NewValidatorDispatcher(fetcher, connector, "noop", time.Minute, 
testLogger())
+
+       result, err := d.ValidateForClient(context.Background(), "t1", "c1", 
"jdoe", "sub1", "ldap")
+       if err != nil {
+               t.Fatalf("unexpected error: %v", err)
+       }
+       if !result.Allowed {
+               t.Error("expected Allowed=true")
+       }
+}
+
+func TestDispatcher_LDAPSource_NoCredentials(t *testing.T) {
+       fetcher := &mockCredentialFetcher{creds: nil}
+       d := NewValidatorDispatcher(fetcher, nil, "noop", time.Minute, 
testLogger())
+
+       _, err := d.ValidateForClient(context.Background(), "t1", "c1", "jdoe", 
"sub1", "ldap")
+       if err == nil {
+               t.Fatal("expected error")
+       }
+       valErr := err.(*ValidationError)
+       if valErr.ReasonCode != "VALIDATION_NOT_CONFIGURED" {
+               t.Errorf("expected VALIDATION_NOT_CONFIGURED, got %s", 
valErr.ReasonCode)
+       }
+}
+
+func TestDispatcher_LDAPSource_VaultError(t *testing.T) {
+       fetcher := &mockCredentialFetcher{err: errors.New("vault sealed")}
+       d := NewValidatorDispatcher(fetcher, nil, "noop", time.Minute, 
testLogger())
+
+       _, err := d.ValidateForClient(context.Background(), "t1", "c1", "jdoe", 
"sub1", "ldap")
+       if err == nil {
+               t.Fatal("expected error")
+       }
+       valErr := err.(*ValidationError)
+       if valErr.ReasonCode != "LDAP_UNAVAILABLE" {
+               t.Errorf("expected LDAP_UNAVAILABLE, got %s", valErr.ReasonCode)
+       }
+}
+
+func TestDispatcher_CacheHit(t *testing.T) {
+       boolTrue := true
+       fetcher := &mockCredentialFetcher{
+               creds: &vault.ValidationCredentials{
+                       Type:         "ldap",
+                       LDAPUrl:      "ldaps://ldap.test:636",
+                       BindDN:       "cn=reader,dc=test",
+                       BindPassword: "secret",
+                       BaseDN:       "ou=people,dc=test",
+                       SearchFilter: "(&(uid=%s)(objectClass=posixAccount))",
+                       VerifySSL:    &boolTrue,
+               },
+       }
+       conn := &mockLDAPConn{
+               searchRes: &ldap.SearchResult{
+                       Entries: 
[]*ldap.Entry{ldap.NewEntry("uid=jdoe,ou=people,dc=test", 
map[string][]string{"uid": {"jdoe"}})},
+               },
+       }
+       connector := &mockLDAPConnector{conn: conn}
+       d := NewValidatorDispatcher(fetcher, connector, "noop", time.Minute, 
testLogger())
+
+       // First call — fetches from Vault
+       d.ValidateForClient(context.Background(), "t1", "c1", "jdoe", "sub1", 
"ldap")
+       // Second call — should use cache
+       d.ValidateForClient(context.Background(), "t1", "c1", "jdoe", "sub1", 
"ldap")
+
+       if fetcher.callCount != 1 {
+               t.Errorf("expected 1 Vault fetch (cached), got %d", 
fetcher.callCount)
+       }
+}
+
+func TestDispatcher_CacheExpiry(t *testing.T) {
+       boolTrue := true
+       fetcher := &mockCredentialFetcher{
+               creds: &vault.ValidationCredentials{
+                       Type:         "ldap",
+                       LDAPUrl:      "ldaps://ldap.test:636",
+                       BindDN:       "cn=reader,dc=test",
+                       BindPassword: "secret",
+                       BaseDN:       "ou=people,dc=test",
+                       SearchFilter: "(&(uid=%s)(objectClass=posixAccount))",
+                       VerifySSL:    &boolTrue,
+               },
+       }
+       conn := &mockLDAPConn{
+               searchRes: &ldap.SearchResult{
+                       Entries: 
[]*ldap.Entry{ldap.NewEntry("uid=jdoe,ou=people,dc=test", 
map[string][]string{"uid": {"jdoe"}})},
+               },
+       }
+       connector := &mockLDAPConnector{conn: conn}
+       d := NewValidatorDispatcher(fetcher, connector, "noop", 
1*time.Millisecond, testLogger())
+
+       // First call
+       d.ValidateForClient(context.Background(), "t1", "c1", "jdoe", "sub1", 
"ldap")
+       // Wait for cache to expire
+       time.Sleep(5 * time.Millisecond)
+       // Second call — should re-fetch
+       d.ValidateForClient(context.Background(), "t1", "c1", "jdoe", "sub1", 
"ldap")
+
+       if fetcher.callCount != 2 {
+               t.Errorf("expected 2 Vault fetches (cache expired), got %d", 
fetcher.callCount)
+       }
+}
+
+func TestDispatcher_UnknownSource(t *testing.T) {
+       d := NewValidatorDispatcher(nil, nil, "noop", time.Minute, testLogger())
+
+       _, err := d.ValidateForClient(context.Background(), "t1", "c1", "jdoe", 
"sub1", "unknown")
+       if err == nil {
+               t.Fatal("expected error")
+       }
+       valErr := err.(*ValidationError)
+       if valErr.ReasonCode != "UNKNOWN_PRINCIPAL_SOURCE" {
+               t.Errorf("expected UNKNOWN_PRINCIPAL_SOURCE, got %s", 
valErr.ReasonCode)
+       }
+}
+
+func TestDispatcher_ComanageStub(t *testing.T) {
+       d := NewValidatorDispatcher(nil, nil, "noop", time.Minute, testLogger())
+
+       _, err := d.ValidateForClient(context.Background(), "t1", "c1", "jdoe", 
"sub1", "comanage")
+       if err == nil {
+               t.Fatal("expected error")
+       }
+       valErr := err.(*ValidationError)
+       if valErr.ReasonCode != "COMANAGE_NOT_IMPLEMENTED" {
+               t.Errorf("expected COMANAGE_NOT_IMPLEMENTED, got %s", 
valErr.ReasonCode)
+       }
+}
diff --git a/signer/internal/validation/ldap.go 
b/signer/internal/validation/ldap.go
new file mode 100644
index 000000000..79c497747
--- /dev/null
+++ b/signer/internal/validation/ldap.go
@@ -0,0 +1,155 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package validation
+
+import (
+       "crypto/tls"
+       "fmt"
+
+       "github.com/go-ldap/ldap/v3"
+)
+
+// LDAPConnection abstracts LDAP operations for testability.
+type LDAPConnection interface {
+       Bind(username, password string) error
+       Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error)
+       Close() error
+}
+
+// LDAPConnector creates LDAP connections.
+type LDAPConnector interface {
+       Connect(url string, verifySSL bool) (LDAPConnection, error)
+}
+
+// DefaultLDAPConnector uses go-ldap/ldap to establish real LDAP connections.
+type DefaultLDAPConnector struct{}
+
+func NewDefaultLDAPConnector() *DefaultLDAPConnector {
+       return &DefaultLDAPConnector{}
+}
+
+func (c *DefaultLDAPConnector) Connect(url string, verifySSL bool) 
(LDAPConnection, error) {
+       conn, err := ldap.DialURL(url, ldap.DialWithTLSConfig(&tls.Config{
+               InsecureSkipVerify: !verifySSL,
+       }))
+       if err != nil {
+               return nil, fmt.Errorf("connecting to LDAP at %s: %w", url, err)
+       }
+       return conn, nil
+}
+
+// LDAPConfig holds the configuration needed to validate principals via LDAP.
+//
+// The lookup flow:
+//  1. Search LDAP using the OIDC subject (identitySubject) against 
SearchFilter
+//     e.g. (&(objectClass=posixAccount)(voPersonExternalID=%s))
+//  2. Extract the POSIX username from UsernameAttribute (e.g. "uid")
+//  3. Compare the extracted username with the requested principal
+type LDAPConfig struct {
+       URL               string
+       BindDN            string
+       BindPassword      string
+       BaseDN            string
+       SearchFilter      string // must contain %s — substituted with the OIDC 
subject
+       UsernameAttribute string // LDAP attribute holding the POSIX username 
(e.g. "uid")
+       VerifySSL         bool
+}
+
+// LDAPValidator resolves an OIDC subject to a POSIX username via LDAP 
directory lookup.
+type LDAPValidator struct {
+       config    LDAPConfig
+       connector LDAPConnector
+}
+
+func NewLDAPValidator(config LDAPConfig, connector LDAPConnector) 
*LDAPValidator {
+       usernameAttr := config.UsernameAttribute
+       if usernameAttr == "" {
+               usernameAttr = "uid"
+       }
+       config.UsernameAttribute = usernameAttr
+       return &LDAPValidator{
+               config:    config,
+               connector: connector,
+       }
+}
+
+func (v *LDAPValidator) Validate(tenantID, clientID, principal, 
identitySubject string) (*ValidationResult, error) {
+       conn, err := v.connector.Connect(v.config.URL, v.config.VerifySSL)
+       if err != nil {
+               return nil, &ValidationError{
+                       Message:    "Principal validation unavailable: LDAP 
connection failed",
+                       ReasonCode: "LDAP_UNAVAILABLE",
+               }
+       }
+       defer conn.Close()
+
+       if err := conn.Bind(v.config.BindDN, v.config.BindPassword); err != nil 
{
+               return nil, &ValidationError{
+                       Message:    "Principal validation unavailable: LDAP 
bind failed",
+                       ReasonCode: "LDAP_UNAVAILABLE",
+               }
+       }
+
+       // Search using the OIDC subject, not the requested principal
+       filter := fmt.Sprintf(v.config.SearchFilter, 
ldap.EscapeFilter(identitySubject))
+
+       result, err := conn.Search(ldap.NewSearchRequest(
+               v.config.BaseDN,
+               ldap.ScopeWholeSubtree,
+               ldap.NeverDerefAliases,
+               1,  // SizeLimit
+               10, // TimeLimit (seconds)
+               false,
+               filter,
+               []string{v.config.UsernameAttribute},
+               nil,
+       ))
+       if err != nil {
+               return nil, &ValidationError{
+                       Message:    "Principal validation unavailable: LDAP 
search failed",
+                       ReasonCode: "LDAP_UNAVAILABLE",
+               }
+       }
+
+       if len(result.Entries) == 0 {
+               return nil, &ValidationError{
+                       Message:    fmt.Sprintf("No POSIX account found for 
identity subject in directory"),
+                       ReasonCode: "LDAP_IDENTITY_NOT_FOUND",
+               }
+       }
+
+       // Extract the POSIX username from the directory entry
+       resolvedUsername := 
result.Entries[0].GetAttributeValue(v.config.UsernameAttribute)
+       if resolvedUsername == "" {
+               return nil, &ValidationError{
+                       Message:    fmt.Sprintf("Directory entry missing %s 
attribute", v.config.UsernameAttribute),
+                       ReasonCode: "LDAP_USERNAME_MISSING",
+               }
+       }
+
+       // Verify the requested principal matches the directory-resolved 
username
+       if resolvedUsername != principal {
+               return nil, &ValidationError{
+                       Message:    fmt.Sprintf("Requested principal %q does 
not match directory account %q", principal, resolvedUsername),
+                       ReasonCode: "LDAP_PRINCIPAL_MISMATCH",
+               }
+       }
+
+       return &ValidationResult{
+               Allowed:            true,
+               ValidatedPrincipal: resolvedUsername,
+       }, nil
+}
diff --git a/signer/internal/validation/ldap_test.go 
b/signer/internal/validation/ldap_test.go
new file mode 100644
index 000000000..f33ac0b07
--- /dev/null
+++ b/signer/internal/validation/ldap_test.go
@@ -0,0 +1,241 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package validation
+
+import (
+       "errors"
+       "testing"
+
+       "github.com/go-ldap/ldap/v3"
+)
+
+// mockLDAPConn implements LDAPConnection for testing.
+type mockLDAPConn struct {
+       bindErr   error
+       searchRes *ldap.SearchResult
+       searchErr error
+}
+
+func (m *mockLDAPConn) Bind(username, password string) error { return 
m.bindErr }
+func (m *mockLDAPConn) Search(req *ldap.SearchRequest) (*ldap.SearchResult, 
error) {
+       return m.searchRes, m.searchErr
+}
+func (m *mockLDAPConn) Close() error { return nil }
+
+// mockLDAPConnector implements LDAPConnector for testing.
+type mockLDAPConnector struct {
+       conn    LDAPConnection
+       connErr error
+}
+
+func (m *mockLDAPConnector) Connect(url string, verifySSL bool) 
(LDAPConnection, error) {
+       return m.conn, m.connErr
+}
+
+func baseLDAPConfig() LDAPConfig {
+       return LDAPConfig{
+               URL:               "ldaps://ldap.test:636",
+               BindDN:            "cn=reader,dc=test",
+               BindPassword:      "secret",
+               BaseDN:            "ou=people,dc=test",
+               SearchFilter:      
"(&(objectClass=posixAccount)(voPersonExternalID=%s))",
+               UsernameAttribute: "uid",
+               VerifySSL:         true,
+       }
+}
+
+// ldapEntry creates an LDAP entry with a uid attribute for testing.
+func ldapEntry(dn, uid string) *ldap.Entry {
+       return ldap.NewEntry(dn, map[string][]string{
+               "uid": {uid},
+       })
+}
+
+func TestLDAPValidator_SubjectResolvesToPrincipal(t *testing.T) {
+       conn := &mockLDAPConn{
+               searchRes: &ldap.SearchResult{
+                       Entries: 
[]*ldap.Entry{ldapEntry("uid=jdoe,ou=people,dc=test", "jdoe")},
+               },
+       }
+       connector := &mockLDAPConnector{conn: conn}
+       v := NewLDAPValidator(baseLDAPConfig(), connector)
+
+       // OIDC subject resolves to uid=jdoe, requested principal is jdoe → 
match
+       result, err := v.Validate("tenant1", "client1", "jdoe", 
"http://cilogon.org/serverE/users/100001";)
+       if err != nil {
+               t.Fatalf("unexpected error: %v", err)
+       }
+       if !result.Allowed {
+               t.Error("expected Allowed=true")
+       }
+       if result.ValidatedPrincipal != "jdoe" {
+               t.Errorf("expected principal jdoe, got %s", 
result.ValidatedPrincipal)
+       }
+}
+
+func TestLDAPValidator_PrincipalMismatch(t *testing.T) {
+       conn := &mockLDAPConn{
+               searchRes: &ldap.SearchResult{
+                       Entries: 
[]*ldap.Entry{ldapEntry("uid=jdoe,ou=people,dc=test", "jdoe")},
+               },
+       }
+       connector := &mockLDAPConnector{conn: conn}
+       v := NewLDAPValidator(baseLDAPConfig(), connector)
+
+       // OIDC subject resolves to jdoe, but caller requested "admin" → 
mismatch
+       _, err := v.Validate("tenant1", "client1", "admin", 
"http://cilogon.org/serverE/users/100001";)
+       if err == nil {
+               t.Fatal("expected error for principal mismatch")
+       }
+       valErr := err.(*ValidationError)
+       if valErr.ReasonCode != "LDAP_PRINCIPAL_MISMATCH" {
+               t.Errorf("expected LDAP_PRINCIPAL_MISMATCH, got %s", 
valErr.ReasonCode)
+       }
+}
+
+func TestLDAPValidator_IdentityNotFound(t *testing.T) {
+       conn := &mockLDAPConn{
+               searchRes: &ldap.SearchResult{Entries: []*ldap.Entry{}},
+       }
+       connector := &mockLDAPConnector{conn: conn}
+       v := NewLDAPValidator(baseLDAPConfig(), connector)
+
+       _, err := v.Validate("tenant1", "client1", "jdoe", 
"http://cilogon.org/serverE/users/999999";)
+       if err == nil {
+               t.Fatal("expected error")
+       }
+       valErr := err.(*ValidationError)
+       if valErr.ReasonCode != "LDAP_IDENTITY_NOT_FOUND" {
+               t.Errorf("expected LDAP_IDENTITY_NOT_FOUND, got %s", 
valErr.ReasonCode)
+       }
+}
+
+func TestLDAPValidator_MissingUsernameAttribute(t *testing.T) {
+       // Entry exists but has no uid attribute
+       entry := ldap.NewEntry("cn=someone,ou=people,dc=test", 
map[string][]string{
+               "cn": {"someone"},
+       })
+       conn := &mockLDAPConn{
+               searchRes: &ldap.SearchResult{Entries: []*ldap.Entry{entry}},
+       }
+       connector := &mockLDAPConnector{conn: conn}
+       v := NewLDAPValidator(baseLDAPConfig(), connector)
+
+       _, err := v.Validate("tenant1", "client1", "someone", "sub123")
+       if err == nil {
+               t.Fatal("expected error for missing uid attribute")
+       }
+       valErr := err.(*ValidationError)
+       if valErr.ReasonCode != "LDAP_USERNAME_MISSING" {
+               t.Errorf("expected LDAP_USERNAME_MISSING, got %s", 
valErr.ReasonCode)
+       }
+}
+
+func TestLDAPValidator_ConnectionFailure(t *testing.T) {
+       connector := &mockLDAPConnector{connErr: errors.New("connection 
refused")}
+       v := NewLDAPValidator(baseLDAPConfig(), connector)
+
+       _, err := v.Validate("t1", "c1", "jdoe", "sub123")
+       if err == nil {
+               t.Fatal("expected error")
+       }
+       valErr := err.(*ValidationError)
+       if valErr.ReasonCode != "LDAP_UNAVAILABLE" {
+               t.Errorf("expected LDAP_UNAVAILABLE, got %s", valErr.ReasonCode)
+       }
+}
+
+func TestLDAPValidator_BindFailure(t *testing.T) {
+       conn := &mockLDAPConn{bindErr: errors.New("invalid credentials")}
+       connector := &mockLDAPConnector{conn: conn}
+       v := NewLDAPValidator(baseLDAPConfig(), connector)
+
+       _, err := v.Validate("t1", "c1", "jdoe", "sub123")
+       if err == nil {
+               t.Fatal("expected error")
+       }
+       valErr := err.(*ValidationError)
+       if valErr.ReasonCode != "LDAP_UNAVAILABLE" {
+               t.Errorf("expected LDAP_UNAVAILABLE, got %s", valErr.ReasonCode)
+       }
+}
+
+func TestLDAPValidator_SearchError(t *testing.T) {
+       conn := &mockLDAPConn{searchErr: errors.New("search timeout")}
+       connector := &mockLDAPConnector{conn: conn}
+       v := NewLDAPValidator(baseLDAPConfig(), connector)
+
+       _, err := v.Validate("t1", "c1", "jdoe", "sub123")
+       if err == nil {
+               t.Fatal("expected error")
+       }
+       valErr := err.(*ValidationError)
+       if valErr.ReasonCode != "LDAP_UNAVAILABLE" {
+               t.Errorf("expected LDAP_UNAVAILABLE, got %s", valErr.ReasonCode)
+       }
+}
+
+func TestLDAPValidator_FilterEscaping(t *testing.T) {
+       var capturedFilter string
+       conn := &mockLDAPConn{
+               searchRes: &ldap.SearchResult{Entries: []*ldap.Entry{}},
+       }
+       wrappedConn := &filterCapturingConn{
+               LDAPConnection: conn,
+               capturedFilter: &capturedFilter,
+       }
+       connector := &mockLDAPConnector{conn: wrappedConn}
+       v := NewLDAPValidator(baseLDAPConfig(), connector)
+
+       // OIDC subject with injection characters — should be escaped in filter
+       v.Validate("t1", "c1", "jdoe", "http://evil.org/)(objectClass=*)")
+
+       expected := 
`(&(objectClass=posixAccount)(voPersonExternalID=http://evil.org/\29\28objectClass=\2a\29))`
+       if capturedFilter != expected {
+               t.Errorf("expected escaped filter %q, got %q", expected, 
capturedFilter)
+       }
+}
+
+func TestLDAPValidator_DefaultUsernameAttribute(t *testing.T) {
+       cfg := baseLDAPConfig()
+       cfg.UsernameAttribute = "" // should default to "uid"
+       conn := &mockLDAPConn{
+               searchRes: &ldap.SearchResult{
+                       Entries: 
[]*ldap.Entry{ldapEntry("uid=jdoe,ou=people,dc=test", "jdoe")},
+               },
+       }
+       connector := &mockLDAPConnector{conn: conn}
+       v := NewLDAPValidator(cfg, connector)
+
+       result, err := v.Validate("t1", "c1", "jdoe", "sub123")
+       if err != nil {
+               t.Fatalf("unexpected error: %v", err)
+       }
+       if result.ValidatedPrincipal != "jdoe" {
+               t.Errorf("expected jdoe, got %s", result.ValidatedPrincipal)
+       }
+}
+
+// filterCapturingConn wraps LDAPConnection to capture the search filter.
+type filterCapturingConn struct {
+       LDAPConnection
+       capturedFilter *string
+}
+
+func (c *filterCapturingConn) Search(req *ldap.SearchRequest) 
(*ldap.SearchResult, error) {
+       *c.capturedFilter = req.Filter
+       return c.LDAPConnection.Search(req)
+}
diff --git a/signer/internal/vault/client.go b/signer/internal/vault/client.go
index 856b5b48c..ea0a17f5b 100644
--- a/signer/internal/vault/client.go
+++ b/signer/internal/vault/client.go
@@ -388,3 +388,54 @@ func generateEd25519CAKey() (*CAKeyPair, error) {
                CreatedAt:  time.Now().UTC().Format(time.RFC3339),
        }, nil
 }
+
+// ValidationCredentials holds per-client - principal validation.
+// Stored in Vault at ssh-ca/{tenant_id}/{client_id}/validation.
+type ValidationCredentials struct {
+       Type              string `json:"type"`                         // 
"ldap" or "comanage"
+       LDAPUrl           string `json:"ldap_url,omitempty"`           // LDAP
+       BindDN            string `json:"bind_dn,omitempty"`            // LDAP
+       BindPassword      string `json:"bind_password,omitempty"`      // LDAP
+       BaseDN            string `json:"base_dn,omitempty"`            // LDAP
+       SearchFilter      string `json:"search_filter,omitempty"`      // LDAP 
— %s is the OIDC subject
+       UsernameAttribute string `json:"username_attribute,omitempty"` // LDAP 
— attribute for POSIX username (default: "uid")
+       VerifySSL         *bool  `json:"verify_ssl,omitempty"`         // LDAP 
/ COmanage
+       RegistryURL       string `json:"registry_url,omitempty"`       // 
COmanage
+       APIUser           string `json:"api_user,omitempty"`           // 
COmanage
+       APIKey            string `json:"api_key,omitempty"`            // 
COmanage
+       APIPath           string `json:"api_path,omitempty"`           // 
COmanage
+}
+
+// GetValidationCredentials reads per-client validation credentials from Vault.
+// Returns (nil, nil) if no credentials exist at the path.
+func (c *Client) GetValidationCredentials(ctx context.Context, tenantID, 
clientID string) (*ValidationCredentials, error) {
+       path := c.kvPath(tenantID, clientID, "validation")
+       secret, err := c.client.Logical().ReadWithContext(ctx, path)
+       if err != nil {
+               return nil, fmt.Errorf("reading validation credentials at %s: 
%w", path, err)
+       }
+       if secret == nil || secret.Data == nil {
+               return nil, nil
+       }
+
+       data, ok := secret.Data["data"].(map[string]interface{})
+       if !ok || data == nil {
+               return nil, nil
+       }
+
+       raw, err := json.Marshal(data)
+       if err != nil {
+               return nil, fmt.Errorf("marshaling validation data: %w", err)
+       }
+
+       var creds ValidationCredentials
+       if err := json.Unmarshal(raw, &creds); err != nil {
+               return nil, fmt.Errorf("unmarshaling validation credentials: 
%w", err)
+       }
+
+       if creds.Type == "" {
+               return nil, nil
+       }
+
+       return &creds, nil
+}
diff --git a/signer/main.go b/signer/main.go
index f5f5c13a6..1e8fd6e82 100644
--- a/signer/main.go
+++ b/signer/main.go
@@ -21,6 +21,7 @@ import (
        "log/slog"
        "os"
        "strings"
+       "time"
 
        "github.com/apache/airavata-custos/signer/internal/audit"
        "github.com/apache/airavata-custos/signer/internal/auth"
@@ -152,15 +153,19 @@ func runServer(cfg *config.Config, logger *slog.Logger, 
autoMigrate bool) {
        policyEnforcer := 
policy.NewEnforcer(cfg.Signer.Policy.Defaults.MaxTTLSeconds, 
cfg.Signer.Policy.Defaults.AllowedKeyTypes)
        auditLogger := audit.NewLogger(db, logger)
 
-       var principalValidator validation.PrincipalValidator
-       switch strings.ToLower(cfg.Signer.Validation.PrincipalValidator) {
-       case "comanage":
-               principalValidator = validation.NewCOmanageValidator()
-               logger.Info("using COmanage principal validator (stub)")
-       default:
-               principalValidator = validation.NewNoOpValidator()
-               logger.Info("using NoOp principal validator")
+       cacheTTL := time.Duration(cfg.Signer.Validation.CacheTTLSeconds) * 
time.Second
+       if cacheTTL <= 0 {
+               cacheTTL = 5 * time.Minute
        }
+       ldapConnector := validation.NewDefaultLDAPConnector()
+       principalValidator := validation.NewValidatorDispatcher(
+               vaultClient, ldapConnector,
+               cfg.Signer.Validation.PrincipalValidator,
+               cacheTTL, logger,
+       )
+       logger.Info("principal validation dispatcher initialized",
+               "fallback_source", cfg.Signer.Validation.PrincipalValidator,
+               "cache_ttl_seconds", cfg.Signer.Validation.CacheTTLSeconds)
 
        signHandler := handler.NewSignHandler(oidcValidator, policyEnforcer, 
principalValidator, vaultClient, auditLogger, logger)
        revokeHandler := handler.NewRevokeHandler(auditLogger, logger)
diff --git a/signer/migrations/001_initial_schema.up.sql 
b/signer/migrations/001_initial_schema.up.sql
index 5da7969cd..e69ac454a 100644
--- a/signer/migrations/001_initial_schema.up.sql
+++ b/signer/migrations/001_initial_schema.up.sql
@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS client_ssh_configs
     allowed_key_types          JSON         NOT NULL,
     source_address_restriction VARCHAR(255) NULL,
     denied_extensions          JSON         NULL,
+    principal_source           VARCHAR(20)  NOT NULL DEFAULT 'noop',
     enabled                    BOOLEAN      NOT NULL DEFAULT TRUE,
     created_at                 TIMESTAMP(6) NOT NULL DEFAULT 
CURRENT_TIMESTAMP(6),
     updated_at                 TIMESTAMP(6) NOT NULL DEFAULT 
CURRENT_TIMESTAMP(6)


Reply via email to