From bd0b1fe9d5783ade58583b626ece130dc550238d Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Fri, 20 Feb 2026 11:04:18 -0800 Subject: [PATCH] S3 IAM: Added ListPolicyVersions and GetPolicyVersion support (#8395) * test(s3/iam): add managed policy CRUD lifecycle integration coverage * s3/iam: add ListPolicyVersions and GetPolicyVersion support * test(s3/iam): cover ListPolicyVersions and GetPolicyVersion --- test/s3/iam/s3_iam_admin_test.go | 111 +++++++++++++++++++++++++++++++ weed/iam/responses.go | 20 ++++++ weed/s3api/s3api_embedded_iam.go | 92 ++++++++++++++++++++++++- 3 files changed, 222 insertions(+), 1 deletion(-) diff --git a/test/s3/iam/s3_iam_admin_test.go b/test/s3/iam/s3_iam_admin_test.go index fd1d0da7f..d9ce1a3d4 100644 --- a/test/s3/iam/s3_iam_admin_test.go +++ b/test/s3/iam/s3_iam_admin_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/service/iam" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -202,6 +203,116 @@ func TestIAMPolicyManagement(t *testing.T) { }) }) + t.Run("managed_policy_crud_lifecycle", func(t *testing.T) { + policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::*"}]}` + + policyNames := []string{"test-managed-policy-lifecycle-a", "test-managed-policy-lifecycle-b"} + policyArns := make([]*string, 0, len(policyNames)) + for _, policyName := range policyNames { + createResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String(policyName), + PolicyDocument: aws.String(policyDoc), + }) + require.NoError(t, err) + policyArns = append(policyArns, createResp.Policy.Arn) + } + + t.Cleanup(func() { + for _, policyArn := range policyArns { + _, _ = iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policyArn}) + } + }) + + listResp, err := iamClient.ListPolicies(&iam.ListPoliciesInput{}) + require.NoError(t, err) + + foundByName := map[string]bool{} + for _, policy := range listResp.Policies { + if policy.PolicyName != nil { + foundByName[*policy.PolicyName] = true + } + } + for _, policyName := range policyNames { + assert.True(t, foundByName[policyName], "policy %s should be listed", policyName) + } + + getResp, err := iamClient.GetPolicy(&iam.GetPolicyInput{PolicyArn: policyArns[0]}) + require.NoError(t, err) + require.NotNil(t, getResp.Policy) + assert.Equal(t, policyNames[0], aws.StringValue(getResp.Policy.PolicyName)) + assert.Equal(t, aws.StringValue(policyArns[0]), aws.StringValue(getResp.Policy.Arn)) + + _, err = iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policyArns[0]}) + require.NoError(t, err) + + _, err = iamClient.GetPolicy(&iam.GetPolicyInput{PolicyArn: policyArns[0]}) + require.Error(t, err) + awsErr, ok := err.(awserr.Error) + require.True(t, ok) + assert.Equal(t, iam.ErrCodeNoSuchEntityException, awsErr.Code()) + + listAfterDeleteResp, err := iamClient.ListPolicies(&iam.ListPoliciesInput{}) + require.NoError(t, err) + deletedPolicyFound := false + remainingPolicyFound := false + for _, policy := range listAfterDeleteResp.Policies { + if policy.PolicyName == nil { + continue + } + if *policy.PolicyName == policyNames[0] { + deletedPolicyFound = true + } + if *policy.PolicyName == policyNames[1] { + remainingPolicyFound = true + } + } + assert.False(t, deletedPolicyFound, "deleted policy should no longer be listed") + assert.True(t, remainingPolicyFound, "remaining policy should still be listed") + + policyArns[0] = nil + }) + + t.Run("managed_policy_versions", func(t *testing.T) { + policyName := "test-managed-policy-version" + policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"*"}]}` + + createResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String(policyName), + PolicyDocument: aws.String(policyDoc), + }) + require.NoError(t, err) + + t.Cleanup(func() { + _, _ = iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: createResp.Policy.Arn}) + }) + + listVersionsResp, err := iamClient.ListPolicyVersions(&iam.ListPolicyVersionsInput{ + PolicyArn: createResp.Policy.Arn, + }) + require.NoError(t, err) + require.NotEmpty(t, listVersionsResp.Versions) + assert.Equal(t, "v1", aws.StringValue(listVersionsResp.Versions[0].VersionId)) + assert.Equal(t, true, aws.BoolValue(listVersionsResp.Versions[0].IsDefaultVersion)) + + getVersionResp, err := iamClient.GetPolicyVersion(&iam.GetPolicyVersionInput{ + PolicyArn: createResp.Policy.Arn, + VersionId: aws.String("v1"), + }) + require.NoError(t, err) + require.NotNil(t, getVersionResp.PolicyVersion) + assert.Equal(t, "v1", aws.StringValue(getVersionResp.PolicyVersion.VersionId)) + assert.Contains(t, aws.StringValue(getVersionResp.PolicyVersion.Document), "s3:ListBucket") + + _, err = iamClient.GetPolicyVersion(&iam.GetPolicyVersionInput{ + PolicyArn: createResp.Policy.Arn, + VersionId: aws.String("v2"), + }) + require.Error(t, err) + awsErr, ok := err.(awserr.Error) + require.True(t, ok) + assert.Equal(t, iam.ErrCodeNoSuchEntityException, awsErr.Code()) + }) + t.Run("user_inline_policy", func(t *testing.T) { userName := "test-user-policy" _, err := iamClient.CreateUser(&iam.CreateUserInput{ diff --git a/weed/iam/responses.go b/weed/iam/responses.go index 0afd9f6da..1deb7b36c 100644 --- a/weed/iam/responses.go +++ b/weed/iam/responses.go @@ -81,6 +81,26 @@ type GetPolicyResponse struct { } `xml:"GetPolicyResult"` } +// ListPolicyVersionsResponse is the response for ListPolicyVersions action. +type ListPolicyVersionsResponse struct { + CommonResponse + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListPolicyVersionsResponse"` + ListPolicyVersionsResult struct { + Versions []*iam.PolicyVersion `xml:"Versions>member"` + IsTruncated bool `xml:"IsTruncated"` + Marker string `xml:"Marker,omitempty"` + } `xml:"ListPolicyVersionsResult"` +} + +// GetPolicyVersionResponse is the response for GetPolicyVersion action. +type GetPolicyVersionResponse struct { + CommonResponse + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetPolicyVersionResponse"` + GetPolicyVersionResult struct { + PolicyVersion iam.PolicyVersion `xml:"PolicyVersion"` + } `xml:"GetPolicyVersionResult"` +} + // CreateUserResponse is the response for CreateUser action. type CreateUserResponse struct { CommonResponse diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index 886f9d339..f31e624a4 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -75,6 +75,8 @@ type ( iamDeletePolicyResponse = iamlib.DeletePolicyResponse iamListPoliciesResponse = iamlib.ListPoliciesResponse iamGetPolicyResponse = iamlib.GetPolicyResponse + iamListPolicyVersionsResponse = iamlib.ListPolicyVersionsResponse + iamGetPolicyVersionResponse = iamlib.GetPolicyVersionResponse iamCreateUserResponse = iamlib.CreateUserResponse iamDeleteUserResponse = iamlib.DeleteUserResponse iamGetUserResponse = iamlib.GetUserResponse @@ -577,6 +579,82 @@ func (e *EmbeddedIamApi) GetPolicy(ctx context.Context, values url.Values) (iamG return resp, nil } +// ListPolicyVersions lists versions for a managed policy. +// Current SeaweedFS implementation stores one version per policy (v1). +func (e *EmbeddedIamApi) ListPolicyVersions(ctx context.Context, values url.Values) (iamListPolicyVersionsResponse, *iamError) { + var resp iamListPolicyVersionsResponse + policyArn := values.Get("PolicyArn") + policyName, err := iamPolicyNameFromArn(policyArn) + if err != nil { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err} + } + if e.credentialManager == nil { + return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("credential manager not configured")} + } + policy, err := e.credentialManager.GetPolicy(ctx, policyName) + if err != nil { + return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + if policy == nil { + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s not found", policyName)} + } + + versionID := "v1" + isDefaultVersion := true + createDate := time.Now().UTC() + resp.ListPolicyVersionsResult.Versions = []*iam.PolicyVersion{{ + VersionId: &versionID, + IsDefaultVersion: &isDefaultVersion, + CreateDate: &createDate, + }} + resp.ListPolicyVersionsResult.IsTruncated = false + return resp, nil +} + +// GetPolicyVersion returns the document for a specific policy version. +// Current SeaweedFS implementation stores one version per policy (v1). +func (e *EmbeddedIamApi) GetPolicyVersion(ctx context.Context, values url.Values) (iamGetPolicyVersionResponse, *iamError) { + var resp iamGetPolicyVersionResponse + policyArn := values.Get("PolicyArn") + versionID := values.Get("VersionId") + if versionID == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("VersionId is required")} + } + + policyName, err := iamPolicyNameFromArn(policyArn) + if err != nil { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err} + } + if e.credentialManager == nil { + return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("credential manager not configured")} + } + policy, err := e.credentialManager.GetPolicy(ctx, policyName) + if err != nil { + return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + if policy == nil { + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s not found", policyName)} + } + if versionID != "v1" { + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy version %s not found", versionID)} + } + policyDocumentJSON, err := json.Marshal(policy) + if err != nil { + return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + + isDefaultVersion := true + createDate := time.Now().UTC() + document := string(policyDocumentJSON) + resp.GetPolicyVersionResult.PolicyVersion = iam.PolicyVersion{ + VersionId: &versionID, + IsDefaultVersion: &isDefaultVersion, + CreateDate: &createDate, + Document: &document, + } + return resp, nil +} + func iamPolicyNameFromArn(policyArn string) (string, error) { const policyPathDelimiter = ":policy/" idx := strings.Index(policyArn, policyPathDelimiter) @@ -1453,7 +1531,7 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s action := values.Get("Action") if e.readOnly { switch action { - case "ListUsers", "ListAccessKeys", "GetUser", "GetUserPolicy", "ListAttachedUserPolicies", "ListPolicies", "GetPolicy", "ListServiceAccounts", "GetServiceAccount": + case "ListUsers", "ListAccessKeys", "GetUser", "GetUserPolicy", "ListAttachedUserPolicies", "ListPolicies", "GetPolicy", "ListPolicyVersions", "GetPolicyVersion", "ListServiceAccounts", "GetServiceAccount": // Allowed read-only actions default: return nil, &iamError{Code: s3err.GetAPIError(s3err.ErrAccessDenied).Code, Error: fmt.Errorf("IAM write operations are disabled on this server")} @@ -1570,6 +1648,18 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s return nil, iamErr } changed = false + case "ListPolicyVersions": + response, iamErr = e.ListPolicyVersions(ctx, values) + if iamErr != nil { + return nil, iamErr + } + changed = false + case "GetPolicyVersion": + response, iamErr = e.GetPolicyVersion(ctx, values) + if iamErr != nil { + return nil, iamErr + } + changed = false case "SetUserStatus": response, iamErr = e.SetUserStatus(s3cfg, values) if iamErr != nil {