45 changed files with 3628 additions and 1132 deletions
-
1.gitignore
-
79README.md
-
16go.mod
-
30go.sum
-
27test/s3/iam/Makefile
-
114test/s3/iam/iam_config.json
-
6test/s3/iam/iam_config.local.json
-
8test/s3/iam/s3_iam_distributed_test.go
-
357test/s3/iam/s3_sts_assume_role_test.go
-
291test/s3/iam/s3_sts_ldap_test.go
-
82test/s3/iam/setup_all_tests.sh
-
2test/s3/iam/setup_keycloak.sh
-
4weed/iam/integration/advanced_policy_test.go
-
6weed/iam/integration/iam_integration_test.go
-
18weed/iam/integration/iam_manager.go
-
43weed/iam/integration/iam_manager_trust.go
-
571weed/iam/ldap/ldap_provider.go
-
24weed/iam/sts/cross_instance_token_test.go
-
24weed/iam/sts/distributed_sts_test.go
-
15weed/iam/sts/provider_factory.go
-
14weed/iam/sts/sts_service.go
-
5weed/pb/master.proto
-
185weed/pb/master_pb/master.pb.go
-
10weed/pb/volume_server.proto
-
1133weed/pb/volume_server_pb/volume_server.pb.go
-
777weed/pb/volume_server_pb/volume_server_grpc.pb.go
-
24weed/remote_storage/gcs/gcs_storage_client.go
-
15weed/s3api/auth_credentials_trust.go
-
12weed/s3api/auth_signature_v4_sts_test.go
-
8weed/s3api/s3_end_to_end_test.go
-
9weed/s3api/s3_iam_middleware.go
-
10weed/s3api/s3_jwt_auth_test.go
-
4weed/s3api/s3_multipart_iam_test.go
-
6weed/s3api/s3_presigned_url_iam_test.go
-
40weed/s3api/s3api_server.go
-
4weed/s3api/s3api_server_routing_test.go
-
429weed/s3api/s3api_sts.go
-
4weed/security/jwt.go
-
143weed/server/filer_jwt_test.go
-
40weed/server/filer_server_handlers.go
-
1weed/server/master_grpc_server.go
-
13weed/server/volume_grpc_client_to_master.go
-
9weed/storage/disk_location.go
-
76weed/storage/store.go
-
71weed/storage/store_state.go
@ -0,0 +1,357 @@ |
|||||
|
package iam |
||||
|
|
||||
|
import ( |
||||
|
"encoding/xml" |
||||
|
"fmt" |
||||
|
"io" |
||||
|
"net/http" |
||||
|
"net/url" |
||||
|
"os" |
||||
|
"strings" |
||||
|
"testing" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/aws/aws-sdk-go/aws/credentials" |
||||
|
v4 "github.com/aws/aws-sdk-go/aws/signer/v4" |
||||
|
"github.com/stretchr/testify/assert" |
||||
|
"github.com/stretchr/testify/require" |
||||
|
) |
||||
|
|
||||
|
// AssumeRoleResponse represents the STS AssumeRole response
|
||||
|
type AssumeRoleTestResponse struct { |
||||
|
XMLName xml.Name `xml:"AssumeRoleResponse"` |
||||
|
Result struct { |
||||
|
Credentials struct { |
||||
|
AccessKeyId string `xml:"AccessKeyId"` |
||||
|
SecretAccessKey string `xml:"SecretAccessKey"` |
||||
|
SessionToken string `xml:"SessionToken"` |
||||
|
Expiration string `xml:"Expiration"` |
||||
|
} `xml:"Credentials"` |
||||
|
AssumedRoleUser struct { |
||||
|
AssumedRoleId string `xml:"AssumedRoleId"` |
||||
|
Arn string `xml:"Arn"` |
||||
|
} `xml:"AssumedRoleUser"` |
||||
|
} `xml:"AssumeRoleResult"` |
||||
|
} |
||||
|
|
||||
|
// TestSTSAssumeRoleValidation tests input validation for AssumeRole endpoint
|
||||
|
func TestSTSAssumeRoleValidation(t *testing.T) { |
||||
|
if testing.Short() { |
||||
|
t.Skip("Skipping integration test in short mode") |
||||
|
} |
||||
|
|
||||
|
if !isSTSEndpointRunning(t) { |
||||
|
t.Fatal("SeaweedFS STS endpoint is not running at", TestSTSEndpoint, "- please run 'make setup-all-tests' first") |
||||
|
} |
||||
|
|
||||
|
// Check if AssumeRole is implemented by making a test call
|
||||
|
if !isAssumeRoleImplemented(t) { |
||||
|
t.Fatal("AssumeRole action is not implemented in the running server - please rebuild weed binary with new code and restart the server") |
||||
|
} |
||||
|
|
||||
|
t.Run("missing_role_arn", func(t *testing.T) { |
||||
|
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
||||
|
"Action": {"AssumeRole"}, |
||||
|
"Version": {"2011-06-15"}, |
||||
|
"RoleSessionName": {"test-session"}, |
||||
|
// RoleArn is missing
|
||||
|
}, "test-access-key", "test-secret-key") |
||||
|
require.NoError(t, err) |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode, |
||||
|
"Should fail without RoleArn") |
||||
|
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
require.NoError(t, err) |
||||
|
var errResp STSErrorTestResponse |
||||
|
err = xml.Unmarshal(body, &errResp) |
||||
|
require.NoError(t, err, "Failed to parse error response: %s", string(body)) |
||||
|
assert.Equal(t, "MissingParameter", errResp.Error.Code) |
||||
|
}) |
||||
|
|
||||
|
t.Run("missing_role_session_name", func(t *testing.T) { |
||||
|
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
||||
|
"Action": {"AssumeRole"}, |
||||
|
"Version": {"2011-06-15"}, |
||||
|
"RoleArn": {"arn:aws:iam::role/test-role"}, |
||||
|
// RoleSessionName is missing
|
||||
|
}, "test-access-key", "test-secret-key") |
||||
|
require.NoError(t, err) |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode, |
||||
|
"Should fail without RoleSessionName") |
||||
|
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
require.NoError(t, err) |
||||
|
var errResp STSErrorTestResponse |
||||
|
err = xml.Unmarshal(body, &errResp) |
||||
|
require.NoError(t, err, "Failed to parse error response: %s", string(body)) |
||||
|
assert.Equal(t, "MissingParameter", errResp.Error.Code) |
||||
|
}) |
||||
|
|
||||
|
t.Run("unsupported_action_for_anonymous", func(t *testing.T) { |
||||
|
// AssumeRole requires SigV4 authentication, anonymous requests should fail
|
||||
|
resp, err := callSTSAPI(t, url.Values{ |
||||
|
"Action": {"AssumeRole"}, |
||||
|
"Version": {"2011-06-15"}, |
||||
|
"RoleArn": {"arn:aws:iam::role/test-role"}, |
||||
|
"RoleSessionName": {"test-session"}, |
||||
|
}) |
||||
|
require.NoError(t, err) |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
// Should fail because AssumeRole requires AWS SigV4 authentication
|
||||
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode, |
||||
|
"AssumeRole should require authentication") |
||||
|
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
require.NoError(t, err) |
||||
|
t.Logf("Response for anonymous AssumeRole: status=%d, body=%s", resp.StatusCode, string(body)) |
||||
|
}) |
||||
|
|
||||
|
t.Run("invalid_duration_too_short", func(t *testing.T) { |
||||
|
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
||||
|
"Action": {"AssumeRole"}, |
||||
|
"Version": {"2011-06-15"}, |
||||
|
"RoleArn": {"arn:aws:iam::role/test-role"}, |
||||
|
"RoleSessionName": {"test-session"}, |
||||
|
"DurationSeconds": {"100"}, // Less than 900 seconds minimum
|
||||
|
}, "test-access-key", "test-secret-key") |
||||
|
require.NoError(t, err) |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode, |
||||
|
"Should fail with DurationSeconds < 900") |
||||
|
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
require.NoError(t, err) |
||||
|
var errResp STSErrorTestResponse |
||||
|
err = xml.Unmarshal(body, &errResp) |
||||
|
require.NoError(t, err, "Failed to parse error response: %s", string(body)) |
||||
|
assert.Equal(t, "InvalidParameterValue", errResp.Error.Code) |
||||
|
}) |
||||
|
|
||||
|
t.Run("invalid_duration_too_long", func(t *testing.T) { |
||||
|
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
||||
|
"Action": {"AssumeRole"}, |
||||
|
"Version": {"2011-06-15"}, |
||||
|
"RoleArn": {"arn:aws:iam::role/test-role"}, |
||||
|
"RoleSessionName": {"test-session"}, |
||||
|
"DurationSeconds": {"100000"}, // More than 43200 seconds maximum
|
||||
|
}, "test-access-key", "test-secret-key") |
||||
|
require.NoError(t, err) |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode, |
||||
|
"Should fail with DurationSeconds > 43200") |
||||
|
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
require.NoError(t, err) |
||||
|
var errResp STSErrorTestResponse |
||||
|
err = xml.Unmarshal(body, &errResp) |
||||
|
require.NoError(t, err, "Failed to parse error response: %s", string(body)) |
||||
|
assert.Equal(t, "InvalidParameterValue", errResp.Error.Code) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// isAssumeRoleImplemented checks if the running server supports AssumeRole
|
||||
|
func isAssumeRoleImplemented(t *testing.T) bool { |
||||
|
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
||||
|
"Action": {"AssumeRole"}, |
||||
|
"Version": {"2011-06-15"}, |
||||
|
"RoleArn": {"arn:aws:iam::role/test"}, |
||||
|
"RoleSessionName": {"test"}, |
||||
|
}, "test", "test") |
||||
|
if err != nil { |
||||
|
return false |
||||
|
} |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
if err != nil { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// If we get "NotImplemented", the action isn't supported
|
||||
|
var errResp STSErrorTestResponse |
||||
|
if xml.Unmarshal(body, &errResp) == nil && errResp.Error.Code == "NotImplemented" { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// If we get InvalidAction, the action isn't routed
|
||||
|
if errResp.Error.Code == "InvalidAction" { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
// TestSTSAssumeRoleWithValidCredentials tests AssumeRole with valid IAM credentials
|
||||
|
// This test requires a configured IAM user in SeaweedFS
|
||||
|
func TestSTSAssumeRoleWithValidCredentials(t *testing.T) { |
||||
|
if testing.Short() { |
||||
|
t.Skip("Skipping integration test in short mode") |
||||
|
} |
||||
|
|
||||
|
if !isSTSEndpointRunning(t) { |
||||
|
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint) |
||||
|
} |
||||
|
|
||||
|
// Use test credentials from environment or fall back to defaults
|
||||
|
accessKey := os.Getenv("STS_TEST_ACCESS_KEY") |
||||
|
if accessKey == "" { |
||||
|
accessKey = "admin" |
||||
|
} |
||||
|
secretKey := os.Getenv("STS_TEST_SECRET_KEY") |
||||
|
if secretKey == "" { |
||||
|
secretKey = "admin" |
||||
|
} |
||||
|
|
||||
|
t.Run("successful_assume_role", func(t *testing.T) { |
||||
|
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
||||
|
"Action": {"AssumeRole"}, |
||||
|
"Version": {"2011-06-15"}, |
||||
|
"RoleArn": {"arn:aws:iam::role/admin"}, |
||||
|
"RoleSessionName": {"integration-test-session"}, |
||||
|
}, accessKey, secretKey) |
||||
|
require.NoError(t, err) |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
require.NoError(t, err) |
||||
|
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body)) |
||||
|
|
||||
|
// If AssumeRole is not yet implemented, expect an error about unsupported action
|
||||
|
if resp.StatusCode != http.StatusOK { |
||||
|
var errResp STSErrorTestResponse |
||||
|
err = xml.Unmarshal(body, &errResp) |
||||
|
require.NoError(t, err, "Failed to parse error response: %s", string(body)) |
||||
|
t.Logf("Error response: code=%s, message=%s", errResp.Error.Code, errResp.Error.Message) |
||||
|
|
||||
|
// This test will initially fail until AssumeRole is implemented
|
||||
|
// Once implemented, uncomment the assertions below
|
||||
|
// assert.Fail(t, "AssumeRole not yet implemented")
|
||||
|
} else { |
||||
|
var stsResp AssumeRoleTestResponse |
||||
|
err = xml.Unmarshal(body, &stsResp) |
||||
|
require.NoError(t, err, "Failed to parse response: %s", string(body)) |
||||
|
|
||||
|
creds := stsResp.Result.Credentials |
||||
|
assert.NotEmpty(t, creds.AccessKeyId, "AccessKeyId should not be empty") |
||||
|
assert.NotEmpty(t, creds.SecretAccessKey, "SecretAccessKey should not be empty") |
||||
|
assert.NotEmpty(t, creds.SessionToken, "SessionToken should not be empty") |
||||
|
assert.NotEmpty(t, creds.Expiration, "Expiration should not be empty") |
||||
|
|
||||
|
t.Logf("Successfully obtained temporary credentials: AccessKeyId=%s", creds.AccessKeyId) |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("with_custom_duration", func(t *testing.T) { |
||||
|
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
||||
|
"Action": {"AssumeRole"}, |
||||
|
"Version": {"2011-06-15"}, |
||||
|
"RoleArn": {"arn:aws:iam::role/admin"}, |
||||
|
"RoleSessionName": {"duration-test-session"}, |
||||
|
"DurationSeconds": {"3600"}, // 1 hour
|
||||
|
}, accessKey, secretKey) |
||||
|
require.NoError(t, err) |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
require.NoError(t, err) |
||||
|
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body)) |
||||
|
|
||||
|
// Verify DurationSeconds is accepted
|
||||
|
if resp.StatusCode != http.StatusOK { |
||||
|
var errResp STSErrorTestResponse |
||||
|
err = xml.Unmarshal(body, &errResp) |
||||
|
require.NoError(t, err, "Failed to parse error response: %s", string(body)) |
||||
|
// Should not fail due to DurationSeconds parameter
|
||||
|
assert.NotContains(t, errResp.Error.Message, "DurationSeconds", |
||||
|
"DurationSeconds parameter should be accepted") |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// TestSTSAssumeRoleWithInvalidCredentials tests AssumeRole rejection with bad credentials
|
||||
|
func TestSTSAssumeRoleWithInvalidCredentials(t *testing.T) { |
||||
|
if testing.Short() { |
||||
|
t.Skip("Skipping integration test in short mode") |
||||
|
} |
||||
|
|
||||
|
if !isSTSEndpointRunning(t) { |
||||
|
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint) |
||||
|
} |
||||
|
|
||||
|
t.Run("invalid_access_key", func(t *testing.T) { |
||||
|
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
||||
|
"Action": {"AssumeRole"}, |
||||
|
"Version": {"2011-06-15"}, |
||||
|
"RoleArn": {"arn:aws:iam::role/admin"}, |
||||
|
"RoleSessionName": {"test-session"}, |
||||
|
}, "invalid-access-key", "some-secret-key") |
||||
|
require.NoError(t, err) |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
// Should fail with access denied or signature mismatch
|
||||
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode, |
||||
|
"Should fail with invalid access key") |
||||
|
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
require.NoError(t, err) |
||||
|
t.Logf("Response for invalid credentials: status=%d, body=%s", resp.StatusCode, string(body)) |
||||
|
}) |
||||
|
|
||||
|
t.Run("invalid_secret_key", func(t *testing.T) { |
||||
|
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
||||
|
"Action": {"AssumeRole"}, |
||||
|
"Version": {"2011-06-15"}, |
||||
|
"RoleArn": {"arn:aws:iam::role/admin"}, |
||||
|
"RoleSessionName": {"test-session"}, |
||||
|
}, "admin", "wrong-secret-key") |
||||
|
require.NoError(t, err) |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
// Should fail with signature mismatch
|
||||
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode, |
||||
|
"Should fail with invalid secret key") |
||||
|
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
require.NoError(t, err) |
||||
|
t.Logf("Response for wrong secret: status=%d, body=%s", resp.StatusCode, string(body)) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// callSTSAPIWithSigV4 makes an STS API call with AWS Signature V4 authentication
|
||||
|
func callSTSAPIWithSigV4(t *testing.T, params url.Values, accessKey, secretKey string) (*http.Response, error) { |
||||
|
// Prepare request body
|
||||
|
body := params.Encode() |
||||
|
|
||||
|
// Create request
|
||||
|
req, err := http.NewRequest(http.MethodPost, TestSTSEndpoint+"/", |
||||
|
strings.NewReader(body)) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
||||
|
req.Header.Set("Host", req.URL.Host) |
||||
|
|
||||
|
// Sign request with AWS Signature V4 using official SDK
|
||||
|
creds := credentials.NewStaticCredentials(accessKey, secretKey, "") |
||||
|
signer := v4.NewSigner(creds) |
||||
|
|
||||
|
// Read body for signing
|
||||
|
// Note: We need a ReadSeeker for the signer, or we can pass the body string/bytes to ComputeBodyHash if needed,
|
||||
|
// but standard Sign method takes an io.ReadSeeker for the body.
|
||||
|
bodyReader := strings.NewReader(body) |
||||
|
_, err = signer.Sign(req, bodyReader, "sts", "us-east-1", time.Now()) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to sign request: %w", err) |
||||
|
} |
||||
|
|
||||
|
client := &http.Client{Timeout: 30 * time.Second} |
||||
|
return client.Do(req) |
||||
|
} |
||||
@ -0,0 +1,291 @@ |
|||||
|
package iam |
||||
|
|
||||
|
import ( |
||||
|
"encoding/xml" |
||||
|
"io" |
||||
|
"net/http" |
||||
|
"net/url" |
||||
|
"os" |
||||
|
"strings" |
||||
|
"testing" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/stretchr/testify/assert" |
||||
|
"github.com/stretchr/testify/require" |
||||
|
) |
||||
|
|
||||
|
// AssumeRoleWithLDAPIdentityResponse represents the STS response for LDAP identity
|
||||
|
type AssumeRoleWithLDAPIdentityTestResponse struct { |
||||
|
XMLName xml.Name `xml:"AssumeRoleWithLDAPIdentityResponse"` |
||||
|
Result struct { |
||||
|
Credentials struct { |
||||
|
AccessKeyId string `xml:"AccessKeyId"` |
||||
|
SecretAccessKey string `xml:"SecretAccessKey"` |
||||
|
SessionToken string `xml:"SessionToken"` |
||||
|
Expiration string `xml:"Expiration"` |
||||
|
} `xml:"Credentials"` |
||||
|
} `xml:"AssumeRoleWithLDAPIdentityResult"` |
||||
|
} |
||||
|
|
||||
|
// TestSTSLDAPValidation tests input validation for AssumeRoleWithLDAPIdentity
|
||||
|
func TestSTSLDAPValidation(t *testing.T) { |
||||
|
if testing.Short() { |
||||
|
t.Skip("Skipping integration test in short mode") |
||||
|
} |
||||
|
|
||||
|
if !isSTSEndpointRunning(t) { |
||||
|
t.Fatal("SeaweedFS STS endpoint is not running at", TestSTSEndpoint, "- please run 'make setup-all-tests' first") |
||||
|
} |
||||
|
|
||||
|
// Check if AssumeRoleWithLDAPIdentity is implemented
|
||||
|
if !isLDAPIdentityActionImplemented(t) { |
||||
|
t.Fatal("AssumeRoleWithLDAPIdentity action is not implemented in the running server - please rebuild weed binary with new code and restart the server") |
||||
|
} |
||||
|
|
||||
|
t.Run("missing_ldap_username", func(t *testing.T) { |
||||
|
resp, err := callSTSAPIForLDAP(t, url.Values{ |
||||
|
"Action": {"AssumeRoleWithLDAPIdentity"}, |
||||
|
"Version": {"2011-06-15"}, |
||||
|
"RoleArn": {"arn:aws:iam::role/test-role"}, |
||||
|
"RoleSessionName": {"test-session"}, |
||||
|
"LDAPPassword": {"testpass"}, |
||||
|
// LDAPUsername is missing
|
||||
|
}) |
||||
|
require.NoError(t, err) |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode, |
||||
|
"Should fail without LDAPUsername") |
||||
|
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
require.NoError(t, err) |
||||
|
var errResp STSErrorTestResponse |
||||
|
err = xml.Unmarshal(body, &errResp) |
||||
|
require.NoError(t, err, "Failed to parse error response: %s", string(body)) |
||||
|
// Expect either MissingParameter or InvalidAction (if not implemented)
|
||||
|
assert.Contains(t, []string{"MissingParameter", "InvalidAction"}, errResp.Error.Code) |
||||
|
}) |
||||
|
|
||||
|
t.Run("missing_ldap_password", func(t *testing.T) { |
||||
|
resp, err := callSTSAPIForLDAP(t, url.Values{ |
||||
|
"Action": {"AssumeRoleWithLDAPIdentity"}, |
||||
|
"Version": {"2011-06-15"}, |
||||
|
"RoleArn": {"arn:aws:iam::role/test-role"}, |
||||
|
"RoleSessionName": {"test-session"}, |
||||
|
"LDAPUsername": {"testuser"}, |
||||
|
// LDAPPassword is missing
|
||||
|
}) |
||||
|
require.NoError(t, err) |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode, |
||||
|
"Should fail without LDAPPassword") |
||||
|
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
require.NoError(t, err) |
||||
|
var errResp STSErrorTestResponse |
||||
|
err = xml.Unmarshal(body, &errResp) |
||||
|
require.NoError(t, err, "Failed to parse error response: %s", string(body)) |
||||
|
assert.Contains(t, []string{"MissingParameter", "InvalidAction"}, errResp.Error.Code) |
||||
|
}) |
||||
|
|
||||
|
t.Run("missing_role_arn", func(t *testing.T) { |
||||
|
resp, err := callSTSAPIForLDAP(t, url.Values{ |
||||
|
"Action": {"AssumeRoleWithLDAPIdentity"}, |
||||
|
"Version": {"2011-06-15"}, |
||||
|
"RoleSessionName": {"test-session"}, |
||||
|
"LDAPUsername": {"testuser"}, |
||||
|
"LDAPPassword": {"testpass"}, |
||||
|
// RoleArn is missing
|
||||
|
}) |
||||
|
require.NoError(t, err) |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode, |
||||
|
"Should fail without RoleArn") |
||||
|
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
require.NoError(t, err) |
||||
|
var errResp STSErrorTestResponse |
||||
|
err = xml.Unmarshal(body, &errResp) |
||||
|
require.NoError(t, err, "Failed to parse error response: %s", string(body)) |
||||
|
assert.Contains(t, []string{"MissingParameter", "InvalidAction"}, errResp.Error.Code) |
||||
|
}) |
||||
|
|
||||
|
t.Run("invalid_duration_too_short", func(t *testing.T) { |
||||
|
resp, err := callSTSAPIForLDAP(t, url.Values{ |
||||
|
"Action": {"AssumeRoleWithLDAPIdentity"}, |
||||
|
"Version": {"2011-06-15"}, |
||||
|
"RoleArn": {"arn:aws:iam::role/test-role"}, |
||||
|
"RoleSessionName": {"test-session"}, |
||||
|
"LDAPUsername": {"testuser"}, |
||||
|
"LDAPPassword": {"testpass"}, |
||||
|
"DurationSeconds": {"100"}, // Less than 900 seconds minimum
|
||||
|
}) |
||||
|
require.NoError(t, err) |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
// If the action is implemented, it should reject invalid duration
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
require.NoError(t, err) |
||||
|
t.Logf("Response for invalid duration: status=%d, body=%s", resp.StatusCode, string(body)) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// TestSTSLDAPWithValidCredentials tests LDAP authentication
|
||||
|
// This test requires an LDAP server to be configured
|
||||
|
func TestSTSLDAPWithValidCredentials(t *testing.T) { |
||||
|
if testing.Short() { |
||||
|
t.Skip("Skipping integration test in short mode") |
||||
|
} |
||||
|
|
||||
|
if !isSTSEndpointRunning(t) { |
||||
|
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint) |
||||
|
} |
||||
|
|
||||
|
// Check if LDAP is configured (skip if not)
|
||||
|
if !isLDAPConfigured() { |
||||
|
t.Skip("LDAP is not configured - skipping LDAP integration tests") |
||||
|
} |
||||
|
|
||||
|
t.Run("successful_ldap_auth", func(t *testing.T) { |
||||
|
resp, err := callSTSAPIForLDAP(t, url.Values{ |
||||
|
"Action": {"AssumeRoleWithLDAPIdentity"}, |
||||
|
"Version": {"2011-06-15"}, |
||||
|
"RoleArn": {"arn:aws:iam::role/ldap-user"}, |
||||
|
"RoleSessionName": {"ldap-test-session"}, |
||||
|
"LDAPUsername": {"testuser"}, |
||||
|
"LDAPPassword": {"testpass"}, |
||||
|
}) |
||||
|
require.NoError(t, err) |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
require.NoError(t, err) |
||||
|
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body)) |
||||
|
|
||||
|
if resp.StatusCode == http.StatusOK { |
||||
|
var stsResp AssumeRoleWithLDAPIdentityTestResponse |
||||
|
err = xml.Unmarshal(body, &stsResp) |
||||
|
require.NoError(t, err, "Failed to parse response: %s", string(body)) |
||||
|
|
||||
|
creds := stsResp.Result.Credentials |
||||
|
assert.NotEmpty(t, creds.AccessKeyId, "AccessKeyId should not be empty") |
||||
|
assert.NotEmpty(t, creds.SecretAccessKey, "SecretAccessKey should not be empty") |
||||
|
assert.NotEmpty(t, creds.SessionToken, "SessionToken should not be empty") |
||||
|
assert.NotEmpty(t, creds.Expiration, "Expiration should not be empty") |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// TestSTSLDAPWithInvalidCredentials tests LDAP rejection with bad credentials
|
||||
|
func TestSTSLDAPWithInvalidCredentials(t *testing.T) { |
||||
|
if testing.Short() { |
||||
|
t.Skip("Skipping integration test in short mode") |
||||
|
} |
||||
|
|
||||
|
if !isSTSEndpointRunning(t) { |
||||
|
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint) |
||||
|
} |
||||
|
|
||||
|
t.Run("invalid_ldap_password", func(t *testing.T) { |
||||
|
resp, err := callSTSAPIForLDAP(t, url.Values{ |
||||
|
"Action": {"AssumeRoleWithLDAPIdentity"}, |
||||
|
"Version": {"2011-06-15"}, |
||||
|
"RoleArn": {"arn:aws:iam::role/ldap-user"}, |
||||
|
"RoleSessionName": {"ldap-test-session"}, |
||||
|
"LDAPUsername": {"testuser"}, |
||||
|
"LDAPPassword": {"wrong-password"}, |
||||
|
}) |
||||
|
require.NoError(t, err) |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
require.NoError(t, err) |
||||
|
t.Logf("Response for invalid LDAP credentials: status=%d, body=%s", resp.StatusCode, string(body)) |
||||
|
|
||||
|
// Should fail (either AccessDenied or InvalidAction if not implemented)
|
||||
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode, |
||||
|
"Should fail with invalid LDAP password") |
||||
|
}) |
||||
|
|
||||
|
t.Run("nonexistent_ldap_user", func(t *testing.T) { |
||||
|
resp, err := callSTSAPIForLDAP(t, url.Values{ |
||||
|
"Action": {"AssumeRoleWithLDAPIdentity"}, |
||||
|
"Version": {"2011-06-15"}, |
||||
|
"RoleArn": {"arn:aws:iam::role/ldap-user"}, |
||||
|
"RoleSessionName": {"ldap-test-session"}, |
||||
|
"LDAPUsername": {"nonexistent-user-12345"}, |
||||
|
"LDAPPassword": {"somepassword"}, |
||||
|
}) |
||||
|
require.NoError(t, err) |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
require.NoError(t, err) |
||||
|
t.Logf("Response for nonexistent user: status=%d, body=%s", resp.StatusCode, string(body)) |
||||
|
|
||||
|
// Should fail
|
||||
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode, |
||||
|
"Should fail with nonexistent LDAP user") |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// callSTSAPIForLDAP makes an STS API call for LDAP operation
|
||||
|
func callSTSAPIForLDAP(t *testing.T, params url.Values) (*http.Response, error) { |
||||
|
req, err := http.NewRequest(http.MethodPost, TestSTSEndpoint+"/", |
||||
|
strings.NewReader(params.Encode())) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
||||
|
|
||||
|
client := &http.Client{Timeout: 30 * time.Second} |
||||
|
return client.Do(req) |
||||
|
} |
||||
|
|
||||
|
// isLDAPConfigured checks if LDAP server is configured and available
|
||||
|
func isLDAPConfigured() bool { |
||||
|
// Check environment variable for LDAP URL
|
||||
|
ldapURL := os.Getenv("LDAP_URL") |
||||
|
return ldapURL != "" |
||||
|
} |
||||
|
|
||||
|
// isLDAPIdentityActionImplemented checks if the running server supports AssumeRoleWithLDAPIdentity
|
||||
|
func isLDAPIdentityActionImplemented(t *testing.T) bool { |
||||
|
resp, err := callSTSAPIForLDAP(t, url.Values{ |
||||
|
"Action": {"AssumeRoleWithLDAPIdentity"}, |
||||
|
"Version": {"2011-06-15"}, |
||||
|
"RoleArn": {"arn:aws:iam::role/test"}, |
||||
|
"RoleSessionName": {"test"}, |
||||
|
"LDAPUsername": {"test"}, |
||||
|
"LDAPPassword": {"test"}, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return false |
||||
|
} |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
if err != nil { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// If we get "NotImplemented" or empty response, the action isn't supported
|
||||
|
if len(body) == 0 { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
var errResp STSErrorTestResponse |
||||
|
if xml.Unmarshal(body, &errResp) == nil && errResp.Error.Code == "NotImplemented" { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// If we get InvalidAction, the action isn't routed
|
||||
|
if errResp.Error.Code == "InvalidAction" { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
return true |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
1133
weed/pb/volume_server_pb/volume_server.pb.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
777
weed/pb/volume_server_pb/volume_server_grpc.pb.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View 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") |
||||
|
} |
||||
@ -0,0 +1,143 @@ |
|||||
|
package weed_server |
||||
|
|
||||
|
import ( |
||||
|
"net/http/httptest" |
||||
|
"testing" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/golang-jwt/jwt/v5" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/security" |
||||
|
) |
||||
|
|
||||
|
func TestFilerServer_maybeCheckJwtAuthorization_Scoped(t *testing.T) { |
||||
|
signingKey := "secret" |
||||
|
filerGuard := security.NewGuard(nil, signingKey, 0, signingKey, 0) |
||||
|
fs := &FilerServer{ |
||||
|
filerGuard: filerGuard, |
||||
|
} |
||||
|
|
||||
|
// Helper to generate token
|
||||
|
genToken := func(allowedPrefixes []string, allowedMethods []string) string { |
||||
|
claims := security.SeaweedFilerClaims{ |
||||
|
AllowedPrefixes: allowedPrefixes, |
||||
|
AllowedMethods: allowedMethods, |
||||
|
RegisteredClaims: jwt.RegisteredClaims{ |
||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), |
||||
|
}, |
||||
|
} |
||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) |
||||
|
str, err := token.SignedString([]byte(signingKey)) |
||||
|
if err != nil { |
||||
|
t.Fatalf("failed to sign token: %v", err) |
||||
|
} |
||||
|
return str |
||||
|
} |
||||
|
|
||||
|
tests := []struct { |
||||
|
name string |
||||
|
token string |
||||
|
method string |
||||
|
path string |
||||
|
isWrite bool |
||||
|
expectAuthorized bool |
||||
|
}{ |
||||
|
{ |
||||
|
name: "no restrictions", |
||||
|
token: genToken(nil, nil), |
||||
|
method: "GET", |
||||
|
path: "/data/test", |
||||
|
isWrite: false, |
||||
|
expectAuthorized: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "allowed prefix match", |
||||
|
token: genToken([]string{"/data"}, nil), |
||||
|
method: "GET", |
||||
|
path: "/data/test", |
||||
|
isWrite: false, |
||||
|
expectAuthorized: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "allowed prefix mismatch", |
||||
|
token: genToken([]string{"/private"}, nil), |
||||
|
method: "GET", |
||||
|
path: "/data/test", |
||||
|
isWrite: false, |
||||
|
expectAuthorized: false, |
||||
|
}, |
||||
|
{ |
||||
|
name: "allowed method match", |
||||
|
token: genToken(nil, []string{"GET"}), |
||||
|
method: "GET", |
||||
|
path: "/data/test", |
||||
|
isWrite: false, |
||||
|
expectAuthorized: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "allowed method mismatch", |
||||
|
token: genToken(nil, []string{"POST"}), |
||||
|
method: "GET", |
||||
|
path: "/data/test", |
||||
|
isWrite: false, |
||||
|
expectAuthorized: false, |
||||
|
}, |
||||
|
{ |
||||
|
name: "both match", |
||||
|
token: genToken([]string{"/data"}, []string{"GET"}), |
||||
|
method: "GET", |
||||
|
path: "/data/test", |
||||
|
isWrite: false, |
||||
|
expectAuthorized: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "prefix match, method mismatch", |
||||
|
token: genToken([]string{"/data"}, []string{"POST"}), |
||||
|
method: "GET", |
||||
|
path: "/data/test", |
||||
|
isWrite: false, |
||||
|
expectAuthorized: false, |
||||
|
}, |
||||
|
{ |
||||
|
name: "multiple prefixes match", |
||||
|
token: genToken([]string{"/other", "/data"}, nil), |
||||
|
method: "GET", |
||||
|
path: "/data/test", |
||||
|
isWrite: false, |
||||
|
expectAuthorized: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "write operation with method restriction", |
||||
|
token: genToken(nil, []string{"POST", "PUT"}), |
||||
|
method: "POST", |
||||
|
path: "/data/upload", |
||||
|
isWrite: true, |
||||
|
expectAuthorized: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "root path with prefix restriction", |
||||
|
token: genToken([]string{"/data"}, nil), |
||||
|
method: "GET", |
||||
|
path: "/", |
||||
|
isWrite: false, |
||||
|
expectAuthorized: false, |
||||
|
}, |
||||
|
{ |
||||
|
name: "exact prefix match", |
||||
|
token: genToken([]string{"/data"}, nil), |
||||
|
method: "GET", |
||||
|
path: "/data", |
||||
|
isWrite: false, |
||||
|
expectAuthorized: true, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
for _, tt := range tests { |
||||
|
t.Run(tt.name, func(t *testing.T) { |
||||
|
req := httptest.NewRequest(tt.method, tt.path, nil) |
||||
|
req.Header.Set("Authorization", "Bearer "+tt.token) |
||||
|
if authorized := fs.maybeCheckJwtAuthorization(req, tt.isWrite); authorized != tt.expectAuthorized { |
||||
|
t.Errorf("expected authorized=%v, got %v", tt.expectAuthorized, authorized) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,71 @@ |
|||||
|
package storage |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"os" |
||||
|
"path/filepath" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/glog" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/util" |
||||
|
"google.golang.org/protobuf/proto" |
||||
|
) |
||||
|
|
||||
|
const ( |
||||
|
StateFileName = "state.pb" |
||||
|
StateFileMode = 0644 |
||||
|
) |
||||
|
|
||||
|
type State struct { |
||||
|
FilePath string |
||||
|
Pb *volume_server_pb.VolumeServerState |
||||
|
} |
||||
|
|
||||
|
func NewState(dir string) (*State, error) { |
||||
|
state := &State{ |
||||
|
FilePath: filepath.Join(dir, StateFileName), |
||||
|
Pb: nil, |
||||
|
} |
||||
|
|
||||
|
err := state.Load() |
||||
|
return state, err |
||||
|
} |
||||
|
|
||||
|
func (st *State) Load() error { |
||||
|
st.Pb = &volume_server_pb.VolumeServerState{} |
||||
|
|
||||
|
if !util.FileExists(st.FilePath) { |
||||
|
glog.V(1).Infof("No preexisting store state at %s", st.FilePath) |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
binPb, err := os.ReadFile(st.FilePath) |
||||
|
if err != nil { |
||||
|
st.Pb = nil |
||||
|
return fmt.Errorf("failed to read store state from %s : %v", st.FilePath, err) |
||||
|
} |
||||
|
if err := proto.Unmarshal(binPb, st.Pb); err != nil { |
||||
|
st.Pb = nil |
||||
|
return fmt.Errorf("failed to parse store state from %s : %v", st.FilePath, err) |
||||
|
} |
||||
|
|
||||
|
glog.V(1).Infof("Got store state from %s: %v", st.FilePath, st.Pb) |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (st *State) Save() error { |
||||
|
if st.Pb == nil { |
||||
|
st.Pb = &volume_server_pb.VolumeServerState{} |
||||
|
} |
||||
|
|
||||
|
binPb, err := proto.Marshal(st.Pb) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to serialize store state %v: %s", st.Pb, err) |
||||
|
} |
||||
|
if err := util.WriteFile(st.FilePath, binPb, StateFileMode); err != nil { |
||||
|
return fmt.Errorf("failed to write store state to %s : %v", st.FilePath, err) |
||||
|
} |
||||
|
|
||||
|
glog.V(1).Infof("Saved store state %v to %s", st.Pb, st.FilePath) |
||||
|
return nil |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue