Add AssumeRole and AssumeRoleWithLDAPIdentity STS actions (#8003)
* test: add integration tests for AssumeRole and AssumeRoleWithLDAPIdentity STS actions - Add s3_sts_assume_role_test.go with comprehensive tests for AssumeRole: * Parameter validation (missing RoleArn, RoleSessionName, invalid duration) * AWS SigV4 authentication with valid/invalid credentials * Temporary credential generation and usage - Add s3_sts_ldap_test.go with tests for AssumeRoleWithLDAPIdentity: * Parameter validation (missing LDAP credentials, RoleArn) * LDAP authentication scenarios (valid/invalid credentials) * Integration with LDAP server (when configured) - Update Makefile with new test targets: * test-sts: run all STS tests * test-sts-assume-role: run AssumeRole tests only * test-sts-ldap: run LDAP STS tests only * test-sts-suite: run tests with full service lifecycle - Enhance setup_all_tests.sh: * Add OpenLDAP container setup for LDAP testing * Create test LDAP users (testuser, ldapadmin) * Set LDAP environment variables for tests * Update cleanup to remove LDAP container - Fix setup_keycloak.sh: * Enable verbose error logging for realm creation * Improve error diagnostics Tests use fail-fast approach (t.Fatal) when server not configured, ensuring clear feedback when infrastructure is missing. * feat: implement AssumeRole and AssumeRoleWithLDAPIdentity STS actions Implement two new STS actions to match MinIO's STS feature set: **AssumeRole Implementation:** - Add handleAssumeRole with full AWS SigV4 authentication - Integrate with existing IAM infrastructure via verifyV4Signature - Validate required parameters (RoleArn, RoleSessionName) - Validate DurationSeconds (900-43200 seconds range) - Generate temporary credentials with expiration - Return AWS-compatible XML response **AssumeRoleWithLDAPIdentity Implementation:** - Add handleAssumeRoleWithLDAPIdentity handler (stub) - Validate LDAP-specific parameters (LDAPUsername, LDAPPassword) - Validate common STS parameters (RoleArn, RoleSessionName, DurationSeconds) - Return proper error messages for missing LDAP provider - Ready for LDAP provider integration **Routing Fixes:** - Add explicit routes for AssumeRole and AssumeRoleWithLDAPIdentity - Prevent IAM handler from intercepting authenticated STS requests - Ensure proper request routing priority **Handler Infrastructure:** - Add IAM field to STSHandlers for SigV4 verification - Update NewSTSHandlers to accept IAM reference - Add STS-specific error codes and response types - Implement writeSTSErrorResponse for AWS-compatible errors The AssumeRole action is fully functional and tested. AssumeRoleWithLDAPIdentity requires LDAP provider implementation. * fix: update IAM matcher to exclude STS actions from interception Update the IAM handler matcher to check for STS actions (AssumeRole, AssumeRoleWithWebIdentity, AssumeRoleWithLDAPIdentity) and exclude them from IAM handler processing. This allows STS requests to be handled by the STS fallback handler even when they include AWS SigV4 authentication. The matcher now parses the form data to check the Action parameter and returns false for STS actions, ensuring they are routed to the correct handler. Note: This is a work-in-progress fix. Tests are still showing some routing issues that need further investigation. * fix: address PR review security issues for STS handlers This commit addresses all critical security issues from PR review: Security Fixes: - Use crypto/rand for cryptographically secure credential generation instead of time.Now().UnixNano() (fixes predictable credentials) - Add sts:AssumeRole permission check via VerifyActionPermission to prevent unauthorized role assumption - Generate proper session tokens using crypto/rand instead of placeholder strings Code Quality Improvements: - Refactor DurationSeconds parsing into reusable parseDurationSeconds() helper function used by all three STS handlers - Create generateSecureCredentials() helper for consistent and secure temporary credential generation - Fix iamMatcher to check query string as fallback when Action not found in form data LDAP Provider Implementation: - Add go-ldap/ldap/v3 dependency - Create LDAPProvider implementing IdentityProvider interface with full LDAP authentication support (connect, bind, search, groups) - Update ProviderFactory to create real LDAP providers - Wire LDAP provider into AssumeRoleWithLDAPIdentity handler Test Infrastructure: - Add LDAP user creation verification step in setup_all_tests.sh * fix: address PR feedback (Round 2) - config validation & provider improvements - Implement `validateLDAPConfig` in `ProviderFactory` - Improve `LDAPProvider.Initialize`: - Support `connectionTimeout` parsing (string/int/float) from config map - Warn if `BindDN` is present but `BindPassword` is empty - Improve `LDAPProvider.GetUserInfo`: - Add fallback to `searchUserGroups` if `memberOf` returns no groups (consistent with Authenticate) * fix: address PR feedback (Round 3) - LDAP connection improvements & build fix - Improve `LDAPProvider` connection handling: - Use `net.Dialer` with configured timeout for connection establishment - Enforce TLS 1.2+ (`MinVersion: tls.VersionTLS12`) for both LDAPS and StartTLS - Fix build error in `s3api_sts.go` (format verb for ErrorCode) * fix: address PR feedback (Round 4) - LDAP hardening, Authz check & Routing fix - LDAP Provider Hardening: - Prevent re-initialization - Enforce single user match in `GetUserInfo` (was explicit only in Authenticate) - Ensure connection closure if StartTLS fails - STS Handlers: - Add robust provider detection using type assertion - **Security**: Implement authorization check (`VerifyActionPermission`) after LDAP authentication - Routing: - Update tests to reflect that STS actions are handled by STS handler, not generic IAM * fix: address PR feedback (Round 5) - JWT tokens, ARN formatting, PrincipalArn CRITICAL FIXES: - Replace standalone credential generation with STS service JWT tokens - handleAssumeRole now generates proper JWT session tokens - handleAssumeRoleWithLDAPIdentity now generates proper JWT session tokens - Session tokens can be validated across distributed instances - Fix ARN formatting in responses - Extract role name from ARN using utils.ExtractRoleNameFromArn() - Prevents malformed ARNs like "arn:aws:sts::assumed-role/arn:aws:iam::..." - Add configurable AccountId for federated users - Add AccountId field to STSConfig (defaults to "111122223333") - PrincipalArn now uses configured account ID instead of hardcoded "aws" - Enables proper trust policy validation IMPROVEMENTS: - Sanitize LDAP authentication error messages (don't leak internal details) - Remove duplicate comment in provider detection - Add utils import for ARN parsing utilities * feat: implement LDAP connection pooling to prevent resource exhaustion PERFORMANCE IMPROVEMENT: - Add connection pool to LDAPProvider (default size: 10 connections) - Reuse LDAP connections across authentication requests - Prevent file descriptor exhaustion under high load IMPLEMENTATION: - connectionPool struct with channel-based connection management - getConnection(): retrieves from pool or creates new connection - returnConnection(): returns healthy connections to pool - createConnection(): establishes new LDAP connection with TLS support - Close(): cleanup method to close all pooled connections - Connection health checking (IsClosing()) before reuse BENEFITS: - Reduced connection overhead (no TCP handshake per request) - Better resource utilization under load - Prevents "too many open files" errors - Non-blocking pool operations (creates new conn if pool empty) * fix: correct TokenGenerator access in STS handlers CRITICAL FIX: - Make TokenGenerator public in STSService (was private tokenGenerator) - Update all references from Config.TokenGenerator to TokenGenerator - Remove TokenGenerator from STSConfig (it belongs in STSService) This fixes the "NotImplemented" errors in distributed and Keycloak tests. The issue was that Round 5 changes tried to access Config.TokenGenerator which didn't exist - TokenGenerator is a field in STSService, not STSConfig. The TokenGenerator is properly initialized in STSService.Initialize() and is now accessible for JWT token generation in AssumeRole handlers. * fix: update tests to use public TokenGenerator field Following the change to make TokenGenerator public in STSService, this commit updates the test files to reference the correct public field name. This resolves compilation errors in the IAM STS test suite. * fix: update distributed tests to use valid Keycloak users Updated s3_iam_distributed_test.go to use 'admin-user' and 'read-user' which exist in the standard Keycloak setup provided by setup_keycloak.sh. This resolves 'unknown test user' errors in distributed integration tests. * fix: ensure iam_config.json exists in setup target for CI The GitHub Actions workflow calls 'make setup' which was not creating iam_config.json, causing the server to start without IAM integration enabled (iamIntegration = nil), resulting in NotImplemented errors. Now 'make setup' copies iam_config.local.json to iam_config.json if it doesn't exist, ensuring IAM is properly configured in CI. * fix(iam/ldap): fix connection pool race and rebind corruption - Add atomic 'closed' flag to connection pool to prevent racing on Close() - Rebind authenticated user connections back to service account before returning to pool - Close connections on error instead of returning potentially corrupted state to pool * fix(iam/ldap): populate standard TokenClaims fields in ValidateToken - Set Subject, Issuer, Audience, IssuedAt, and ExpiresAt to satisfy the interface - Use time.Time for timestamps as required by TokenClaims struct - Default to 1 hour TTL for LDAP tokens * fix(s3api): include account ID in STS AssumedRoleUser ARN - Consistent with AWS, include the account ID in the assumed-role ARN - Use the configured account ID from STS service if available, otherwise default to '111122223333' - Apply to both AssumeRole and AssumeRoleWithLDAPIdentity handlers - Also update .gitignore to ignore IAM test environment files * refactor(s3api): extract shared STS credential generation logic - Move common logic for session claims and credential generation to prepareSTSCredentials - Update handleAssumeRole and handleAssumeRoleWithLDAPIdentity to use the helper - Remove stale comments referencing outdated line numbers * feat(iam/ldap): make pool size configurable and add audience support - Add PoolSize to LDAPConfig (default 10) - Add Audience to LDAPConfig to align with OIDC validation - Update initialization and ValidateToken to use new fields * update tests * debug * chore(iam): cleanup debug prints and fix test config port * refactor(iam): use mapstructure for LDAP config parsing * feat(sts): implement strict trust policy validation for AssumeRole * test(iam): refactor STS tests to use AWS SDK signer * test(s3api): implement ValidateTrustPolicyForPrincipal in MockIAMIntegration * fix(s3api): ensure IAM matcher checks query string on ParseForm error * fix(sts): use crypto/rand for secure credentials and extract constants * fix(iam): fix ldap connection leaks and add insecure warning * chore(iam): improved error wrapping and test parameterization * feat(sts): add support for LDAPProviderName parameter * Update weed/iam/ldap/ldap_provider.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update weed/s3api/s3api_sts.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix(sts): use STSErrSTSNotReady when LDAP provider is missing * fix(sts): encapsulate TokenGenerator in STSService and add getter --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -25,7 +25,7 @@ func TestPolicyVariableSubstitution(t *testing.T) {
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "https://test-issuer.com",
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -102,7 +102,7 @@ func TestConditionWithNumericComparison(t *testing.T) {
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "https://test-issuer.com",
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
Condition: map[string]map[string]interface{}{
|
||||
|
||||
@@ -421,7 +421,7 @@ func TestTrustPolicyWildcardPrincipal(t *testing.T) {
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "https://test-issuer.com",
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -440,7 +440,7 @@ func TestTrustPolicyWildcardPrincipal(t *testing.T) {
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": []interface{}{"specific-provider", "https://test-issuer.com"},
|
||||
"Federated": []interface{}{"specific-provider", "test-oidc"},
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -646,7 +646,7 @@ func setupTestPoliciesAndRoles(t *testing.T, manager *IAMManager) {
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "https://test-issuer.com",
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
|
||||
@@ -346,7 +346,7 @@ func (m *IAMManager) ValidateTrustPolicy(ctx context.Context, roleArn, provider,
|
||||
if principal, ok := statement.Principal.(map[string]interface{}); ok {
|
||||
if federated, ok := principal["Federated"].(string); ok {
|
||||
// For OIDC, check against issuer URL
|
||||
if provider == "oidc" && federated == "https://test-issuer.com" {
|
||||
if provider == "oidc" && federated == "test-oidc" {
|
||||
return true
|
||||
}
|
||||
// For LDAP, check against test-ldap
|
||||
@@ -391,8 +391,24 @@ func (m *IAMManager) validateTrustPolicyForWebIdentity(ctx context.Context, role
|
||||
|
||||
// The issuer is the federated provider for OIDC
|
||||
if iss, ok := tokenClaims["iss"].(string); ok {
|
||||
// Default to issuer URL
|
||||
requestContext["aws:FederatedProvider"] = iss
|
||||
requestContext["oidc:iss"] = iss
|
||||
|
||||
// Try to resolve provider name from issuer for better policy matching
|
||||
// This allows policies to reference the provider name (e.g. "keycloak") instead of the full issuer URL
|
||||
if m.stsService != nil {
|
||||
for name, provider := range m.stsService.GetProviders() {
|
||||
if oidcProvider, ok := provider.(interface{ GetIssuer() string }); ok {
|
||||
confIssuer := oidcProvider.GetIssuer()
|
||||
|
||||
if confIssuer == iss {
|
||||
requestContext["aws:FederatedProvider"] = name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sub, ok := tokenClaims["sub"].(string); ok {
|
||||
|
||||
43
weed/iam/integration/iam_manager_trust.go
Normal file
43
weed/iam/integration/iam_manager_trust.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/utils"
|
||||
)
|
||||
|
||||
// ValidateTrustPolicyForPrincipal validates if a principal is allowed to assume a role
|
||||
func (m *IAMManager) ValidateTrustPolicyForPrincipal(ctx context.Context, roleArn, principalArn string) error {
|
||||
if !m.initialized {
|
||||
return fmt.Errorf("IAM manager not initialized")
|
||||
}
|
||||
|
||||
// Extract role name from ARN
|
||||
roleName := utils.ExtractRoleNameFromArn(roleArn)
|
||||
|
||||
// Get role definition
|
||||
roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get role %s: %w", roleName, err)
|
||||
}
|
||||
|
||||
if roleDef.TrustPolicy == nil {
|
||||
return fmt.Errorf("role has no trust policy")
|
||||
}
|
||||
|
||||
// Create evaluation context
|
||||
evalCtx := &policy.EvaluationContext{
|
||||
Principal: principalArn,
|
||||
Action: "sts:AssumeRole",
|
||||
Resource: roleArn,
|
||||
}
|
||||
|
||||
// Evaluate the trust policy
|
||||
if !m.evaluateTrustPolicy(roleDef.TrustPolicy, evalCtx) {
|
||||
return fmt.Errorf("trust policy denies access to principal: %s", principalArn)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
571
weed/iam/ldap/ldap_provider.go
Normal file
571
weed/iam/ldap/ldap_provider.go
Normal file
@@ -0,0 +1,571 @@
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
||||
)
|
||||
|
||||
// LDAPConfig holds configuration for LDAP provider
|
||||
type LDAPConfig struct {
|
||||
// Server is the LDAP server URL (ldap:// or ldaps://)
|
||||
Server string `json:"server"`
|
||||
|
||||
// BindDN is the DN used to bind for searches (optional for anonymous bind)
|
||||
BindDN string `json:"bindDN,omitempty"`
|
||||
|
||||
// BindPassword is the password for the bind DN
|
||||
BindPassword string `json:"bindPassword,omitempty"`
|
||||
|
||||
// BaseDN is the base DN for user searches
|
||||
BaseDN string `json:"baseDN"`
|
||||
|
||||
// UserFilter is the filter to find users (use %s for username placeholder)
|
||||
// Example: "(uid=%s)" or "(cn=%s)" or "(&(objectClass=person)(uid=%s))"
|
||||
UserFilter string `json:"userFilter"`
|
||||
|
||||
// GroupFilter is the filter to find user groups (use %s for user DN placeholder)
|
||||
// Example: "(member=%s)" or "(memberUid=%s)"
|
||||
GroupFilter string `json:"groupFilter,omitempty"`
|
||||
|
||||
// GroupBaseDN is the base DN for group searches (defaults to BaseDN)
|
||||
GroupBaseDN string `json:"groupBaseDN,omitempty"`
|
||||
|
||||
// Attributes to retrieve from LDAP
|
||||
Attributes LDAPAttributes `json:"attributes,omitempty"`
|
||||
|
||||
// UseTLS enables StartTLS
|
||||
UseTLS bool `json:"useTLS,omitempty"`
|
||||
|
||||
// InsecureSkipVerify skips TLS certificate verification
|
||||
InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"`
|
||||
|
||||
// ConnectionTimeout is the connection timeout
|
||||
ConnectionTimeout time.Duration `json:"connectionTimeout,omitempty"`
|
||||
|
||||
// PoolSize is the number of connections in the pool (default: 10)
|
||||
PoolSize int `json:"poolSize,omitempty"`
|
||||
|
||||
// Audience is the expected audience for tokens (optional)
|
||||
Audience string `json:"audience,omitempty"`
|
||||
}
|
||||
|
||||
// LDAPAttributes maps LDAP attribute names
|
||||
type LDAPAttributes struct {
|
||||
Email string `json:"email,omitempty"` // Default: mail
|
||||
DisplayName string `json:"displayName,omitempty"` // Default: cn
|
||||
Groups string `json:"groups,omitempty"` // Default: memberOf
|
||||
UID string `json:"uid,omitempty"` // Default: uid
|
||||
}
|
||||
|
||||
// connectionPool manages a pool of LDAP connections for reuse
|
||||
type connectionPool struct {
|
||||
conns chan *ldap.Conn
|
||||
mu sync.Mutex
|
||||
size int
|
||||
closed uint32 // atomic flag: 1 if closed, 0 if open
|
||||
}
|
||||
|
||||
// LDAPProvider implements the IdentityProvider interface for LDAP
|
||||
type LDAPProvider struct {
|
||||
name string
|
||||
config *LDAPConfig
|
||||
initialized bool
|
||||
mu sync.RWMutex
|
||||
pool *connectionPool
|
||||
}
|
||||
|
||||
// NewLDAPProvider creates a new LDAP provider
|
||||
func NewLDAPProvider(name string) *LDAPProvider {
|
||||
return &LDAPProvider{
|
||||
name: name,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the provider name
|
||||
func (p *LDAPProvider) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
// Initialize initializes the provider with configuration
|
||||
func (p *LDAPProvider) Initialize(config interface{}) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.initialized {
|
||||
return fmt.Errorf("LDAP provider already initialized")
|
||||
}
|
||||
|
||||
cfg := &LDAPConfig{}
|
||||
|
||||
// Check if input is already the correct struct type
|
||||
if c, ok := config.(*LDAPConfig); ok {
|
||||
cfg = c
|
||||
} else {
|
||||
// Parse from map using mapstructure with weak typing and time duration hook
|
||||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
||||
DecodeHook: mapstructure.ComposeDecodeHookFunc(
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
),
|
||||
Result: cfg,
|
||||
TagName: "json",
|
||||
WeaklyTypedInput: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create config decoder: %w", err)
|
||||
}
|
||||
|
||||
if err := decoder.Decode(config); err != nil {
|
||||
return fmt.Errorf("failed to decode LDAP configuration: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if cfg.Server == "" {
|
||||
return fmt.Errorf("LDAP server URL is required")
|
||||
}
|
||||
if cfg.BaseDN == "" {
|
||||
return fmt.Errorf("LDAP base DN is required")
|
||||
}
|
||||
if cfg.UserFilter == "" {
|
||||
cfg.UserFilter = "(cn=%s)" // Default filter
|
||||
}
|
||||
|
||||
// Warn if BindDN is configured but BindPassword is empty
|
||||
if cfg.BindDN != "" && cfg.BindPassword == "" {
|
||||
glog.Warningf("LDAP provider '%s' configured with BindDN but no BindPassword", p.name)
|
||||
}
|
||||
|
||||
// Warn if InsecureSkipVerify is enabled
|
||||
if cfg.InsecureSkipVerify {
|
||||
glog.Warningf("LDAP provider '%s' has InsecureSkipVerify enabled. Do not use in production.", p.name)
|
||||
}
|
||||
|
||||
// Set default attributes
|
||||
if cfg.Attributes.Email == "" {
|
||||
cfg.Attributes.Email = "mail"
|
||||
}
|
||||
if cfg.Attributes.DisplayName == "" {
|
||||
cfg.Attributes.DisplayName = "cn"
|
||||
}
|
||||
if cfg.Attributes.Groups == "" {
|
||||
cfg.Attributes.Groups = "memberOf"
|
||||
}
|
||||
if cfg.Attributes.UID == "" {
|
||||
cfg.Attributes.UID = "uid"
|
||||
}
|
||||
if cfg.GroupBaseDN == "" {
|
||||
cfg.GroupBaseDN = cfg.BaseDN
|
||||
}
|
||||
if cfg.ConnectionTimeout == 0 {
|
||||
cfg.ConnectionTimeout = 10 * time.Second
|
||||
}
|
||||
|
||||
p.config = cfg
|
||||
|
||||
// Initialize connection pool (default size: 10 connections)
|
||||
poolSize := 10
|
||||
if cfg.PoolSize > 0 {
|
||||
poolSize = cfg.PoolSize
|
||||
}
|
||||
p.pool = &connectionPool{
|
||||
conns: make(chan *ldap.Conn, poolSize),
|
||||
size: poolSize,
|
||||
}
|
||||
|
||||
p.initialized = true
|
||||
|
||||
glog.V(1).Infof("LDAP provider '%s' initialized: server=%s, baseDN=%s",
|
||||
p.name, cfg.Server, cfg.BaseDN)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getConnection gets a connection from the pool or creates a new one
|
||||
func (p *LDAPProvider) getConnection() (*ldap.Conn, error) {
|
||||
// Try to get a connection from the pool (non-blocking)
|
||||
select {
|
||||
case conn := <-p.pool.conns:
|
||||
// Test if connection is still alive
|
||||
if conn != nil && conn.IsClosing() {
|
||||
conn.Close()
|
||||
// Connection is dead, create a new one
|
||||
return p.createConnection()
|
||||
}
|
||||
return conn, nil
|
||||
default:
|
||||
// Pool is empty, create a new connection
|
||||
return p.createConnection()
|
||||
}
|
||||
}
|
||||
|
||||
// returnConnection returns a connection to the pool
|
||||
func (p *LDAPProvider) returnConnection(conn *ldap.Conn) {
|
||||
if conn == nil || conn.IsClosing() {
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if pool is closed before attempting to send
|
||||
if atomic.LoadUint32(&p.pool.closed) == 1 {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// Try to return to pool (non-blocking)
|
||||
select {
|
||||
case p.pool.conns <- conn:
|
||||
// Successfully returned to pool
|
||||
default:
|
||||
// Pool is full, close the connection
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// createConnection establishes a new connection to the LDAP server
|
||||
func (p *LDAPProvider) createConnection() (*ldap.Conn, error) {
|
||||
var conn *ldap.Conn
|
||||
var err error
|
||||
|
||||
// Create dialer with timeout
|
||||
dialer := &net.Dialer{Timeout: p.config.ConnectionTimeout}
|
||||
|
||||
// Parse server URL
|
||||
if strings.HasPrefix(p.config.Server, "ldaps://") {
|
||||
// LDAPS connection
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: p.config.InsecureSkipVerify,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
conn, err = ldap.DialURL(p.config.Server, ldap.DialWithDialer(dialer), ldap.DialWithTLSConfig(tlsConfig))
|
||||
} else {
|
||||
// LDAP connection
|
||||
conn, err = ldap.DialURL(p.config.Server, ldap.DialWithDialer(dialer))
|
||||
if err == nil && p.config.UseTLS {
|
||||
// StartTLS
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: p.config.InsecureSkipVerify,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
if err = conn.StartTLS(tlsConfig); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("failed to start TLS: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to LDAP server: %w", err)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// Close closes all connections in the pool
|
||||
func (p *LDAPProvider) Close() error {
|
||||
if p.pool == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Atomically mark pool as closed to prevent new connections being returned
|
||||
if !atomic.CompareAndSwapUint32(&p.pool.closed, 0, 1) {
|
||||
// Already closed
|
||||
return nil
|
||||
}
|
||||
|
||||
p.pool.mu.Lock()
|
||||
defer p.pool.mu.Unlock()
|
||||
|
||||
// Now safe to close the channel since closed flag prevents new sends
|
||||
close(p.pool.conns)
|
||||
for conn := range p.pool.conns {
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Authenticate authenticates a user with username:password credentials
|
||||
func (p *LDAPProvider) Authenticate(ctx context.Context, credentials string) (*providers.ExternalIdentity, error) {
|
||||
p.mu.RLock()
|
||||
if !p.initialized {
|
||||
p.mu.RUnlock()
|
||||
return nil, fmt.Errorf("LDAP provider not initialized")
|
||||
}
|
||||
config := p.config
|
||||
p.mu.RUnlock()
|
||||
|
||||
// Parse credentials (username:password format)
|
||||
parts := strings.SplitN(credentials, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid credentials format (expected username:password)")
|
||||
}
|
||||
username, password := parts[0], parts[1]
|
||||
|
||||
if username == "" || password == "" {
|
||||
return nil, fmt.Errorf("username and password are required")
|
||||
}
|
||||
|
||||
// Get connection from pool
|
||||
conn, err := p.getConnection()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Note: defer returnConnection moved to after rebinding to service account
|
||||
|
||||
// First, bind with service account to search for user
|
||||
if config.BindDN != "" {
|
||||
err = conn.Bind(config.BindDN, config.BindPassword)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("LDAP service bind failed: %v", err)
|
||||
conn.Close() // Close on error, don't return to pool
|
||||
return nil, fmt.Errorf("LDAP service bind failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Search for the user
|
||||
userFilter := fmt.Sprintf(config.UserFilter, ldap.EscapeFilter(username))
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
config.BaseDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
1, // Size limit
|
||||
int(config.ConnectionTimeout.Seconds()),
|
||||
false,
|
||||
userFilter,
|
||||
[]string{"dn", config.Attributes.Email, config.Attributes.DisplayName, config.Attributes.UID, config.Attributes.Groups},
|
||||
nil,
|
||||
)
|
||||
|
||||
result, err := conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("LDAP user search failed: %v", err)
|
||||
conn.Close() // Close on error
|
||||
return nil, fmt.Errorf("LDAP user search failed: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Entries) == 0 {
|
||||
conn.Close() // Close on error
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
if len(result.Entries) > 1 {
|
||||
conn.Close() // Close on error
|
||||
return nil, fmt.Errorf("multiple users found")
|
||||
}
|
||||
|
||||
userEntry := result.Entries[0]
|
||||
userDN := userEntry.DN
|
||||
|
||||
// Bind as the user to verify password
|
||||
err = conn.Bind(userDN, password)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("LDAP user bind failed for %s: %v", username, err)
|
||||
conn.Close() // Close on error, don't return to pool
|
||||
return nil, fmt.Errorf("authentication failed: invalid credentials")
|
||||
}
|
||||
|
||||
// Rebind to service account before returning connection to pool
|
||||
// This prevents pool corruption from authenticated user binds
|
||||
if config.BindDN != "" {
|
||||
if err = conn.Bind(config.BindDN, config.BindPassword); err != nil {
|
||||
glog.V(2).Infof("LDAP rebind to service account failed: %v", err)
|
||||
conn.Close() // Close on error, don't return to pool
|
||||
return nil, fmt.Errorf("LDAP service account rebind failed after successful user authentication (check bindDN %q and its credentials): %w", config.BindDN, err)
|
||||
}
|
||||
}
|
||||
// Now safe to defer return to pool with clean service account binding
|
||||
defer p.returnConnection(conn)
|
||||
|
||||
// Build identity from LDAP attributes
|
||||
identity := &providers.ExternalIdentity{
|
||||
UserID: username,
|
||||
Email: userEntry.GetAttributeValue(config.Attributes.Email),
|
||||
DisplayName: userEntry.GetAttributeValue(config.Attributes.DisplayName),
|
||||
Groups: userEntry.GetAttributeValues(config.Attributes.Groups),
|
||||
Provider: p.name,
|
||||
Attributes: map[string]string{
|
||||
"dn": userDN,
|
||||
"uid": userEntry.GetAttributeValue(config.Attributes.UID),
|
||||
},
|
||||
}
|
||||
|
||||
// If no groups from memberOf, try group search
|
||||
if len(identity.Groups) == 0 && config.GroupFilter != "" {
|
||||
groups, err := p.searchUserGroups(conn, userDN, config)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("Group search failed for %s: %v", username, err)
|
||||
} else {
|
||||
identity.Groups = groups
|
||||
}
|
||||
}
|
||||
|
||||
glog.V(2).Infof("LDAP authentication successful for user: %s, groups: %v", username, identity.Groups)
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// searchUserGroups searches for groups the user belongs to
|
||||
func (p *LDAPProvider) searchUserGroups(conn *ldap.Conn, userDN string, config *LDAPConfig) ([]string, error) {
|
||||
groupFilter := fmt.Sprintf(config.GroupFilter, ldap.EscapeFilter(userDN))
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
config.GroupBaseDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
0,
|
||||
int(config.ConnectionTimeout.Seconds()),
|
||||
false,
|
||||
groupFilter,
|
||||
[]string{"cn", "dn"},
|
||||
nil,
|
||||
)
|
||||
|
||||
result, err := conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var groups []string
|
||||
for _, entry := range result.Entries {
|
||||
cn := entry.GetAttributeValue("cn")
|
||||
if cn != "" {
|
||||
groups = append(groups, cn)
|
||||
}
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// GetUserInfo retrieves user information by user ID
|
||||
func (p *LDAPProvider) GetUserInfo(ctx context.Context, userID string) (*providers.ExternalIdentity, error) {
|
||||
p.mu.RLock()
|
||||
if !p.initialized {
|
||||
p.mu.RUnlock()
|
||||
return nil, fmt.Errorf("LDAP provider not initialized")
|
||||
}
|
||||
config := p.config
|
||||
p.mu.RUnlock()
|
||||
|
||||
// Get connection from pool
|
||||
conn, err := p.getConnection()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Note: defer returnConnection moved to after bind
|
||||
|
||||
// Bind with service account
|
||||
if config.BindDN != "" {
|
||||
err = conn.Bind(config.BindDN, config.BindPassword)
|
||||
if err != nil {
|
||||
conn.Close() // Close on bind failure
|
||||
return nil, fmt.Errorf("LDAP service bind failed: %w", err)
|
||||
}
|
||||
}
|
||||
defer p.returnConnection(conn)
|
||||
|
||||
// Search for the user
|
||||
userFilter := fmt.Sprintf(config.UserFilter, ldap.EscapeFilter(userID))
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
config.BaseDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
1,
|
||||
int(config.ConnectionTimeout.Seconds()),
|
||||
false,
|
||||
userFilter,
|
||||
[]string{"dn", config.Attributes.Email, config.Attributes.DisplayName, config.Attributes.UID, config.Attributes.Groups},
|
||||
nil,
|
||||
)
|
||||
|
||||
result, err := conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LDAP user search failed: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Entries) == 0 {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
if len(result.Entries) > 1 {
|
||||
return nil, fmt.Errorf("multiple users found")
|
||||
}
|
||||
|
||||
userEntry := result.Entries[0]
|
||||
identity := &providers.ExternalIdentity{
|
||||
UserID: userID,
|
||||
Email: userEntry.GetAttributeValue(config.Attributes.Email),
|
||||
DisplayName: userEntry.GetAttributeValue(config.Attributes.DisplayName),
|
||||
Groups: userEntry.GetAttributeValues(config.Attributes.Groups),
|
||||
Provider: p.name,
|
||||
Attributes: map[string]string{
|
||||
"dn": userEntry.DN,
|
||||
"uid": userEntry.GetAttributeValue(config.Attributes.UID),
|
||||
},
|
||||
}
|
||||
|
||||
// If no groups from memberOf, try group search
|
||||
if len(identity.Groups) == 0 && config.GroupFilter != "" {
|
||||
groups, err := p.searchUserGroups(conn, userEntry.DN, config)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("Group search failed for %s: %v", userID, err)
|
||||
} else {
|
||||
identity.Groups = groups
|
||||
}
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// ValidateToken validates credentials (username:password format) and returns claims
|
||||
func (p *LDAPProvider) ValidateToken(ctx context.Context, token string) (*providers.TokenClaims, error) {
|
||||
identity, err := p.Authenticate(ctx, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.mu.RLock()
|
||||
config := p.config
|
||||
p.mu.RUnlock()
|
||||
|
||||
// If audience is configured, validate it (consistent with OIDC approach)
|
||||
audience := p.name
|
||||
if config.Audience != "" {
|
||||
audience = config.Audience
|
||||
}
|
||||
|
||||
// Populate standard TokenClaims fields for interface compliance
|
||||
now := time.Now()
|
||||
ttl := 1 * time.Hour // Default TTL for LDAP tokens
|
||||
|
||||
return &providers.TokenClaims{
|
||||
Subject: identity.UserID,
|
||||
Issuer: p.name,
|
||||
Audience: audience,
|
||||
IssuedAt: now,
|
||||
ExpiresAt: now.Add(ttl),
|
||||
Claims: map[string]interface{}{
|
||||
"email": identity.Email,
|
||||
"name": identity.DisplayName,
|
||||
"groups": identity.Groups,
|
||||
"dn": identity.Attributes["dn"],
|
||||
"provider": p.name,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsInitialized returns whether the provider is initialized
|
||||
func (p *LDAPProvider) IsInitialized() bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.initialized
|
||||
}
|
||||
@@ -127,16 +127,16 @@ func TestCrossInstanceTokenUsage(t *testing.T) {
|
||||
sessionId := TestSessionID
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
|
||||
tokenFromA, err := instanceA.tokenGenerator.GenerateSessionToken(sessionId, expiresAt)
|
||||
tokenFromA, err := instanceA.GetTokenGenerator().GenerateSessionToken(sessionId, expiresAt)
|
||||
require.NoError(t, err, "Instance A should generate token")
|
||||
|
||||
// Validate token on Instance B
|
||||
claimsFromB, err := instanceB.tokenGenerator.ValidateSessionToken(tokenFromA)
|
||||
claimsFromB, err := instanceB.GetTokenGenerator().ValidateSessionToken(tokenFromA)
|
||||
require.NoError(t, err, "Instance B should validate token from Instance A")
|
||||
assert.Equal(t, sessionId, claimsFromB.SessionId, "Session ID should match")
|
||||
|
||||
// Validate same token on Instance C
|
||||
claimsFromC, err := instanceC.tokenGenerator.ValidateSessionToken(tokenFromA)
|
||||
claimsFromC, err := instanceC.GetTokenGenerator().ValidateSessionToken(tokenFromA)
|
||||
require.NoError(t, err, "Instance C should validate token from Instance A")
|
||||
assert.Equal(t, sessionId, claimsFromC.SessionId, "Session ID should match")
|
||||
|
||||
@@ -295,15 +295,15 @@ func TestSTSDistributedConfigurationRequirements(t *testing.T) {
|
||||
// Generate token on Instance A
|
||||
sessionId := "test-session"
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
tokenFromA, err := instanceA.tokenGenerator.GenerateSessionToken(sessionId, expiresAt)
|
||||
tokenFromA, err := instanceA.GetTokenGenerator().GenerateSessionToken(sessionId, expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Instance A should validate its own token
|
||||
_, err = instanceA.tokenGenerator.ValidateSessionToken(tokenFromA)
|
||||
_, err = instanceA.GetTokenGenerator().ValidateSessionToken(tokenFromA)
|
||||
assert.NoError(t, err, "Instance A should validate own token")
|
||||
|
||||
// Instance B should REJECT token due to different signing key
|
||||
_, err = instanceB.tokenGenerator.ValidateSessionToken(tokenFromA)
|
||||
_, err = instanceB.GetTokenGenerator().ValidateSessionToken(tokenFromA)
|
||||
assert.Error(t, err, "Instance B should reject token with different signing key")
|
||||
assert.Contains(t, err.Error(), "invalid token", "Should be signature validation error")
|
||||
})
|
||||
@@ -339,11 +339,11 @@ func TestSTSDistributedConfigurationRequirements(t *testing.T) {
|
||||
// Generate token on Instance A
|
||||
sessionId := "test-session"
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
tokenFromA, err := instanceA.tokenGenerator.GenerateSessionToken(sessionId, expiresAt)
|
||||
tokenFromA, err := instanceA.GetTokenGenerator().GenerateSessionToken(sessionId, expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Instance B should REJECT token due to different issuer
|
||||
_, err = instanceB.tokenGenerator.ValidateSessionToken(tokenFromA)
|
||||
_, err = instanceB.GetTokenGenerator().ValidateSessionToken(tokenFromA)
|
||||
assert.Error(t, err, "Instance B should reject token with different issuer")
|
||||
assert.Contains(t, err.Error(), "invalid issuer", "Should be issuer validation error")
|
||||
})
|
||||
@@ -368,12 +368,12 @@ func TestSTSDistributedConfigurationRequirements(t *testing.T) {
|
||||
// Generate token on Instance 0
|
||||
sessionId := "multi-instance-test"
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
token, err := instances[0].tokenGenerator.GenerateSessionToken(sessionId, expiresAt)
|
||||
token, err := instances[0].GetTokenGenerator().GenerateSessionToken(sessionId, expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// All other instances should validate the token
|
||||
for i := 1; i < 5; i++ {
|
||||
claims, err := instances[i].tokenGenerator.ValidateSessionToken(token)
|
||||
claims, err := instances[i].GetTokenGenerator().ValidateSessionToken(token)
|
||||
require.NoError(t, err, "Instance %d should validate token", i)
|
||||
assert.Equal(t, sessionId, claims.SessionId, "Instance %d should extract correct session ID", i)
|
||||
}
|
||||
@@ -486,10 +486,10 @@ func TestSTSRealWorldDistributedScenarios(t *testing.T) {
|
||||
assert.True(t, sessionInfo3.ExpiresAt.After(time.Now()), "Session should not be expired")
|
||||
|
||||
// Step 5: Token should be identical when parsed
|
||||
claims2, err := gateway2.tokenGenerator.ValidateSessionToken(sessionToken)
|
||||
claims2, err := gateway2.GetTokenGenerator().ValidateSessionToken(sessionToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
claims3, err := gateway3.tokenGenerator.ValidateSessionToken(sessionToken)
|
||||
claims3, err := gateway3.GetTokenGenerator().ValidateSessionToken(sessionToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, claims2.SessionId, claims3.SessionId, "Session IDs should match")
|
||||
|
||||
@@ -109,9 +109,9 @@ func TestDistributedSTSService(t *testing.T) {
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
|
||||
// Generate tokens from different instances
|
||||
token1, err1 := instance1.tokenGenerator.GenerateSessionToken(sessionId, expiresAt)
|
||||
token2, err2 := instance2.tokenGenerator.GenerateSessionToken(sessionId, expiresAt)
|
||||
token3, err3 := instance3.tokenGenerator.GenerateSessionToken(sessionId, expiresAt)
|
||||
token1, err1 := instance1.GetTokenGenerator().GenerateSessionToken(sessionId, expiresAt)
|
||||
token2, err2 := instance2.GetTokenGenerator().GenerateSessionToken(sessionId, expiresAt)
|
||||
token3, err3 := instance3.GetTokenGenerator().GenerateSessionToken(sessionId, expiresAt)
|
||||
|
||||
require.NoError(t, err1, "Instance 1 token generation should succeed")
|
||||
require.NoError(t, err2, "Instance 2 token generation should succeed")
|
||||
@@ -130,13 +130,13 @@ func TestDistributedSTSService(t *testing.T) {
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
|
||||
// Generate token on instance 1
|
||||
token, err := instance1.tokenGenerator.GenerateSessionToken(sessionId, expiresAt)
|
||||
token, err := instance1.GetTokenGenerator().GenerateSessionToken(sessionId, expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Validate on all instances
|
||||
claims1, err1 := instance1.tokenGenerator.ValidateSessionToken(token)
|
||||
claims2, err2 := instance2.tokenGenerator.ValidateSessionToken(token)
|
||||
claims3, err3 := instance3.tokenGenerator.ValidateSessionToken(token)
|
||||
claims1, err1 := instance1.GetTokenGenerator().ValidateSessionToken(token)
|
||||
claims2, err2 := instance2.GetTokenGenerator().ValidateSessionToken(token)
|
||||
claims3, err3 := instance3.GetTokenGenerator().ValidateSessionToken(token)
|
||||
|
||||
require.NoError(t, err1, "Instance 1 should validate token from instance 1")
|
||||
require.NoError(t, err2, "Instance 2 should validate token from instance 1")
|
||||
@@ -216,15 +216,15 @@ func TestSTSConfigurationValidation(t *testing.T) {
|
||||
// Generate token on instance 1
|
||||
sessionId := "test-session"
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
token, err := instance1.tokenGenerator.GenerateSessionToken(sessionId, expiresAt)
|
||||
token, err := instance1.GetTokenGenerator().GenerateSessionToken(sessionId, expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Instance 1 should validate its own token
|
||||
_, err = instance1.tokenGenerator.ValidateSessionToken(token)
|
||||
_, err = instance1.GetTokenGenerator().ValidateSessionToken(token)
|
||||
assert.NoError(t, err, "Instance 1 should validate its own token")
|
||||
|
||||
// Instance 2 should reject token from instance 1 (different signing key)
|
||||
_, err = instance2.tokenGenerator.ValidateSessionToken(token)
|
||||
_, err = instance2.GetTokenGenerator().ValidateSessionToken(token)
|
||||
assert.Error(t, err, "Instance 2 should reject token with different signing key")
|
||||
})
|
||||
|
||||
@@ -258,12 +258,12 @@ func TestSTSConfigurationValidation(t *testing.T) {
|
||||
// Generate token on instance 1
|
||||
sessionId := "test-session"
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
token, err := instance1.tokenGenerator.GenerateSessionToken(sessionId, expiresAt)
|
||||
token, err := instance1.GetTokenGenerator().GenerateSessionToken(sessionId, expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Instance 2 should reject token due to issuer mismatch
|
||||
// (Even though signing key is the same, issuer validation will fail)
|
||||
_, err = instance2.tokenGenerator.ValidateSessionToken(token)
|
||||
_, err = instance2.GetTokenGenerator().ValidateSessionToken(token)
|
||||
assert.Error(t, err, "Instance 2 should reject token with different issuer")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/ldap"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/oidc"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
||||
)
|
||||
@@ -66,8 +67,11 @@ func (f *ProviderFactory) createOIDCProvider(config *ProviderConfig) (providers.
|
||||
|
||||
// createLDAPProvider creates an LDAP provider from configuration
|
||||
func (f *ProviderFactory) createLDAPProvider(config *ProviderConfig) (providers.IdentityProvider, error) {
|
||||
// TODO: Implement LDAP provider when available
|
||||
return nil, fmt.Errorf("LDAP provider not implemented yet")
|
||||
provider := ldap.NewLDAPProvider(config.Name)
|
||||
if err := provider.Initialize(config.Config); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize LDAP provider: %w", err)
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// createSAMLProvider creates a SAML provider from configuration
|
||||
@@ -317,7 +321,12 @@ func (f *ProviderFactory) validateOIDCConfig(config map[string]interface{}) erro
|
||||
|
||||
// validateLDAPConfig validates LDAP provider configuration
|
||||
func (f *ProviderFactory) validateLDAPConfig(config map[string]interface{}) error {
|
||||
// TODO: Implement when LDAP provider is available
|
||||
if _, ok := config["server"]; !ok {
|
||||
return fmt.Errorf("LDAP provider requires 'server' field")
|
||||
}
|
||||
if _, ok := config["baseDN"]; !ok {
|
||||
return fmt.Errorf("LDAP provider requires 'baseDN' field")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,12 @@ type STSService struct {
|
||||
trustPolicyValidator TrustPolicyValidator // Interface for trust policy validation
|
||||
}
|
||||
|
||||
// GetTokenGenerator returns the token generator used by the STS service.
|
||||
// This keeps the underlying field unexported while still allowing read-only access.
|
||||
func (s *STSService) GetTokenGenerator() *TokenGenerator {
|
||||
return s.tokenGenerator
|
||||
}
|
||||
|
||||
// STSConfig holds STS service configuration
|
||||
type STSConfig struct {
|
||||
// TokenDuration is the default duration for issued tokens
|
||||
@@ -95,6 +101,10 @@ type STSConfig struct {
|
||||
// SigningKey is used to sign session tokens
|
||||
SigningKey []byte `json:"signingKey"`
|
||||
|
||||
// AccountId is the AWS account ID used for federated user ARNs
|
||||
// Defaults to "111122223333" if not specified
|
||||
AccountId string `json:"accountId,omitempty"`
|
||||
|
||||
// Providers configuration - enables automatic provider loading
|
||||
Providers []*ProviderConfig `json:"providers,omitempty"`
|
||||
}
|
||||
@@ -807,7 +817,7 @@ func (s *STSService) calculateSessionDuration(durationSeconds *int64, tokenExpir
|
||||
|
||||
// extractSessionIdFromToken extracts session ID from JWT session token
|
||||
func (s *STSService) extractSessionIdFromToken(sessionToken string) string {
|
||||
// Parse JWT and extract session ID from claims
|
||||
// Validate JWT and extract session claims
|
||||
claims, err := s.tokenGenerator.ValidateJWTWithClaims(sessionToken)
|
||||
if err != nil {
|
||||
// For test compatibility, also handle direct session IDs
|
||||
@@ -862,7 +872,7 @@ func (s *STSService) ExpireSessionForTesting(ctx context.Context, sessionToken s
|
||||
return fmt.Errorf("session token cannot be empty")
|
||||
}
|
||||
|
||||
// Validate JWT token format
|
||||
// Just validate the signature
|
||||
_, err := s.tokenGenerator.ValidateJWTWithClaims(sessionToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid session token format: %w", err)
|
||||
|
||||
15
weed/s3api/auth_credentials_trust.go
Normal file
15
weed/s3api/auth_credentials_trust.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ValidateTrustPolicyForPrincipal validates if a principal is allowed to assume a role
|
||||
// Delegates to the IAM integration if available
|
||||
func (iam *IdentityAccessManagement) ValidateTrustPolicyForPrincipal(ctx context.Context, roleArn, principalArn string) error {
|
||||
if iam.iamIntegration != nil {
|
||||
return iam.iamIntegration.ValidateTrustPolicyForPrincipal(ctx, roleArn, principalArn)
|
||||
}
|
||||
return fmt.Errorf("IAM integration not available")
|
||||
}
|
||||
@@ -16,8 +16,9 @@ import (
|
||||
|
||||
// MockIAMIntegration is a mock implementation of IAM integration for testing
|
||||
type MockIAMIntegration struct {
|
||||
authorizeFunc func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode
|
||||
authCalled bool
|
||||
authorizeFunc func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode
|
||||
validateTrustPolicyFunc func(ctx context.Context, roleArn, principalArn string) error
|
||||
authCalled bool
|
||||
}
|
||||
|
||||
func (m *MockIAMIntegration) AuthorizeAction(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
|
||||
@@ -36,6 +37,13 @@ func (m *MockIAMIntegration) ValidateSessionToken(ctx context.Context, token str
|
||||
return nil, nil // Not needed for these tests
|
||||
}
|
||||
|
||||
func (m *MockIAMIntegration) ValidateTrustPolicyForPrincipal(ctx context.Context, roleArn, principalArn string) error {
|
||||
if m.validateTrustPolicyFunc != nil {
|
||||
return m.validateTrustPolicyFunc(ctx, roleArn, principalArn)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestVerifyV4SignatureWithSTSIdentity tests that verifyV4Signature properly handles STS identities
|
||||
// by falling back to IAM authorization when shouldCheckPermissions is true
|
||||
func TestVerifyV4SignatureWithSTSIdentity(t *testing.T) {
|
||||
|
||||
@@ -477,7 +477,7 @@ func setupS3ReadOnlyRole(ctx context.Context, manager *integration.IAMManager) {
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "https://test-issuer.com",
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -521,7 +521,7 @@ func setupS3AdminRole(ctx context.Context, manager *integration.IAMManager) {
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "https://test-issuer.com",
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -565,7 +565,7 @@ func setupS3WriteRole(ctx context.Context, manager *integration.IAMManager) {
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "https://test-issuer.com",
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -614,7 +614,7 @@ func setupS3IPRestrictedRole(ctx context.Context, manager *integration.IAMManage
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "https://test-issuer.com",
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ type IAMIntegration interface {
|
||||
AuthenticateJWT(ctx context.Context, r *http.Request) (*IAMIdentity, s3err.ErrorCode)
|
||||
AuthorizeAction(ctx context.Context, identity *IAMIdentity, action Action, bucket string, objectKey string, r *http.Request) s3err.ErrorCode
|
||||
ValidateSessionToken(ctx context.Context, token string) (*sts.SessionInfo, error)
|
||||
ValidateTrustPolicyForPrincipal(ctx context.Context, roleArn, principalArn string) error
|
||||
}
|
||||
|
||||
// S3IAMIntegration provides IAM integration for S3 API
|
||||
@@ -224,6 +225,14 @@ func (s3iam *S3IAMIntegration) AuthorizeAction(ctx context.Context, identity *IA
|
||||
return s3err.ErrNone
|
||||
}
|
||||
|
||||
// ValidateTrustPolicyForPrincipal delegates to IAMManager to validate trust policy
|
||||
func (s3iam *S3IAMIntegration) ValidateTrustPolicyForPrincipal(ctx context.Context, roleArn, principalArn string) error {
|
||||
if s3iam.iamManager == nil {
|
||||
return fmt.Errorf("IAM manager not available")
|
||||
}
|
||||
return s3iam.iamManager.ValidateTrustPolicyForPrincipal(ctx, roleArn, principalArn)
|
||||
}
|
||||
|
||||
// IAMIdentity represents an authenticated identity with session information
|
||||
type IAMIdentity struct {
|
||||
Name string
|
||||
|
||||
@@ -387,7 +387,7 @@ func setupTestReadOnlyRole(ctx context.Context, manager *integration.IAMManager)
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "https://test-issuer.com",
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -405,7 +405,7 @@ func setupTestReadOnlyRole(ctx context.Context, manager *integration.IAMManager)
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "https://test-issuer.com",
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -449,7 +449,7 @@ func setupTestAdminRole(ctx context.Context, manager *integration.IAMManager) {
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "https://test-issuer.com",
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -467,7 +467,7 @@ func setupTestAdminRole(ctx context.Context, manager *integration.IAMManager) {
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "https://test-issuer.com",
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -510,7 +510,7 @@ func setupTestIPRestrictedRole(ctx context.Context, manager *integration.IAMMana
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "https://test-issuer.com",
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
|
||||
@@ -568,7 +568,7 @@ func setupTestRolesForMultipart(ctx context.Context, manager *integration.IAMMan
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "https://test-issuer.com",
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -586,7 +586,7 @@ func setupTestRolesForMultipart(ctx context.Context, manager *integration.IAMMan
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "https://test-issuer.com",
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
|
||||
@@ -521,7 +521,7 @@ func setupTestRolesForPresigned(ctx context.Context, manager *integration.IAMMan
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "https://test-issuer.com",
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -557,7 +557,7 @@ func setupTestRolesForPresigned(ctx context.Context, manager *integration.IAMMan
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "https://test-issuer.com",
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -575,7 +575,7 @@ func setupTestRolesForPresigned(ctx context.Context, manager *integration.IAMMan
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "https://test-issuer.com",
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
|
||||
@@ -190,7 +190,7 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl
|
||||
|
||||
// Initialize STS HTTP handlers for AssumeRoleWithWebIdentity endpoint
|
||||
if stsService := iamManager.GetSTSService(); stsService != nil {
|
||||
s3ApiServer.stsHandlers = NewSTSHandlers(stsService)
|
||||
s3ApiServer.stsHandlers = NewSTSHandlers(stsService, iam)
|
||||
glog.V(1).Infof("STS HTTP handlers initialized for AssumeRoleWithWebIdentity")
|
||||
}
|
||||
|
||||
@@ -622,7 +622,16 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
|
||||
// 1. Explicit query param match (highest priority)
|
||||
apiRouter.Methods(http.MethodPost).Path("/").Queries("Action", "AssumeRoleWithWebIdentity").
|
||||
HandlerFunc(track(s3a.stsHandlers.HandleSTSRequest, "STS"))
|
||||
glog.V(0).Infof("STS API enabled on S3 port (AssumeRoleWithWebIdentity)")
|
||||
|
||||
// AssumeRole - requires SigV4 authentication
|
||||
apiRouter.Methods(http.MethodPost).Path("/").Queries("Action", "AssumeRole").
|
||||
HandlerFunc(track(s3a.stsHandlers.HandleSTSRequest, "STS-AssumeRole"))
|
||||
|
||||
// AssumeRoleWithLDAPIdentity - uses LDAP credentials
|
||||
apiRouter.Methods(http.MethodPost).Path("/").Queries("Action", "AssumeRoleWithLDAPIdentity").
|
||||
HandlerFunc(track(s3a.stsHandlers.HandleSTSRequest, "STS-LDAP"))
|
||||
|
||||
glog.V(0).Infof("STS API enabled on S3 port (AssumeRole, AssumeRoleWithWebIdentity, AssumeRoleWithLDAPIdentity)")
|
||||
}
|
||||
|
||||
// Embedded IAM API endpoint
|
||||
@@ -631,10 +640,31 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
|
||||
if s3a.embeddedIam != nil {
|
||||
// 2. Authenticated IAM requests
|
||||
// Only match if the request appears to be authenticated (AWS Signature)
|
||||
// This prevents unauthenticated STS requests (like AssumeRoleWithWebIdentity in body)
|
||||
// from being captured by the IAM handler which would reject them.
|
||||
// AND is not an STS request (which should be handled by STS handlers)
|
||||
iamMatcher := func(r *http.Request, rm *mux.RouteMatch) bool {
|
||||
return getRequestAuthType(r) != authTypeAnonymous
|
||||
if getRequestAuthType(r) == authTypeAnonymous {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check Action parameter in both form data and query string
|
||||
// We iterate ParseForm but ignore errors to ensure we attempt to parse the body
|
||||
// even if it's malformed, then check FormValue which covers both body and query.
|
||||
// This guards against misrouting STS requests if the body is invalid.
|
||||
r.ParseForm()
|
||||
action := r.FormValue("Action")
|
||||
|
||||
// If FormValue yielded nothing (possibly due to ParseForm failure failing to populate Form),
|
||||
// explicitly fallback to Query string to be safe.
|
||||
if action == "" {
|
||||
action = r.URL.Query().Get("Action")
|
||||
}
|
||||
|
||||
// Exclude STS actions - let them be handled by STS handlers
|
||||
if action == "AssumeRole" || action == "AssumeRoleWithWebIdentity" || action == "AssumeRoleWithLDAPIdentity" {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
apiRouter.Methods(http.MethodPost).Path("/").MatcherFunc(iamMatcher).
|
||||
|
||||
@@ -150,8 +150,8 @@ func TestRouting_IAMMatcherLogic(t *testing.T) {
|
||||
name: "AWS4 signature with STS action in body",
|
||||
authHeader: "AWS4-HMAC-SHA256 Credential=AKIA.../...",
|
||||
queryParams: "",
|
||||
expectsIAM: true,
|
||||
description: "Authenticated STS action should still route to IAM (auth takes precedence)",
|
||||
expectsIAM: false,
|
||||
description: "Authenticated STS action should route to STS handler (STS handlers handle their own auth)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ package s3api
|
||||
// AWS SDKs to obtain temporary credentials using OIDC/JWT tokens.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -13,7 +15,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/ldap"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/utils"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
@@ -28,18 +32,61 @@ const (
|
||||
stsDurationSeconds = "DurationSeconds"
|
||||
|
||||
// STS Action names
|
||||
actionAssumeRoleWithWebIdentity = "AssumeRoleWithWebIdentity"
|
||||
actionAssumeRole = "AssumeRole"
|
||||
actionAssumeRoleWithWebIdentity = "AssumeRoleWithWebIdentity"
|
||||
actionAssumeRoleWithLDAPIdentity = "AssumeRoleWithLDAPIdentity"
|
||||
|
||||
// LDAP parameter names
|
||||
stsLDAPUsername = "LDAPUsername"
|
||||
stsLDAPPassword = "LDAPPassword"
|
||||
stsLDAPProviderName = "LDAPProviderName"
|
||||
)
|
||||
|
||||
// STS duration constants (AWS specification)
|
||||
const (
|
||||
minDurationSeconds = int64(900) // 15 minutes
|
||||
maxDurationSeconds = int64(43200) // 12 hours
|
||||
|
||||
// Default account ID for federated users
|
||||
defaultAccountId = "111122223333"
|
||||
)
|
||||
|
||||
// parseDurationSeconds parses and validates the DurationSeconds parameter
|
||||
// Returns nil if the parameter is not provided, or a pointer to the parsed value
|
||||
func parseDurationSeconds(r *http.Request) (*int64, STSErrorCode, error) {
|
||||
dsStr := r.FormValue("DurationSeconds")
|
||||
if dsStr == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
ds, err := strconv.ParseInt(dsStr, 10, 64)
|
||||
if err != nil {
|
||||
return nil, STSErrInvalidParameterValue, fmt.Errorf("invalid DurationSeconds: %w", err)
|
||||
}
|
||||
|
||||
if ds < minDurationSeconds || ds > maxDurationSeconds {
|
||||
return nil, STSErrInvalidParameterValue,
|
||||
fmt.Errorf("DurationSeconds must be between %d and %d seconds", minDurationSeconds, maxDurationSeconds)
|
||||
}
|
||||
|
||||
return &ds, "", nil
|
||||
}
|
||||
|
||||
// Removed generateSecureCredentials - now using STS service's JWT token generation
|
||||
// The STS service generates proper JWT tokens with embedded claims that can be validated
|
||||
// across distributed instances without shared state.
|
||||
|
||||
// STSHandlers provides HTTP handlers for STS operations
|
||||
type STSHandlers struct {
|
||||
stsService *sts.STSService
|
||||
iam *IdentityAccessManagement
|
||||
}
|
||||
|
||||
// NewSTSHandlers creates a new STSHandlers instance
|
||||
func NewSTSHandlers(stsService *sts.STSService) *STSHandlers {
|
||||
func NewSTSHandlers(stsService *sts.STSService, iam *IdentityAccessManagement) *STSHandlers {
|
||||
return &STSHandlers{
|
||||
stsService: stsService,
|
||||
iam: iam,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,8 +109,12 @@ func (h *STSHandlers) HandleSTSRequest(w http.ResponseWriter, r *http.Request) {
|
||||
// Route based on action
|
||||
action := r.Form.Get(stsAction)
|
||||
switch action {
|
||||
case actionAssumeRole:
|
||||
h.handleAssumeRole(w, r)
|
||||
case actionAssumeRoleWithWebIdentity:
|
||||
h.handleAssumeRoleWithWebIdentity(w, r)
|
||||
case actionAssumeRoleWithLDAPIdentity:
|
||||
h.handleAssumeRoleWithLDAPIdentity(w, r)
|
||||
default:
|
||||
h.writeSTSErrorResponse(w, r, STSErrInvalidAction,
|
||||
fmt.Errorf("unsupported action: %s", action))
|
||||
@@ -98,29 +149,11 @@ func (h *STSHandlers) handleAssumeRoleWithWebIdentity(w http.ResponseWriter, r *
|
||||
return
|
||||
}
|
||||
|
||||
// Parse and validate DurationSeconds
|
||||
var durationSeconds *int64
|
||||
if dsStr := r.FormValue("DurationSeconds"); dsStr != "" {
|
||||
ds, err := strconv.ParseInt(dsStr, 10, 64)
|
||||
if err != nil {
|
||||
h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue,
|
||||
fmt.Errorf("invalid DurationSeconds: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Enforce AWS STS-compatible duration range for AssumeRoleWithWebIdentity
|
||||
// AWS allows 900 seconds (15 minutes) to 43200 seconds (12 hours)
|
||||
const (
|
||||
minDurationSeconds = int64(900)
|
||||
maxDurationSeconds = int64(43200)
|
||||
)
|
||||
if ds < minDurationSeconds || ds > maxDurationSeconds {
|
||||
h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue,
|
||||
fmt.Errorf("DurationSeconds must be between %d and %d seconds", minDurationSeconds, maxDurationSeconds))
|
||||
return
|
||||
}
|
||||
|
||||
durationSeconds = &ds
|
||||
// Parse and validate DurationSeconds using helper
|
||||
durationSeconds, errCode, err := parseDurationSeconds(r)
|
||||
if err != nil {
|
||||
h.writeSTSErrorResponse(w, r, errCode, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if STS service is initialized
|
||||
@@ -179,6 +212,322 @@ func (h *STSHandlers) handleAssumeRoleWithWebIdentity(w http.ResponseWriter, r *
|
||||
s3err.WriteXMLResponse(w, r, http.StatusOK, xmlResponse)
|
||||
}
|
||||
|
||||
// handleAssumeRole handles the AssumeRole API action
|
||||
// This requires AWS Signature V4 authentication
|
||||
func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract parameters from form
|
||||
roleArn := r.FormValue("RoleArn")
|
||||
roleSessionName := r.FormValue("RoleSessionName")
|
||||
|
||||
// Validate required parameters
|
||||
if roleArn == "" {
|
||||
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
|
||||
fmt.Errorf("RoleArn is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if roleSessionName == "" {
|
||||
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
|
||||
fmt.Errorf("RoleSessionName is required"))
|
||||
return
|
||||
}
|
||||
|
||||
// Parse and validate DurationSeconds using helper
|
||||
durationSeconds, errCode, err := parseDurationSeconds(r)
|
||||
if err != nil {
|
||||
h.writeSTSErrorResponse(w, r, errCode, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if STS service is initialized
|
||||
if h.stsService == nil || !h.stsService.IsInitialized() {
|
||||
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
|
||||
fmt.Errorf("STS service not initialized"))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if IAM is available for SigV4 verification
|
||||
if h.iam == nil {
|
||||
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
|
||||
fmt.Errorf("IAM not configured for STS"))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate AWS SigV4 authentication
|
||||
identity, _, _, _, sigErrCode := h.iam.verifyV4Signature(r, false)
|
||||
if sigErrCode != s3err.ErrNone {
|
||||
glog.V(2).Infof("AssumeRole SigV4 verification failed: %v", sigErrCode)
|
||||
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
||||
fmt.Errorf("invalid AWS signature: %v", sigErrCode))
|
||||
return
|
||||
}
|
||||
|
||||
if identity == nil {
|
||||
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
||||
fmt.Errorf("unable to identify caller"))
|
||||
return
|
||||
}
|
||||
|
||||
glog.V(2).Infof("AssumeRole: caller identity=%s, roleArn=%s, sessionName=%s",
|
||||
identity.Name, roleArn, roleSessionName)
|
||||
|
||||
// Check if the caller is authorized to assume the role (sts:AssumeRole permission)
|
||||
// This validates that the caller has a policy allowing sts:AssumeRole on the target role
|
||||
if authErr := h.iam.VerifyActionPermission(r, identity, Action("sts:AssumeRole"), "", roleArn); authErr != s3err.ErrNone {
|
||||
glog.V(2).Infof("AssumeRole: caller %s is not authorized to assume role %s", identity.Name, roleArn)
|
||||
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
||||
fmt.Errorf("user %s is not authorized to assume role %s", identity.Name, roleArn))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate that the target role trusts the caller (Trust Policy)
|
||||
// This ensures the role's trust policy explicitly allows the principal to assume it
|
||||
if err := h.iam.ValidateTrustPolicyForPrincipal(r.Context(), roleArn, identity.PrincipalArn); err != nil {
|
||||
glog.V(2).Infof("AssumeRole: trust policy validation failed for %s to assume %s: %v", identity.Name, roleArn, err)
|
||||
h.writeSTSErrorResponse(w, r, STSErrAccessDenied, fmt.Errorf("trust policy denies access"))
|
||||
return
|
||||
}
|
||||
|
||||
// Generate common STS components
|
||||
stsCreds, assumedUser, err := h.prepareSTSCredentials(roleArn, roleSessionName, identity.PrincipalArn, durationSeconds, nil)
|
||||
if err != nil {
|
||||
h.writeSTSErrorResponse(w, r, STSErrInternalError, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Build and return response
|
||||
xmlResponse := &AssumeRoleResponse{
|
||||
Result: AssumeRoleResult{
|
||||
Credentials: stsCreds,
|
||||
AssumedRoleUser: assumedUser,
|
||||
},
|
||||
}
|
||||
xmlResponse.ResponseMetadata.RequestId = fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
|
||||
s3err.WriteXMLResponse(w, r, http.StatusOK, xmlResponse)
|
||||
}
|
||||
|
||||
// handleAssumeRoleWithLDAPIdentity handles the AssumeRoleWithLDAPIdentity API action
|
||||
func (h *STSHandlers) handleAssumeRoleWithLDAPIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract parameters from form
|
||||
roleArn := r.FormValue("RoleArn")
|
||||
roleSessionName := r.FormValue("RoleSessionName")
|
||||
ldapUsername := r.FormValue(stsLDAPUsername)
|
||||
ldapPassword := r.FormValue(stsLDAPPassword)
|
||||
|
||||
// Validate required parameters
|
||||
if roleArn == "" {
|
||||
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
|
||||
fmt.Errorf("RoleArn is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if roleSessionName == "" {
|
||||
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
|
||||
fmt.Errorf("RoleSessionName is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if ldapUsername == "" {
|
||||
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
|
||||
fmt.Errorf("LDAPUsername is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if ldapPassword == "" {
|
||||
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
|
||||
fmt.Errorf("LDAPPassword is required"))
|
||||
return
|
||||
}
|
||||
|
||||
// Parse and validate DurationSeconds using helper
|
||||
durationSeconds, errCode, err := parseDurationSeconds(r)
|
||||
if err != nil {
|
||||
h.writeSTSErrorResponse(w, r, errCode, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if STS service is initialized
|
||||
if h.stsService == nil || !h.stsService.IsInitialized() {
|
||||
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
|
||||
fmt.Errorf("STS service not initialized"))
|
||||
return
|
||||
}
|
||||
|
||||
// Optional: specific LDAP provider name
|
||||
ldapProviderName := r.FormValue(stsLDAPProviderName)
|
||||
|
||||
// Find an LDAP provider from the registered providers
|
||||
var ldapProvider *ldap.LDAPProvider
|
||||
ldapProvidersFound := 0
|
||||
for _, provider := range h.stsService.GetProviders() {
|
||||
// Check if this is an LDAP provider by type assertion
|
||||
if p, ok := provider.(*ldap.LDAPProvider); ok {
|
||||
if ldapProviderName != "" && p.Name() == ldapProviderName {
|
||||
ldapProvider = p
|
||||
break
|
||||
} else if ldapProviderName == "" && ldapProvider == nil {
|
||||
ldapProvider = p
|
||||
}
|
||||
ldapProvidersFound++
|
||||
}
|
||||
}
|
||||
|
||||
if ldapProvidersFound > 1 && ldapProviderName == "" {
|
||||
glog.Warningf("Multiple LDAP providers found (%d). Using the first one found (non-deterministic). Consider specifying LDAPProviderName.", ldapProvidersFound)
|
||||
}
|
||||
|
||||
if ldapProvider == nil {
|
||||
glog.V(2).Infof("AssumeRoleWithLDAPIdentity: no LDAP provider configured")
|
||||
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
|
||||
fmt.Errorf("no LDAP provider configured - please add an LDAP provider to IAM configuration"))
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticate with LDAP provider
|
||||
// The provider expects credentials in "username:password" format
|
||||
credentials := ldapUsername + ":" + ldapPassword
|
||||
identity, err := ldapProvider.Authenticate(r.Context(), credentials)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("AssumeRoleWithLDAPIdentity: LDAP authentication failed for user %s: %v", ldapUsername, err)
|
||||
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
||||
fmt.Errorf("authentication failed"))
|
||||
return
|
||||
}
|
||||
|
||||
glog.V(2).Infof("AssumeRoleWithLDAPIdentity: user %s authenticated successfully, groups=%v",
|
||||
ldapUsername, identity.Groups)
|
||||
|
||||
// Verify that the identity is allowed to assume the role
|
||||
// We create a temporary identity to represent the LDAP user for permission checking
|
||||
// The checking logic will verify if the role's trust policy allows this principal
|
||||
// Use configured account ID or default to "111122223333" for federated users
|
||||
accountId := defaultAccountId
|
||||
if h.stsService != nil && h.stsService.Config != nil && h.stsService.Config.AccountId != "" {
|
||||
accountId = h.stsService.Config.AccountId
|
||||
}
|
||||
|
||||
ldapUserIdentity := &Identity{
|
||||
Name: identity.UserID,
|
||||
Account: &Account{
|
||||
DisplayName: identity.DisplayName,
|
||||
EmailAddress: identity.Email,
|
||||
Id: identity.UserID,
|
||||
},
|
||||
PrincipalArn: fmt.Sprintf("arn:aws:iam::%s:user/%s", accountId, identity.UserID),
|
||||
}
|
||||
|
||||
// Verify that the identity is allowed to assume the role by checking the Trust Policy
|
||||
// The LDAP user doesn't have identity policies, so we strictly check if the Role trusts this principal.
|
||||
if err := h.iam.ValidateTrustPolicyForPrincipal(r.Context(), roleArn, ldapUserIdentity.PrincipalArn); err != nil {
|
||||
glog.V(2).Infof("AssumeRoleWithLDAPIdentity: trust policy validation failed for %s to assume %s: %v", ldapUsername, roleArn, err)
|
||||
h.writeSTSErrorResponse(w, r, STSErrAccessDenied, fmt.Errorf("trust policy denies access"))
|
||||
return
|
||||
}
|
||||
|
||||
// Generate common STS components with LDAP-specific claims
|
||||
modifyClaims := func(claims *sts.STSSessionClaims) {
|
||||
claims.WithIdentityProvider("ldap", identity.UserID, identity.Provider)
|
||||
}
|
||||
|
||||
stsCreds, assumedUser, err := h.prepareSTSCredentials(roleArn, roleSessionName, ldapUserIdentity.PrincipalArn, durationSeconds, modifyClaims)
|
||||
if err != nil {
|
||||
h.writeSTSErrorResponse(w, r, STSErrInternalError, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Build and return response
|
||||
xmlResponse := &AssumeRoleWithLDAPIdentityResponse{
|
||||
Result: LDAPIdentityResult{
|
||||
Credentials: stsCreds,
|
||||
AssumedRoleUser: assumedUser,
|
||||
},
|
||||
}
|
||||
xmlResponse.ResponseMetadata.RequestId = fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
|
||||
s3err.WriteXMLResponse(w, r, http.StatusOK, xmlResponse)
|
||||
}
|
||||
|
||||
// prepareSTSCredentials extracts common shared logic for credential generation
|
||||
func (h *STSHandlers) prepareSTSCredentials(roleArn, roleSessionName, principalArn string,
|
||||
durationSeconds *int64, modifyClaims func(*sts.STSSessionClaims)) (STSCredentials, *AssumedRoleUser, error) {
|
||||
|
||||
// Calculate duration
|
||||
duration := time.Hour // Default 1 hour
|
||||
if durationSeconds != nil {
|
||||
duration = time.Duration(*durationSeconds) * time.Second
|
||||
}
|
||||
|
||||
// Generate session ID
|
||||
sessionId, err := sts.GenerateSessionId()
|
||||
if err != nil {
|
||||
return STSCredentials{}, nil, fmt.Errorf("failed to generate session ID: %w", err)
|
||||
}
|
||||
|
||||
expiration := time.Now().Add(duration)
|
||||
|
||||
// Extract role name from ARN for proper response formatting
|
||||
roleName := utils.ExtractRoleNameFromArn(roleArn)
|
||||
if roleName == "" {
|
||||
roleName = roleArn // Fallback to full ARN if extraction fails
|
||||
}
|
||||
|
||||
// Create session claims with role information
|
||||
claims := sts.NewSTSSessionClaims(sessionId, h.stsService.Config.Issuer, expiration).
|
||||
WithSessionName(roleSessionName).
|
||||
WithRoleInfo(roleArn, fmt.Sprintf("%s:%s", roleName, roleSessionName), principalArn)
|
||||
|
||||
// Apply custom claims if provided (e.g., LDAP identity)
|
||||
if modifyClaims != nil {
|
||||
modifyClaims(claims)
|
||||
}
|
||||
|
||||
// Generate JWT session token
|
||||
sessionToken, err := h.stsService.GetTokenGenerator().GenerateJWTWithClaims(claims)
|
||||
if err != nil {
|
||||
return STSCredentials{}, nil, fmt.Errorf("failed to generate session token: %w", err)
|
||||
}
|
||||
|
||||
// Generate temporary credentials (cryptographically secure)
|
||||
// AccessKeyId: ASIA + 16 chars hex
|
||||
// SecretAccessKey: 40 chars base64
|
||||
randBytes := make([]byte, 30) // Sufficient for both
|
||||
if _, err := rand.Read(randBytes); err != nil {
|
||||
return STSCredentials{}, nil, fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
|
||||
// Generate AccessKeyId (ASIA + 16 upper-hex chars)
|
||||
// We use 8 bytes (16 hex chars)
|
||||
accessKeyId := "ASIA" + fmt.Sprintf("%X", randBytes[:8])
|
||||
|
||||
// Generate SecretAccessKey: 30 random bytes, base64-encoded to a 40-character string
|
||||
secretBytes := make([]byte, 30)
|
||||
if _, err := rand.Read(secretBytes); err != nil {
|
||||
return STSCredentials{}, nil, fmt.Errorf("failed to generate secret bytes: %w", err)
|
||||
}
|
||||
secretAccessKey := base64.StdEncoding.EncodeToString(secretBytes)
|
||||
|
||||
// Get account ID from STS config or use default
|
||||
accountId := defaultAccountId
|
||||
if h.stsService != nil && h.stsService.Config != nil && h.stsService.Config.AccountId != "" {
|
||||
accountId = h.stsService.Config.AccountId
|
||||
}
|
||||
|
||||
stsCreds := STSCredentials{
|
||||
AccessKeyId: accessKeyId,
|
||||
SecretAccessKey: secretAccessKey,
|
||||
SessionToken: sessionToken,
|
||||
Expiration: expiration.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
assumedUser := &AssumedRoleUser{
|
||||
AssumedRoleId: fmt.Sprintf("%s:%s", roleName, roleSessionName),
|
||||
Arn: fmt.Sprintf("arn:aws:sts::%s:assumed-role/%s/%s", accountId, roleName, roleSessionName),
|
||||
}
|
||||
|
||||
return stsCreds, assumedUser, nil
|
||||
}
|
||||
|
||||
// STS Response types for XML marshaling
|
||||
|
||||
// AssumeRoleWithWebIdentityResponse is the response for AssumeRoleWithWebIdentity
|
||||
@@ -211,6 +560,36 @@ type AssumedRoleUser struct {
|
||||
Arn string `xml:"Arn"`
|
||||
}
|
||||
|
||||
// AssumeRoleResponse is the response for AssumeRole
|
||||
type AssumeRoleResponse struct {
|
||||
XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ AssumeRoleResponse"`
|
||||
Result AssumeRoleResult `xml:"AssumeRoleResult"`
|
||||
ResponseMetadata struct {
|
||||
RequestId string `xml:"RequestId,omitempty"`
|
||||
} `xml:"ResponseMetadata,omitempty"`
|
||||
}
|
||||
|
||||
// AssumeRoleResult contains the result of AssumeRole
|
||||
type AssumeRoleResult struct {
|
||||
Credentials STSCredentials `xml:"Credentials"`
|
||||
AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"`
|
||||
}
|
||||
|
||||
// AssumeRoleWithLDAPIdentityResponse is the response for AssumeRoleWithLDAPIdentity
|
||||
type AssumeRoleWithLDAPIdentityResponse struct {
|
||||
XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ AssumeRoleWithLDAPIdentityResponse"`
|
||||
Result LDAPIdentityResult `xml:"AssumeRoleWithLDAPIdentityResult"`
|
||||
ResponseMetadata struct {
|
||||
RequestId string `xml:"RequestId,omitempty"`
|
||||
} `xml:"ResponseMetadata,omitempty"`
|
||||
}
|
||||
|
||||
// LDAPIdentityResult contains the result of AssumeRoleWithLDAPIdentity
|
||||
type LDAPIdentityResult struct {
|
||||
Credentials STSCredentials `xml:"Credentials"`
|
||||
AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"`
|
||||
}
|
||||
|
||||
// STS Error types
|
||||
|
||||
// STSErrorCode represents STS error codes
|
||||
|
||||
Reference in New Issue
Block a user