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)
