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