diff --git a/weed/iam/responses.go b/weed/iam/responses.go index 9980d965c..45813f402 100644 --- a/weed/iam/responses.go +++ b/weed/iam/responses.go @@ -183,6 +183,21 @@ type GetPolicyVersionResponse struct { CommonResponse } +// CreatePolicyVersionResponse is the response for CreatePolicyVersion action. +type CreatePolicyVersionResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreatePolicyVersionResponse"` + CreatePolicyVersionResult struct { + PolicyVersion iam.PolicyVersion `xml:"PolicyVersion"` + } `xml:"CreatePolicyVersionResult"` + CommonResponse +} + +// DeletePolicyVersionResponse is the response for DeletePolicyVersion action. +type DeletePolicyVersionResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeletePolicyVersionResponse"` + CommonResponse +} + // CreateUserResponse is the response for CreateUser action. type CreateUserResponse struct { XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateUserResponse"` diff --git a/weed/iamapi/iamapi_management_handlers.go b/weed/iamapi/iamapi_management_handlers.go index 4c6f0faf9..1248b7e74 100644 --- a/weed/iamapi/iamapi_management_handlers.go +++ b/weed/iamapi/iamapi_management_handlers.go @@ -46,6 +46,11 @@ var policyLock = sync.RWMutex{} const policyArnPrefix = "arn:aws:iam:::policy/" +// policyDefaultVersionId is the single managed-policy version SeaweedFS exposes. +// Managed policies are stored as one current document with no version history, +// so the API presents exactly one version ("v1") which is always the default. +const policyDefaultVersionId = "v1" + // parsePolicyArn validates an IAM policy ARN and extracts the policy name. func parsePolicyArn(policyArn string) (string, *IamError) { if !strings.HasPrefix(policyArn, policyArnPrefix) { @@ -618,9 +623,15 @@ func (iama *IamApiServer) GetPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url } policyId := Hash(&policyName) + path := "/" + defaultVersionId := policyDefaultVersionId + isAttachable := true resp.GetPolicyResult.Policy.PolicyName = &policyName resp.GetPolicyResult.Policy.Arn = &policyArn resp.GetPolicyResult.Policy.PolicyId = &policyId + resp.GetPolicyResult.Policy.Path = &path + resp.GetPolicyResult.Policy.DefaultVersionId = &defaultVersionId + resp.GetPolicyResult.Policy.IsAttachable = &isAttachable return resp, nil } @@ -692,6 +703,200 @@ func (iama *IamApiServer) ListPolicies(s3cfg *iam_pb.S3ApiConfiguration, values return resp, nil } +// CreatePolicyVersion replaces a managed policy's document with a new version. +// SeaweedFS keeps a single current document per managed policy (no version +// history), so the new document always becomes version "v1" / the default. This +// is what the AWS Terraform provider calls to update an aws_iam_policy in place. +// https://docs.aws.amazon.com/IAM/latest/APIReference/API_CreatePolicyVersion.html +func (iama *IamApiServer) CreatePolicyVersion(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *CreatePolicyVersionResponse, iamError *IamError) { + resp = &CreatePolicyVersionResponse{} + policyArn := values.Get("PolicyArn") + policyName, iamError := parsePolicyArn(policyArn) + if iamError != nil { + return resp, iamError + } + policyDocumentString := values.Get("PolicyDocument") + if policyDocumentString == "" { + return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("PolicyDocument is required")} + } + // SeaweedFS stores a single, always-default managed policy version. On AWS, + // SetAsDefault=false stages a non-default version without activating it; we + // can't honor that, so reject it rather than silently changing permissions. + if !strings.EqualFold(values.Get("SetAsDefault"), "true") { + return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("SetAsDefault must be true: SeaweedFS stores a single managed policy version")} + } + policyDocument, err := GetPolicyDocument(&policyDocumentString) + if err != nil { + return resp, &IamError{Code: iam.ErrCodeMalformedPolicyDocumentException, Error: err} + } + if _, err := GetActions(&policyDocument); err != nil { + return resp, &IamError{Code: iam.ErrCodeMalformedPolicyDocumentException, Error: err} + } + + policies := Policies{} + if err = iama.s3ApiConfig.GetPolicies(&policies); err != nil && !errors.Is(err, filer_pb.ErrNotFound) { + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + if _, exists := policies.Policies[policyName]; !exists { + return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s not found", policyName)} + } + policies.Policies[policyName] = policyDocument + if err = iama.s3ApiConfig.PutPolicies(&policies); err != nil { + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + + // The denormalized Identity.Actions caches were derived from the old + // document; refresh every identity that has this managed policy attached + // (directly or via group membership) so the new document takes effect. + recomputeActionsForManagedPolicy(iama, s3cfg, &policies, policyName) + + versionId := policyDefaultVersionId + isDefault := true + document := policyDocumentString + resp.CreatePolicyVersionResult.PolicyVersion = iam.PolicyVersion{ + VersionId: &versionId, + IsDefaultVersion: &isDefault, + Document: &document, + } + return resp, nil +} + +// ListPolicyVersions lists the versions of a managed policy. SeaweedFS stores a +// single current document per policy, so exactly one version ("v1", the +// default) is reported. +// https://docs.aws.amazon.com/IAM/latest/APIReference/API_ListPolicyVersions.html +func (iama *IamApiServer) ListPolicyVersions(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *ListPolicyVersionsResponse, iamError *IamError) { + resp = &ListPolicyVersionsResponse{} + policyName, iamError := parsePolicyArn(values.Get("PolicyArn")) + if iamError != nil { + return resp, iamError + } + policies := Policies{} + if err := iama.s3ApiConfig.GetPolicies(&policies); err != nil && !errors.Is(err, filer_pb.ErrNotFound) { + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + if _, exists := policies.Policies[policyName]; !exists { + return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s not found", policyName)} + } + + versionId := policyDefaultVersionId + isDefault := true + resp.ListPolicyVersionsResult.Versions = []*iam.PolicyVersion{{ + VersionId: &versionId, + IsDefaultVersion: &isDefault, + }} + resp.ListPolicyVersionsResult.IsTruncated = false + return resp, nil +} + +// GetPolicyVersion returns the document for a managed policy version. Only the +// single stored version ("v1") exists. +// https://docs.aws.amazon.com/IAM/latest/APIReference/API_GetPolicyVersion.html +func (iama *IamApiServer) GetPolicyVersion(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *GetPolicyVersionResponse, iamError *IamError) { + resp = &GetPolicyVersionResponse{} + policyName, iamError := parsePolicyArn(values.Get("PolicyArn")) + if iamError != nil { + return resp, iamError + } + versionId := values.Get("VersionId") + if versionId == "" { + return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("VersionId is required")} + } + policies := Policies{} + if err := iama.s3ApiConfig.GetPolicies(&policies); err != nil && !errors.Is(err, filer_pb.ErrNotFound) { + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + policyDocument, exists := policies.Policies[policyName] + if !exists { + return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s not found", policyName)} + } + if versionId != policyDefaultVersionId { + return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy version %s not found", versionId)} + } + policyDocumentJSON, err := json.Marshal(policyDocument) + if err != nil { + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + + isDefault := true + document := string(policyDocumentJSON) + resp.GetPolicyVersionResult.PolicyVersion = iam.PolicyVersion{ + VersionId: &versionId, + IsDefaultVersion: &isDefault, + Document: &document, + } + return resp, nil +} + +// DeletePolicyVersion is accepted for API completeness. With a single stored +// version that is always the default, the only valid responses are NoSuchEntity +// (unknown version) and the AWS "cannot delete the default version" conflict. +// https://docs.aws.amazon.com/IAM/latest/APIReference/API_DeletePolicyVersion.html +func (iama *IamApiServer) DeletePolicyVersion(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *DeletePolicyVersionResponse, iamError *IamError) { + resp = &DeletePolicyVersionResponse{} + policyName, iamError := parsePolicyArn(values.Get("PolicyArn")) + if iamError != nil { + return resp, iamError + } + versionId := values.Get("VersionId") + if versionId == "" { + return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("VersionId is required")} + } + policies := Policies{} + if err := iama.s3ApiConfig.GetPolicies(&policies); err != nil && !errors.Is(err, filer_pb.ErrNotFound) { + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + if _, exists := policies.Policies[policyName]; !exists { + return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s not found", policyName)} + } + if versionId == policyDefaultVersionId { + return resp, &IamError{Code: iam.ErrCodeDeleteConflictException, Error: fmt.Errorf("cannot delete the default version of policy %s", policyName)} + } + return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy version %s not found", versionId)} +} + +// recomputeActionsForManagedPolicy refreshes Identity.Actions for every identity +// affected by a change to the managed policy named policyName: users with the +// policy attached directly, and members of groups the policy is attached to. +func recomputeActionsForManagedPolicy(iama *IamApiServer, s3cfg *iam_pb.S3ApiConfiguration, policies *Policies, policyName string) { + affected := make(map[string]*iam_pb.Identity) + identIndex := make(map[string]*iam_pb.Identity, len(s3cfg.Identities)) + for _, ident := range s3cfg.Identities { + identIndex[ident.Name] = ident + for _, pn := range ident.PolicyNames { + if pn == policyName { + affected[ident.Name] = ident + break + } + } + } + for _, g := range s3cfg.Groups { + attached := false + for _, pn := range g.PolicyNames { + if pn == policyName { + attached = true + break + } + } + if !attached { + continue + } + for _, member := range g.Members { + if ident, ok := identIndex[member]; ok { + affected[member] = ident + } + } + } + for name, ident := range affected { + aggregatedActions, err := computeAllActionsForUser(iama, name, policies, ident, s3cfg) + if err != nil { + glog.Warningf("Failed to recompute actions for user %s after managed policy %s change: %v", name, policyName, err) + continue + } + ident.Actions = aggregatedActions + } +} + // AttachUserPolicy attaches a managed policy to a user. func (iama *IamApiServer) AttachUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *AttachUserPolicyResponse, iamError *IamError) { resp = &AttachUserPolicyResponse{} @@ -1246,6 +1451,40 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) { return } changed = false + case "CreatePolicyVersion": + var err *IamError + response, err = iama.CreatePolicyVersion(s3cfg, values) + if err != nil { + writeIamErrorResponse(w, r, reqID, err) + return + } + // CreatePolicyVersion persists the new document via PutPolicies and + // recomputes affected Identity.Actions on s3cfg; keep changed=true so the + // updated identities are saved. + case "ListPolicyVersions": + var err *IamError + response, err = iama.ListPolicyVersions(s3cfg, values) + if err != nil { + writeIamErrorResponse(w, r, reqID, err) + return + } + changed = false + case "GetPolicyVersion": + var err *IamError + response, err = iama.GetPolicyVersion(s3cfg, values) + if err != nil { + writeIamErrorResponse(w, r, reqID, err) + return + } + changed = false + case "DeletePolicyVersion": + var err *IamError + response, err = iama.DeletePolicyVersion(s3cfg, values) + if err != nil { + writeIamErrorResponse(w, r, reqID, err) + return + } + changed = false case "AttachUserPolicy": var err *IamError response, err = iama.AttachUserPolicy(s3cfg, values) diff --git a/weed/iamapi/iamapi_response.go b/weed/iamapi/iamapi_response.go index 00f66c725..d48da843c 100644 --- a/weed/iamapi/iamapi_response.go +++ b/weed/iamapi/iamapi_response.go @@ -27,6 +27,10 @@ type ( GetPolicyResponse = iamlib.GetPolicyResponse DeletePolicyResponse = iamlib.DeletePolicyResponse ListPoliciesResponse = iamlib.ListPoliciesResponse + ListPolicyVersionsResponse = iamlib.ListPolicyVersionsResponse + GetPolicyVersionResponse = iamlib.GetPolicyVersionResponse + CreatePolicyVersionResponse = iamlib.CreatePolicyVersionResponse + DeletePolicyVersionResponse = iamlib.DeletePolicyVersionResponse AttachUserPolicyResponse = iamlib.AttachUserPolicyResponse DetachUserPolicyResponse = iamlib.DetachUserPolicyResponse ListAttachedUserPoliciesResponse = iamlib.ListAttachedUserPoliciesResponse diff --git a/weed/iamapi/iamapi_test.go b/weed/iamapi/iamapi_test.go index bedb0c073..6268e448b 100644 --- a/weed/iamapi/iamapi_test.go +++ b/weed/iamapi/iamapi_test.go @@ -197,6 +197,176 @@ func TestCreatePolicy(t *testing.T) { assert.Equal(t, http.StatusOK, response.Code) } +// isolatedIamConfig is a self-contained IamS3ApiConfig backing whose state lives +// on the instance (not the package globals that executeRequest shares), so +// policy-version tests stay order-independent under -shuffle. +type isolatedIamConfig struct { + identities []*iam_pb.Identity + groups []*iam_pb.Group + policies Policies +} + +func newIsolatedIamServer() *IamApiServer { + return &IamApiServer{s3ApiConfig: &isolatedIamConfig{ + policies: Policies{Policies: make(map[string]policy_engine.PolicyDocument)}, + }} +} + +func (m *isolatedIamConfig) GetS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfiguration) error { + _ = copier.Copy(&s3cfg.Identities, &m.identities) + _ = copier.Copy(&s3cfg.Groups, &m.groups) + return nil +} + +func (m *isolatedIamConfig) PutS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfiguration) error { + _ = copier.Copy(&m.identities, &s3cfg.Identities) + _ = copier.Copy(&m.groups, &s3cfg.Groups) + return nil +} + +func (m *isolatedIamConfig) GetPolicies(policies *Policies) error { + _ = copier.Copy(&policies, &m.policies) + return nil +} + +func (m *isolatedIamConfig) PutPolicies(policies *Policies) error { + _ = copier.Copy(&m.policies, &policies) + return nil +} + +// TestCreatePolicyVersion reproduces issue #9785: the AWS Terraform provider +// updates a managed policy in place via CreatePolicyVersion, which previously +// returned 501 NotImplemented. The whole read/update surface Terraform relies on +// is exercised here: GetPolicy (DefaultVersionId), CreatePolicyVersion, +// GetPolicyVersion and ListPolicyVersions. +func TestCreatePolicyVersion(t *testing.T) { + srv := newIsolatedIamServer() + svc := iam.New(session.New()) + policyName := "tf-managed-policy" + policyArn := aws.String("arn:aws:iam:::policy/" + policyName) + + // Create the managed policy. + createReq, _ := svc.CreatePolicyRequest(&iam.CreatePolicyInput{ + PolicyName: aws.String(policyName), + PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:Get*","s3:List*"],"Resource":["arn:aws:s3:::EXAMPLE-BUCKET"]}]}`), + }) + _ = createReq.Build() + resp, err := executeRequestWith(srv, createReq.HTTPRequest, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code) + + // Update it in place (what Terraform does). This used to return 501. + cpvReq, _ := svc.CreatePolicyVersionRequest(&iam.CreatePolicyVersionInput{ + PolicyArn: policyArn, + PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:Get*"],"Resource":["arn:aws:s3:::EXAMPLE-BUCKET"]}]}`), + SetAsDefault: aws.Bool(true), + }) + _ = cpvReq.Build() + resp, err = executeRequestWith(srv, cpvReq.HTTPRequest, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code) + var cpvResp CreatePolicyVersionResponse + require.NoError(t, xml.Unmarshal(resp.Body.Bytes(), &cpvResp)) + require.NotNil(t, cpvResp.CreatePolicyVersionResult.PolicyVersion.VersionId) + assert.Equal(t, "v1", *cpvResp.CreatePolicyVersionResult.PolicyVersion.VersionId) + require.NotNil(t, cpvResp.CreatePolicyVersionResult.PolicyVersion.IsDefaultVersion) + assert.True(t, *cpvResp.CreatePolicyVersionResult.PolicyVersion.IsDefaultVersion) + + // GetPolicy must advertise a default version so Terraform's read can chain + // into GetPolicyVersion. + gpReq, _ := svc.GetPolicyRequest(&iam.GetPolicyInput{PolicyArn: policyArn}) + _ = gpReq.Build() + resp, err = executeRequestWith(srv, gpReq.HTTPRequest, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code) + var gpResp GetPolicyResponse + require.NoError(t, xml.Unmarshal(resp.Body.Bytes(), &gpResp)) + require.NotNil(t, gpResp.GetPolicyResult.Policy.DefaultVersionId) + assert.Equal(t, "v1", *gpResp.GetPolicyResult.Policy.DefaultVersionId) + + // GetPolicyVersion must return the updated document (Get* only, List* gone). + gpvReq, _ := svc.GetPolicyVersionRequest(&iam.GetPolicyVersionInput{PolicyArn: policyArn, VersionId: aws.String("v1")}) + _ = gpvReq.Build() + resp, err = executeRequestWith(srv, gpvReq.HTTPRequest, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code) + var gpvResp GetPolicyVersionResponse + require.NoError(t, xml.Unmarshal(resp.Body.Bytes(), &gpvResp)) + require.NotNil(t, gpvResp.GetPolicyVersionResult.PolicyVersion.Document) + assert.Contains(t, *gpvResp.GetPolicyVersionResult.PolicyVersion.Document, "s3:Get*") + assert.NotContains(t, *gpvResp.GetPolicyVersionResult.PolicyVersion.Document, "s3:List*") + + // ListPolicyVersions must report exactly the single default version. + lpvReq, _ := svc.ListPolicyVersionsRequest(&iam.ListPolicyVersionsInput{PolicyArn: policyArn}) + _ = lpvReq.Build() + resp, err = executeRequestWith(srv, lpvReq.HTTPRequest, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code) + var lpvResp ListPolicyVersionsResponse + require.NoError(t, xml.Unmarshal(resp.Body.Bytes(), &lpvResp)) + require.Len(t, lpvResp.ListPolicyVersionsResult.Versions, 1) + assert.Equal(t, "v1", *lpvResp.ListPolicyVersionsResult.Versions[0].VersionId) +} + +// TestCreatePolicyVersionMissingPolicy verifies a NoSuchEntity (404) when the +// target policy does not exist, matching AWS. +func TestCreatePolicyVersionMissingPolicy(t *testing.T) { + srv := newIsolatedIamServer() + req, _ := iam.New(session.New()).CreatePolicyVersionRequest(&iam.CreatePolicyVersionInput{ + PolicyArn: aws.String("arn:aws:iam:::policy/does-not-exist"), + PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:Get*"],"Resource":["arn:aws:s3:::EXAMPLE-BUCKET"]}]}`), + SetAsDefault: aws.Bool(true), + }) + _ = req.Build() + resp, err := executeRequestWith(srv, req.HTTPRequest, nil) + require.NoError(t, err) + assert.Equal(t, http.StatusNotFound, resp.Code) + code, _ := extractErrorCodeAndMessage(resp) + assert.Equal(t, "NoSuchEntity", code) +} + +// TestCreatePolicyVersionRequiresSetAsDefault verifies that, given the +// single-version model, a request that does not set SetAsDefault=true is +// rejected rather than silently overwriting the active document. +func TestCreatePolicyVersionRequiresSetAsDefault(t *testing.T) { + srv := newIsolatedIamServer() + svc := iam.New(session.New()) + policyArn := aws.String("arn:aws:iam:::policy/tf-managed-policy") + + createReq, _ := svc.CreatePolicyRequest(&iam.CreatePolicyInput{ + PolicyName: aws.String("tf-managed-policy"), + PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:Get*"],"Resource":["arn:aws:s3:::EXAMPLE-BUCKET"]}]}`), + }) + _ = createReq.Build() + resp, err := executeRequestWith(srv, createReq.HTTPRequest, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code) + + // SetAsDefault omitted (defaults to false) must be rejected. + cpvReq, _ := svc.CreatePolicyVersionRequest(&iam.CreatePolicyVersionInput{ + PolicyArn: policyArn, + PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:Put*"],"Resource":["arn:aws:s3:::EXAMPLE-BUCKET"]}]}`), + }) + _ = cpvReq.Build() + resp, err = executeRequestWith(srv, cpvReq.HTTPRequest, nil) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.Code) + code, _ := extractErrorCodeAndMessage(resp) + assert.Equal(t, "InvalidInput", code) + + // The live document must be untouched (still Get*, not Put*). + gpvReq, _ := svc.GetPolicyVersionRequest(&iam.GetPolicyVersionInput{PolicyArn: policyArn, VersionId: aws.String("v1")}) + _ = gpvReq.Build() + resp, err = executeRequestWith(srv, gpvReq.HTTPRequest, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code) + var gpvResp GetPolicyVersionResponse + require.NoError(t, xml.Unmarshal(resp.Body.Bytes(), &gpvResp)) + require.NotNil(t, gpvResp.GetPolicyVersionResult.PolicyVersion.Document) + assert.Contains(t, *gpvResp.GetPolicyVersionResult.PolicyVersion.Document, "s3:Get*") + assert.NotContains(t, *gpvResp.GetPolicyVersionResult.PolicyVersion.Document, "s3:Put*") +} + func TestPutUserPolicy(t *testing.T) { userName := aws.String("Test") params := &iam.PutUserPolicyInput{ diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index f7808e50f..4078fdf60 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -96,6 +96,8 @@ type ( iamGetPolicyResponse = iamlib.GetPolicyResponse iamListPolicyVersionsResponse = iamlib.ListPolicyVersionsResponse iamGetPolicyVersionResponse = iamlib.GetPolicyVersionResponse + iamCreatePolicyVersionResponse = iamlib.CreatePolicyVersionResponse + iamDeletePolicyVersionResponse = iamlib.DeletePolicyVersionResponse iamCreateUserResponse = iamlib.CreateUserResponse iamDeleteUserResponse = iamlib.DeleteUserResponse iamGetUserResponse = iamlib.GetUserResponse @@ -829,6 +831,87 @@ func (e *EmbeddedIamApi) GetPolicyVersion(ctx context.Context, values url.Values return resp, nil } +// CreatePolicyVersion replaces a managed policy's document with a new version. +// SeaweedFS keeps a single current document per managed policy (no version +// history), so the new document always becomes version "v1" / the default. This +// is what the AWS Terraform provider calls to update an aws_iam_policy in place. +func (e *EmbeddedIamApi) CreatePolicyVersion(ctx context.Context, values url.Values) (*iamCreatePolicyVersionResponse, *iamError) { + resp := &iamCreatePolicyVersionResponse{} + policyName, err := iamPolicyNameFromArn(values.Get("PolicyArn")) + if err != nil { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err} + } + policyDocumentString := values.Get("PolicyDocument") + if policyDocumentString == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("PolicyDocument is required")} + } + // SeaweedFS stores a single, always-default managed policy version. On AWS, + // SetAsDefault=false stages a non-default version without activating it; we + // can't honor that, so reject it rather than silently changing permissions. + if !strings.EqualFold(values.Get("SetAsDefault"), "true") { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("SetAsDefault must be true: SeaweedFS stores a single managed policy version")} + } + if e.credentialManager == nil { + return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("credential manager not configured")} + } + policyDocument, err := e.GetPolicyDocument(&policyDocumentString) + if err != nil { + return resp, &iamError{Code: iam.ErrCodeMalformedPolicyDocumentException, Error: err} + } + if _, err := e.getActions(&policyDocument); err != nil { + return resp, &iamError{Code: iam.ErrCodeMalformedPolicyDocumentException, Error: err} + } + existing, err := e.credentialManager.GetPolicy(ctx, policyName) + if err != nil { + return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + if existing == nil { + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s not found", policyName)} + } + if err := e.credentialManager.UpdatePolicy(ctx, policyName, policyDocument); err != nil { + return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + + versionID := "v1" + isDefaultVersion := true + document := policyDocumentString + resp.CreatePolicyVersionResult.PolicyVersion = iam.PolicyVersion{ + VersionId: &versionID, + IsDefaultVersion: &isDefaultVersion, + Document: &document, + } + return resp, nil +} + +// DeletePolicyVersion is accepted for API completeness. With a single stored +// version that is always the default, the only valid responses are NoSuchEntity +// (unknown version) and the AWS "cannot delete the default version" conflict. +func (e *EmbeddedIamApi) DeletePolicyVersion(ctx context.Context, values url.Values) (*iamDeletePolicyVersionResponse, *iamError) { + resp := &iamDeletePolicyVersionResponse{} + policyName, err := iamPolicyNameFromArn(values.Get("PolicyArn")) + if err != nil { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err} + } + versionID := values.Get("VersionId") + if versionID == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("VersionId is required")} + } + 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.ErrCodeDeleteConflictException, Error: fmt.Errorf("cannot delete the default version of policy %s", policyName)} + } + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy version %s not found", versionID)} +} + func iamPolicyNameFromArn(policyArn string) (string, error) { const policyPathDelimiter = ":policy/" idx := strings.Index(policyArn, policyPathDelimiter) @@ -2680,6 +2763,21 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s return nil, iamErr } changed = false + case "CreatePolicyVersion": + var iamErr *iamError + response, iamErr = e.CreatePolicyVersion(ctx, values) + if iamErr != nil { + glog.Errorf("CreatePolicyVersion: %+v", iamErr.Error) + return nil, iamErr + } + changed = false + case "DeletePolicyVersion": + var iamErr *iamError + response, iamErr = e.DeletePolicyVersion(ctx, values) + if iamErr != nil { + return nil, iamErr + } + changed = false case "SetUserStatus": var iamErr *iamError response, iamErr = e.SetUserStatus(s3cfg, values) @@ -2832,7 +2930,7 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s glog.Errorf("Failed to reload IAM configuration after mutation: %v", err) // Don't fail the request since the persistent save succeeded } - } else if action == "AttachUserPolicy" || action == "DetachUserPolicy" || action == "CreatePolicy" || action == "DeletePolicy" || action == "CreateUser" || action == "PutGroupPolicy" || action == "DeleteGroupPolicy" { + } else if action == "AttachUserPolicy" || action == "DetachUserPolicy" || action == "CreatePolicy" || action == "CreatePolicyVersion" || action == "DeletePolicy" || action == "CreateUser" || action == "PutGroupPolicy" || action == "DeleteGroupPolicy" { // Even if changed=false (persisted via credentialManager), we should still reload // if we are utilizing the local in-memory cache for speed if err := e.ReloadConfiguration(); err != nil { diff --git a/weed/s3api/s3api_embedded_iam_test.go b/weed/s3api/s3api_embedded_iam_test.go index 459d0ec4b..67d709f8d 100644 --- a/weed/s3api/s3api_embedded_iam_test.go +++ b/weed/s3api/s3api_embedded_iam_test.go @@ -416,6 +416,98 @@ func TestEmbeddedIamCreatePolicy(t *testing.T) { assert.NotNil(t, out.CreatePolicyResult.Policy.PolicyId) } +// TestEmbeddedIamCreatePolicyVersion covers the embedded IAM path for issue +// #9785: updating a managed policy in place via CreatePolicyVersion (used by the +// AWS Terraform provider) instead of returning 501 NotImplemented. +func TestEmbeddedIamCreatePolicyVersion(t *testing.T) { + api := NewEmbeddedIamApiForTest() + svc := iam.New(session.New()) + policyArn := aws.String("arn:aws:iam:::policy/tf-managed") + + // Create the managed policy. + createReq, _ := svc.CreatePolicyRequest(&iam.CreatePolicyInput{ + PolicyName: aws.String("tf-managed"), + PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:Get*","s3:List*"],"Resource":["arn:aws:s3:::EXAMPLE-BUCKET"]}]}`), + }) + _ = createReq.Build() + resp, err := executeEmbeddedIamRequest(api, createReq.HTTPRequest, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code) + + // Update it in place. This used to return 501 NotImplemented. + cpvReq, _ := svc.CreatePolicyVersionRequest(&iam.CreatePolicyVersionInput{ + PolicyArn: policyArn, + PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:Get*"],"Resource":["arn:aws:s3:::EXAMPLE-BUCKET"]}]}`), + SetAsDefault: aws.Bool(true), + }) + _ = cpvReq.Build() + out := iamCreatePolicyVersionResponse{} + resp, err = executeEmbeddedIamRequest(api, cpvReq.HTTPRequest, &out) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code) + require.NotNil(t, out.CreatePolicyVersionResult.PolicyVersion.VersionId) + assert.Equal(t, "v1", *out.CreatePolicyVersionResult.PolicyVersion.VersionId) + require.NotNil(t, out.CreatePolicyVersionResult.PolicyVersion.IsDefaultVersion) + assert.True(t, *out.CreatePolicyVersionResult.PolicyVersion.IsDefaultVersion) + + // The stored document must reflect the update. + gpvReq, _ := svc.GetPolicyVersionRequest(&iam.GetPolicyVersionInput{PolicyArn: policyArn, VersionId: aws.String("v1")}) + _ = gpvReq.Build() + gpvOut := iamGetPolicyVersionResponse{} + resp, err = executeEmbeddedIamRequest(api, gpvReq.HTTPRequest, &gpvOut) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code) + require.NotNil(t, gpvOut.GetPolicyVersionResult.PolicyVersion.Document) + assert.Contains(t, *gpvOut.GetPolicyVersionResult.PolicyVersion.Document, "s3:Get*") + assert.NotContains(t, *gpvOut.GetPolicyVersionResult.PolicyVersion.Document, "s3:List*") +} + +// TestEmbeddedIamCreatePolicyVersionMissingPolicy verifies NoSuchEntity when the +// target policy does not exist. +func TestEmbeddedIamCreatePolicyVersionMissingPolicy(t *testing.T) { + api := NewEmbeddedIamApiForTest() + req, _ := iam.New(session.New()).CreatePolicyVersionRequest(&iam.CreatePolicyVersionInput{ + PolicyArn: aws.String("arn:aws:iam:::policy/does-not-exist"), + PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:Get*"],"Resource":["arn:aws:s3:::EXAMPLE-BUCKET"]}]}`), + SetAsDefault: aws.Bool(true), + }) + _ = req.Build() + resp, err := executeEmbeddedIamRequest(api, req.HTTPRequest, nil) + require.NoError(t, err) + assert.Equal(t, http.StatusNotFound, resp.Code) + code, _ := extractEmbeddedIamErrorCodeAndMessage(resp) + assert.Equal(t, "NoSuchEntity", code) +} + +// TestEmbeddedIamCreatePolicyVersionRequiresSetAsDefault verifies the embedded +// path also rejects a non-default version request given the single-version model. +func TestEmbeddedIamCreatePolicyVersionRequiresSetAsDefault(t *testing.T) { + api := NewEmbeddedIamApiForTest() + svc := iam.New(session.New()) + policyArn := aws.String("arn:aws:iam:::policy/tf-managed") + + createReq, _ := svc.CreatePolicyRequest(&iam.CreatePolicyInput{ + PolicyName: aws.String("tf-managed"), + PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:Get*"],"Resource":["arn:aws:s3:::EXAMPLE-BUCKET"]}]}`), + }) + _ = createReq.Build() + resp, err := executeEmbeddedIamRequest(api, createReq.HTTPRequest, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code) + + // SetAsDefault omitted must be rejected. + cpvReq, _ := svc.CreatePolicyVersionRequest(&iam.CreatePolicyVersionInput{ + PolicyArn: policyArn, + PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:Put*"],"Resource":["arn:aws:s3:::EXAMPLE-BUCKET"]}]}`), + }) + _ = cpvReq.Build() + resp, err = executeEmbeddedIamRequest(api, cpvReq.HTTPRequest, nil) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.Code) + code, _ := extractEmbeddedIamErrorCodeAndMessage(resp) + assert.Equal(t, "InvalidInput", code) +} + // TestEmbeddedIamPutUserPolicy tests attaching a policy to a user func TestEmbeddedIamPutUserPolicy(t *testing.T) { api := NewEmbeddedIamApiForTest()