mirror of
https://github.com/versity/versitygw.git
synced 2026-07-21 23:42:26 +00:00
feat: add IAM user inline policy CRUD
Add support for AWS-compatible inline identity-based policies on IAM users, implementing the `PutUserPolicy`, `GetUserPolicy`, `DeleteUserPolicy`, and `ListUserPolicies` actions on both the internal and Vault storage backends. - iamapi/policy is a new package that parses and validates policy documents against IAM's parameter-level constraints (max length, allowed charset) and policy grammar (Version, Effect, mutually exclusive Action/NotAction and Resource/NotResource, vendor-prefixed actions, ARN-shaped resources, no Principal/NotPrincipal, unique Sids). - `PutUserPolicy` creates or replaces a named inline policy on a user, enforcing a 2048-byte aggregate quota across all of a user's inline policies (MaxInlinePolicyBytesPerUser), matching the AWS IAM quota. - `GetUserPolicy` returns a policy's document RFC 3986 percent-encoded, matching how real IAM encodes the PolicyDocument response element. - `DeleteUserPolicy` removes a named inline policy from a user. - `ListUserPolicies` returns a paginated, sorted list of a user's inline policy names, honoring Marker/MaxItems like the other IAM list APIs. - `DeleteUser` is now rejected with a DeleteConflict error if the user still has inline policies attached, mirroring the existing access-key delete-conflict behavior.
This commit is contained in:
+154
-62
@@ -17,13 +17,13 @@ package iamapi
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
"github.com/versity/versitygw/iamapi/iamerr"
|
||||
"github.com/versity/versitygw/iamapi/internal/iamutil"
|
||||
"github.com/versity/versitygw/iamapi/policy"
|
||||
"github.com/versity/versitygw/iamapi/storage"
|
||||
"github.com/versity/versitygw/iamapi/types"
|
||||
)
|
||||
@@ -37,12 +37,8 @@ func NewController(store storage.Storer) IAMApiController {
|
||||
}
|
||||
|
||||
func (c IAMApiController) CreateUser(ctx fiber.Ctx) (*Response, error) {
|
||||
userName, ok := iamutil.RequestParam(ctx, "UserName")
|
||||
if !ok {
|
||||
debuglogger.Logf("missing required CreateUser parameter: UserName")
|
||||
return nil, iamerr.GetAPIError(iamerr.ErrMissingUserNameValue)
|
||||
}
|
||||
if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserNameLen); err != nil {
|
||||
userName, err := iamutil.GetUserName(ctx, "CreateUser", iamutil.MaxUserNameLen, iamerr.MissingValue("userName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -95,12 +91,8 @@ func (c IAMApiController) CreateUser(ctx fiber.Ctx) (*Response, error) {
|
||||
}
|
||||
|
||||
func (c IAMApiController) DeleteUser(ctx fiber.Ctx) (*Response, error) {
|
||||
username, ok := iamutil.RequestParam(ctx, "UserName")
|
||||
if !ok || username == "" {
|
||||
debuglogger.Logf("missing required DeleteUser parameter: UserName")
|
||||
return nil, iamerr.MissingParameter("UserName")
|
||||
}
|
||||
if err := iamutil.ValidateUserName("userName", username, iamutil.MaxUserLookupLen); err != nil {
|
||||
username, err := iamutil.GetUserName(ctx, "DeleteUser", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -126,7 +118,7 @@ func (c IAMApiController) GetUser(ctx fiber.Ctx) (*Response, error) {
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
if err := iamutil.ValidateUserName("userName", username, iamutil.MaxUserLookupLen); err != nil {
|
||||
if err := iamutil.ValidateName("userName", username, iamutil.MaxUserLookupLen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -150,14 +142,9 @@ func (c IAMApiController) ListUsers(ctx fiber.Ctx) (*Response, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maxItems := int32(iamutil.DefaultMaxItems)
|
||||
if rawMaxItems, ok := iamutil.RequestParam(ctx, "MaxItems"); ok && rawMaxItems != "" {
|
||||
parsed, err := strconv.ParseInt(rawMaxItems, 10, 32)
|
||||
if err != nil || parsed < 1 || parsed > iamutil.MaxListItems {
|
||||
debuglogger.Logf("invalid ListUsers MaxItems value %q: parse_error=%v", rawMaxItems, err)
|
||||
return nil, iamerr.InvalidMaxItems(rawMaxItems)
|
||||
}
|
||||
maxItems = int32(parsed)
|
||||
maxItems, err := iamutil.ParseMaxItems(ctx, "ListUsers")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
marker, _ := iamutil.RequestParam(ctx, "Marker")
|
||||
@@ -181,12 +168,8 @@ func (c IAMApiController) ListUsers(ctx fiber.Ctx) (*Response, error) {
|
||||
}
|
||||
|
||||
func (c IAMApiController) UpdateUser(ctx fiber.Ctx) (*Response, error) {
|
||||
username, ok := iamutil.RequestParam(ctx, "UserName")
|
||||
if !ok || username == "" {
|
||||
debuglogger.Logf("missing required UpdateUser parameter: UserName")
|
||||
return nil, iamerr.MissingParameter("UserName")
|
||||
}
|
||||
if err := iamutil.ValidateUserName("userName", username, iamutil.MaxUserLookupLen); err != nil {
|
||||
username, err := iamutil.GetUserName(ctx, "UpdateUser", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -198,7 +181,7 @@ func (c IAMApiController) UpdateUser(ctx fiber.Ctx) (*Response, error) {
|
||||
}
|
||||
newUserName, _ := iamutil.RequestParam(ctx, "NewUserName")
|
||||
if newUserName != "" {
|
||||
if err := iamutil.ValidateUserName("newUserName", newUserName, iamutil.MaxUserNameLen); err != nil {
|
||||
if err := iamutil.ValidateName("newUserName", newUserName, iamutil.MaxUserNameLen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -235,12 +218,8 @@ func (c IAMApiController) UpdateUser(ctx fiber.Ctx) (*Response, error) {
|
||||
}
|
||||
|
||||
func (c IAMApiController) CreateAccessKey(ctx fiber.Ctx) (*Response, error) {
|
||||
userName, ok := iamutil.RequestParam(ctx, "UserName")
|
||||
if !ok || userName == "" {
|
||||
debuglogger.Logf("missing required CreateAccessKey parameter: UserName")
|
||||
return nil, iamerr.MissingParameter("UserName")
|
||||
}
|
||||
if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil {
|
||||
userName, err := iamutil.GetUserName(ctx, "CreateAccessKey", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -277,18 +256,14 @@ func (c IAMApiController) CreateAccessKey(ctx fiber.Ctx) (*Response, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
err := fmt.Errorf("generate IAM access key id: exhausted collision retries")
|
||||
err = fmt.Errorf("generate IAM access key id: exhausted collision retries")
|
||||
debuglogger.Logf("failed to create IAM access key for user %q: %v", userName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (c IAMApiController) UpdateAccessKey(ctx fiber.Ctx) (*Response, error) {
|
||||
userName, ok := iamutil.RequestParam(ctx, "UserName")
|
||||
if !ok || userName == "" {
|
||||
debuglogger.Logf("missing required UpdateAccessKey parameter: UserName")
|
||||
return nil, iamerr.MissingParameter("UserName")
|
||||
}
|
||||
if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil {
|
||||
userName, err := iamutil.GetUserName(ctx, "UpdateAccessKey", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -323,12 +298,8 @@ func (c IAMApiController) UpdateAccessKey(ctx fiber.Ctx) (*Response, error) {
|
||||
}
|
||||
|
||||
func (c IAMApiController) DeleteAccessKey(ctx fiber.Ctx) (*Response, error) {
|
||||
userName, ok := iamutil.RequestParam(ctx, "UserName")
|
||||
if !ok || userName == "" {
|
||||
debuglogger.Logf("missing required DeleteAccessKey parameter: UserName")
|
||||
return nil, iamerr.MissingParameter("UserName")
|
||||
}
|
||||
if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil {
|
||||
userName, err := iamutil.GetUserName(ctx, "DeleteAccessKey", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -392,23 +363,14 @@ func (c IAMApiController) GetAccessKeyLastUsed(ctx fiber.Ctx) (*Response, error)
|
||||
}
|
||||
|
||||
func (c IAMApiController) ListAccessKeys(ctx fiber.Ctx) (*Response, error) {
|
||||
userName, ok := iamutil.RequestParam(ctx, "UserName")
|
||||
if !ok || userName == "" {
|
||||
debuglogger.Logf("missing required ListAccessKeys parameter: UserName")
|
||||
return nil, iamerr.MissingParameter("UserName")
|
||||
}
|
||||
if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil {
|
||||
userName, err := iamutil.GetUserName(ctx, "ListAccessKeys", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maxItems := int32(iamutil.DefaultMaxItems)
|
||||
if rawMaxItems, ok := iamutil.RequestParam(ctx, "MaxItems"); ok && rawMaxItems != "" {
|
||||
parsed, err := strconv.ParseInt(rawMaxItems, 10, 32)
|
||||
if err != nil || parsed < 1 || parsed > iamutil.MaxListItems {
|
||||
debuglogger.Logf("invalid ListAccessKeys MaxItems value %q: parse_error=%v", rawMaxItems, err)
|
||||
return nil, iamerr.InvalidMaxItems(rawMaxItems)
|
||||
}
|
||||
maxItems = int32(parsed)
|
||||
maxItems, err := iamutil.ParseMaxItems(ctx, "ListAccessKeys")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
marker, _ := iamutil.RequestParam(ctx, "Marker")
|
||||
@@ -430,3 +392,133 @@ func (c IAMApiController) ListAccessKeys(ctx fiber.Ctx) (*Response, error) {
|
||||
},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (c IAMApiController) PutUserPolicy(ctx fiber.Ctx) (*Response, error) {
|
||||
policyDocument, ok := iamutil.RequestParam(ctx, "PolicyDocument")
|
||||
if !ok {
|
||||
debuglogger.Logf("missing required PutUserPolicy parameter: PolicyDocument")
|
||||
return nil, iamerr.MissingValue("policyDocument")
|
||||
}
|
||||
if err := policy.Validate("policyDocument", policyDocument); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
policyName, ok := iamutil.RequestParam(ctx, "PolicyName")
|
||||
if !ok {
|
||||
debuglogger.Logf("missing required PutUserPolicy parameter: PolicyName")
|
||||
return nil, iamerr.MissingValue("policyName")
|
||||
}
|
||||
if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userName, err := iamutil.GetUserName(ctx, "PutUserPolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Confirm the user exists before inspecting policy document content
|
||||
if _, err := c.store.GetUser(ctx.Context(), userName); err != nil {
|
||||
debuglogger.Logf("failed to get IAM user %q for PutUserPolicy: %v", userName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := policy.Parse(policyDocument); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := c.store.PutUserPolicy(ctx.Context(), storage.PutUserPolicyInput{
|
||||
UserName: userName,
|
||||
PolicyName: policyName,
|
||||
PolicyDocument: policyDocument,
|
||||
}); err != nil {
|
||||
debuglogger.Logf("failed to put IAM user policy %q for user %q: %v", policyName, userName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Response{Data: &types.PutUserPolicyResponse{}}, nil
|
||||
}
|
||||
|
||||
func (c IAMApiController) GetUserPolicy(ctx fiber.Ctx) (*Response, error) {
|
||||
policyName, ok := iamutil.RequestParam(ctx, "PolicyName")
|
||||
if !ok {
|
||||
debuglogger.Logf("missing required GetUserPolicy parameter: PolicyName")
|
||||
return nil, iamerr.MissingValue("policyName")
|
||||
}
|
||||
if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userName, err := iamutil.GetUserName(ctx, "GetUserPolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entry, err := c.store.GetUserPolicy(ctx.Context(), userName, policyName)
|
||||
if err != nil {
|
||||
debuglogger.Logf("failed to get IAM user policy %q for user %q: %v", policyName, userName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Response{Data: &types.GetUserPolicyResponse{
|
||||
Result: types.GetUserPolicyResult{
|
||||
UserName: userName,
|
||||
PolicyName: entry.PolicyName,
|
||||
PolicyDocument: iamutil.EncodePolicyDocument(entry.PolicyDocument),
|
||||
},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (c IAMApiController) DeleteUserPolicy(ctx fiber.Ctx) (*Response, error) {
|
||||
policyName, ok := iamutil.RequestParam(ctx, "PolicyName")
|
||||
if !ok {
|
||||
debuglogger.Logf("missing required DeleteUserPolicy parameter: PolicyName")
|
||||
return nil, iamerr.MissingValue("policyName")
|
||||
}
|
||||
if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userName, err := iamutil.GetUserName(ctx, "DeleteUserPolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := c.store.DeleteUserPolicy(ctx.Context(), userName, policyName); err != nil {
|
||||
debuglogger.Logf("failed to delete IAM user policy %q for user %q: %v", policyName, userName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Response{Data: &types.DeleteUserPolicyResponse{}}, nil
|
||||
}
|
||||
|
||||
func (c IAMApiController) ListUserPolicies(ctx fiber.Ctx) (*Response, error) {
|
||||
userName, err := iamutil.GetUserName(ctx, "ListUserPolicies", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maxItems, err := iamutil.ParseMaxItems(ctx, "ListUserPolicies")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
marker, _ := iamutil.RequestParam(ctx, "Marker")
|
||||
out, err := c.store.ListUserPolicies(ctx.Context(), storage.ListUserPoliciesInput{
|
||||
UserName: userName,
|
||||
Marker: marker,
|
||||
MaxItems: maxItems,
|
||||
})
|
||||
if err != nil {
|
||||
debuglogger.Logf("failed to list IAM user policies for user %q: %v", userName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Response{Data: &types.ListUserPoliciesResponse{
|
||||
Result: types.ListUserPoliciesResult{
|
||||
PolicyNames: types.PolicyNameList{Members: out.PolicyNames},
|
||||
IsTruncated: out.IsTruncated,
|
||||
Marker: out.Marker,
|
||||
},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/versity/versitygw/iamapi/internal/iammiddleware"
|
||||
"github.com/versity/versitygw/iamapi/internal/iamutil"
|
||||
"github.com/versity/versitygw/iamapi/storage"
|
||||
@@ -478,6 +479,355 @@ func TestIAMApiControllerUpdateUserAlreadyExists(t *testing.T) {
|
||||
requireIAMError(t, resp, http.StatusConflict, "Sender", "EntityAlreadyExists", "User with name zoe already exists.")
|
||||
}
|
||||
|
||||
func TestIAMApiControllerUserPolicyLifecycle(t *testing.T) {
|
||||
server := newIAMControllerTestServer(t)
|
||||
|
||||
createUser := doIAMAction(t, server, url.Values{
|
||||
"Action": {"CreateUser"},
|
||||
"UserName": {"alice"},
|
||||
})
|
||||
if createUser.StatusCode != http.StatusOK {
|
||||
t.Fatalf("CreateUser status = %d, body=%s", createUser.StatusCode, readBody(t, createUser))
|
||||
}
|
||||
|
||||
policyDoc := `{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"}]}`
|
||||
|
||||
put := doIAMAction(t, server, url.Values{
|
||||
"Action": {"PutUserPolicy"},
|
||||
"UserName": {"alice"},
|
||||
"PolicyName": {"ReadOnly"},
|
||||
"PolicyDocument": {policyDoc},
|
||||
})
|
||||
if put.StatusCode != http.StatusOK {
|
||||
t.Fatalf("PutUserPolicy status = %d, body=%s", put.StatusCode, readBody(t, put))
|
||||
}
|
||||
var putOut iamtypes.PutUserPolicyResponse
|
||||
unmarshalXML(t, readBody(t, put), &putOut)
|
||||
if putOut.XMLName.Space != "https://iam.amazonaws.com/doc/2010-05-08/" || putOut.XMLName.Local != "PutUserPolicyResponse" {
|
||||
t.Fatalf("PutUserPolicy XMLName = %#v", putOut.XMLName)
|
||||
}
|
||||
if putOut.ResponseMetadata.RequestID == "" {
|
||||
t.Fatal("PutUserPolicy missing RequestId")
|
||||
}
|
||||
|
||||
get := doIAMAction(t, server, url.Values{
|
||||
"Action": {"GetUserPolicy"},
|
||||
"UserName": {"alice"},
|
||||
"PolicyName": {"ReadOnly"},
|
||||
})
|
||||
if get.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GetUserPolicy status = %d, body=%s", get.StatusCode, readBody(t, get))
|
||||
}
|
||||
var getOut iamtypes.GetUserPolicyResponse
|
||||
unmarshalXML(t, readBody(t, get), &getOut)
|
||||
if getOut.Result.UserName != "alice" || getOut.Result.PolicyName != "ReadOnly" {
|
||||
t.Fatalf("GetUserPolicy result = %#v", getOut.Result)
|
||||
}
|
||||
if !strings.Contains(getOut.Result.PolicyDocument, "%20") {
|
||||
t.Fatalf("GetUserPolicy PolicyDocument = %q, want RFC 3986 percent-encoding (%%20 for space)", getOut.Result.PolicyDocument)
|
||||
}
|
||||
decoded, err := url.QueryUnescape(getOut.Result.PolicyDocument)
|
||||
if err != nil {
|
||||
t.Fatalf("QueryUnescape: %v", err)
|
||||
}
|
||||
if decoded != policyDoc {
|
||||
t.Fatalf("GetUserPolicy PolicyDocument = %q, want verbatim %q", decoded, policyDoc)
|
||||
}
|
||||
|
||||
list := doIAMAction(t, server, url.Values{
|
||||
"Action": {"ListUserPolicies"},
|
||||
"UserName": {"alice"},
|
||||
})
|
||||
if list.StatusCode != http.StatusOK {
|
||||
t.Fatalf("ListUserPolicies status = %d, body=%s", list.StatusCode, readBody(t, list))
|
||||
}
|
||||
var listOut iamtypes.ListUserPoliciesResponse
|
||||
unmarshalXML(t, readBody(t, list), &listOut)
|
||||
if len(listOut.Result.PolicyNames.Members) != 1 || listOut.Result.PolicyNames.Members[0] != "ReadOnly" {
|
||||
t.Fatalf("ListUserPolicies = %#v, want [ReadOnly]", listOut.Result.PolicyNames.Members)
|
||||
}
|
||||
if listOut.Result.IsTruncated {
|
||||
t.Fatal("ListUserPolicies IsTruncated = true, want false")
|
||||
}
|
||||
|
||||
// Re-Put-ing the same PolicyName replaces it rather than erroring or
|
||||
// stacking toward the aggregate size quota.
|
||||
overwritePut := doIAMAction(t, server, url.Values{
|
||||
"Action": {"PutUserPolicy"},
|
||||
"UserName": {"alice"},
|
||||
"PolicyName": {"ReadOnly"},
|
||||
"PolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`},
|
||||
})
|
||||
if overwritePut.StatusCode != http.StatusOK {
|
||||
t.Fatalf("overwrite PutUserPolicy status = %d, body=%s", overwritePut.StatusCode, readBody(t, overwritePut))
|
||||
}
|
||||
overwriteGet := doIAMAction(t, server, url.Values{
|
||||
"Action": {"GetUserPolicy"},
|
||||
"UserName": {"alice"},
|
||||
"PolicyName": {"ReadOnly"},
|
||||
})
|
||||
var overwriteOut iamtypes.GetUserPolicyResponse
|
||||
unmarshalXML(t, readBody(t, overwriteGet), &overwriteOut)
|
||||
overwriteDecoded, err := url.QueryUnescape(overwriteOut.Result.PolicyDocument)
|
||||
if err != nil {
|
||||
t.Fatalf("QueryUnescape: %v", err)
|
||||
}
|
||||
if !strings.Contains(overwriteDecoded, "Deny") {
|
||||
t.Fatalf("GetUserPolicy after overwrite = %q, want the Deny statement", overwriteDecoded)
|
||||
}
|
||||
|
||||
del := doIAMAction(t, server, url.Values{
|
||||
"Action": {"DeleteUserPolicy"},
|
||||
"UserName": {"alice"},
|
||||
"PolicyName": {"ReadOnly"},
|
||||
})
|
||||
if del.StatusCode != http.StatusOK {
|
||||
t.Fatalf("DeleteUserPolicy status = %d, body=%s", del.StatusCode, readBody(t, del))
|
||||
}
|
||||
var delOut iamtypes.DeleteUserPolicyResponse
|
||||
unmarshalXML(t, readBody(t, del), &delOut)
|
||||
if delOut.XMLName.Local != "DeleteUserPolicyResponse" || delOut.ResponseMetadata.RequestID == "" {
|
||||
t.Fatalf("DeleteUserPolicy output = %#v", delOut)
|
||||
}
|
||||
|
||||
missing := doIAMAction(t, server, url.Values{
|
||||
"Action": {"GetUserPolicy"},
|
||||
"UserName": {"alice"},
|
||||
"PolicyName": {"ReadOnly"},
|
||||
})
|
||||
requireIAMError(t, missing, http.StatusNotFound, "Sender", "NoSuchEntity", "The user policy with name ReadOnly cannot be found.")
|
||||
|
||||
// A second delete of the same (now-gone) policy is a hard error, not an
|
||||
// idempotent success.
|
||||
doubleDelete := doIAMAction(t, server, url.Values{
|
||||
"Action": {"DeleteUserPolicy"},
|
||||
"UserName": {"alice"},
|
||||
"PolicyName": {"ReadOnly"},
|
||||
})
|
||||
requireIAMError(t, doubleDelete, http.StatusNotFound, "Sender", "NoSuchEntity", "The user policy with name ReadOnly cannot be found.")
|
||||
}
|
||||
|
||||
func TestIAMApiControllerUserPolicyValidationErrors(t *testing.T) {
|
||||
validDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`
|
||||
oversizedDoc := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 2000) + `","Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupUser bool
|
||||
params url.Values
|
||||
status int
|
||||
code string
|
||||
message string
|
||||
}{
|
||||
{
|
||||
name: "put missing policy document",
|
||||
setupUser: true,
|
||||
params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}},
|
||||
status: http.StatusBadRequest,
|
||||
code: "ValidationError",
|
||||
message: "1 validation error detected: Value at 'policyDocument' failed to satisfy constraint: Member must not be null",
|
||||
},
|
||||
{
|
||||
name: "put missing policy name",
|
||||
setupUser: true,
|
||||
params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyDocument": {validDoc}},
|
||||
status: http.StatusBadRequest,
|
||||
code: "ValidationError",
|
||||
message: "1 validation error detected: Value at 'policyName' failed to satisfy constraint: Member must not be null",
|
||||
},
|
||||
{
|
||||
name: "put missing user name",
|
||||
params: url.Values{"Action": {"PutUserPolicy"}, "PolicyName": {"P"}, "PolicyDocument": {validDoc}},
|
||||
status: http.StatusBadRequest,
|
||||
code: "ValidationError",
|
||||
message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must not be null",
|
||||
},
|
||||
{
|
||||
name: "put invalid policy name characters",
|
||||
setupUser: true,
|
||||
params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"bad/name"}, "PolicyDocument": {validDoc}},
|
||||
status: http.StatusBadRequest,
|
||||
code: "ValidationError",
|
||||
message: "The specified value for policyName is invalid. It must contain only alphanumeric characters and/or the following: +=,.@_-",
|
||||
},
|
||||
{
|
||||
name: "put long policy name",
|
||||
setupUser: true,
|
||||
params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {strings.Repeat("p", 129)}, "PolicyDocument": {validDoc}},
|
||||
status: http.StatusBadRequest,
|
||||
code: "ValidationError",
|
||||
message: "1 validation error detected: Value at 'policyName' failed to satisfy constraint: Member must have length less than or equal to 128",
|
||||
},
|
||||
{
|
||||
name: "put non-ascii policy document",
|
||||
setupUser: true,
|
||||
params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}, "PolicyDocument": {"emoji\U0001F600test"}},
|
||||
status: http.StatusBadRequest,
|
||||
code: "ValidationError",
|
||||
message: "The specified value for policyDocument is invalid. It must contain only printable ASCII characters.",
|
||||
},
|
||||
{
|
||||
name: "put user does not exist",
|
||||
params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"nonexistent"}, "PolicyName": {"P"}, "PolicyDocument": {validDoc}},
|
||||
status: http.StatusNotFound,
|
||||
code: "NoSuchEntity",
|
||||
message: "The user with name nonexistent cannot be found.",
|
||||
},
|
||||
{
|
||||
name: "put nonexistent user wins over malformed document",
|
||||
params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"nonexistent"}, "PolicyName": {"P"}, "PolicyDocument": {"{not valid json"}},
|
||||
status: http.StatusNotFound,
|
||||
code: "NoSuchEntity",
|
||||
message: "The user with name nonexistent cannot be found.",
|
||||
},
|
||||
{
|
||||
name: "put malformed policy document",
|
||||
setupUser: true,
|
||||
params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}, "PolicyDocument": {"{not valid json"}},
|
||||
status: http.StatusBadRequest,
|
||||
code: "MalformedPolicyDocument",
|
||||
message: "Syntax errors in policy.",
|
||||
},
|
||||
{
|
||||
name: "put policy document with principal",
|
||||
setupUser: true,
|
||||
params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}, "PolicyDocument": {
|
||||
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"*"}]}`,
|
||||
}},
|
||||
status: http.StatusBadRequest,
|
||||
code: "MalformedPolicyDocument",
|
||||
message: "Policy document should not specify a principal.",
|
||||
},
|
||||
{
|
||||
name: "put policy document exceeds aggregate size quota",
|
||||
setupUser: true,
|
||||
params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}, "PolicyDocument": {oversizedDoc}},
|
||||
status: http.StatusConflict,
|
||||
code: "LimitExceeded",
|
||||
message: "Maximum policy size of 2048 bytes exceeded for user alice",
|
||||
},
|
||||
{
|
||||
name: "get user does not exist",
|
||||
params: url.Values{"Action": {"GetUserPolicy"}, "UserName": {"nonexistent"}, "PolicyName": {"P"}},
|
||||
status: http.StatusNotFound,
|
||||
code: "NoSuchEntity",
|
||||
message: "The user with name nonexistent cannot be found.",
|
||||
},
|
||||
{
|
||||
name: "get policy does not exist",
|
||||
setupUser: true,
|
||||
params: url.Values{"Action": {"GetUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"NoSuchPolicy"}},
|
||||
status: http.StatusNotFound,
|
||||
code: "NoSuchEntity",
|
||||
message: "The user policy with name NoSuchPolicy cannot be found.",
|
||||
},
|
||||
{
|
||||
name: "delete user does not exist",
|
||||
params: url.Values{"Action": {"DeleteUserPolicy"}, "UserName": {"nonexistent"}, "PolicyName": {"P"}},
|
||||
status: http.StatusNotFound,
|
||||
code: "NoSuchEntity",
|
||||
message: "The user with name nonexistent cannot be found.",
|
||||
},
|
||||
{
|
||||
name: "delete policy does not exist",
|
||||
setupUser: true,
|
||||
params: url.Values{"Action": {"DeleteUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"NoSuchPolicy"}},
|
||||
status: http.StatusNotFound,
|
||||
code: "NoSuchEntity",
|
||||
message: "The user policy with name NoSuchPolicy cannot be found.",
|
||||
},
|
||||
{
|
||||
name: "list user does not exist",
|
||||
params: url.Values{"Action": {"ListUserPolicies"}, "UserName": {"nonexistent"}},
|
||||
status: http.StatusNotFound,
|
||||
code: "NoSuchEntity",
|
||||
message: "The user with name nonexistent cannot be found.",
|
||||
},
|
||||
{
|
||||
name: "list max items too large",
|
||||
setupUser: true,
|
||||
params: url.Values{"Action": {"ListUserPolicies"}, "UserName": {"alice"}, "MaxItems": {"1001"}},
|
||||
status: http.StatusBadRequest,
|
||||
code: "ValidationError",
|
||||
message: "1 validation error detected: Value '1001' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := newIAMControllerTestServer(t)
|
||||
if tt.setupUser {
|
||||
resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp))
|
||||
}
|
||||
}
|
||||
resp := doIAMAction(t, server, tt.params)
|
||||
requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIAMApiControllerPutUserPolicyOversizedDocument(t *testing.T) {
|
||||
// A >131072 byte PolicyDocument does not fit in a GET query string
|
||||
// against this test server's header/URL read-buffer limit, matching
|
||||
// real IAM's own guidance to use POST rather than GET for large
|
||||
// policy documents - so this one case is exercised over POST directly
|
||||
// rather than through the doIAMAction GET helper used elsewhere.
|
||||
server := newIAMControllerTestServer(t)
|
||||
create := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}})
|
||||
if create.StatusCode != http.StatusOK {
|
||||
t.Fatalf("CreateUser status = %d, body=%s", create.StatusCode, readBody(t, create))
|
||||
}
|
||||
|
||||
resp := doIAMActionPost(t, server, url.Values{
|
||||
"Action": {"PutUserPolicy"},
|
||||
"UserName": {"alice"},
|
||||
"PolicyName": {"P"},
|
||||
"PolicyDocument": {strings.Repeat("x", 131073)},
|
||||
})
|
||||
requireIAMError(t, resp, http.StatusBadRequest, "Sender", "ValidationError",
|
||||
"1 validation error detected: Value at 'policyDocument' failed to satisfy constraint: Member must have length less than or equal to 131072")
|
||||
}
|
||||
|
||||
func TestIAMApiControllerDeleteUserPolicyConflict(t *testing.T) {
|
||||
server := newIAMControllerTestServer(t)
|
||||
|
||||
create := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}})
|
||||
if create.StatusCode != http.StatusOK {
|
||||
t.Fatalf("CreateUser status = %d, body=%s", create.StatusCode, readBody(t, create))
|
||||
}
|
||||
put := doIAMAction(t, server, url.Values{
|
||||
"Action": {"PutUserPolicy"},
|
||||
"UserName": {"alice"},
|
||||
"PolicyName": {"P"},
|
||||
"PolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`},
|
||||
})
|
||||
if put.StatusCode != http.StatusOK {
|
||||
t.Fatalf("PutUserPolicy status = %d, body=%s", put.StatusCode, readBody(t, put))
|
||||
}
|
||||
|
||||
deletePolicyOnly := doIAMAction(t, server, url.Values{"Action": {"DeleteUser"}, "UserName": {"alice"}})
|
||||
requireIAMError(t, deletePolicyOnly, http.StatusConflict, "Sender", "DeleteConflict", "Cannot delete entity, must delete policies first.")
|
||||
|
||||
// When both an access key and a policy are attached, the policy
|
||||
// conflict is reported first.
|
||||
createKey := doIAMAction(t, server, url.Values{"Action": {"CreateAccessKey"}, "UserName": {"alice"}})
|
||||
if createKey.StatusCode != http.StatusOK {
|
||||
t.Fatalf("CreateAccessKey status = %d, body=%s", createKey.StatusCode, readBody(t, createKey))
|
||||
}
|
||||
deleteBoth := doIAMAction(t, server, url.Values{"Action": {"DeleteUser"}, "UserName": {"alice"}})
|
||||
requireIAMError(t, deleteBoth, http.StatusConflict, "Sender", "DeleteConflict", "Cannot delete entity, must delete policies first.")
|
||||
|
||||
delPolicy := doIAMAction(t, server, url.Values{"Action": {"DeleteUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}})
|
||||
if delPolicy.StatusCode != http.StatusOK {
|
||||
t.Fatalf("DeleteUserPolicy status = %d, body=%s", delPolicy.StatusCode, readBody(t, delPolicy))
|
||||
}
|
||||
|
||||
deleteKeyOnly := doIAMAction(t, server, url.Values{"Action": {"DeleteUser"}, "UserName": {"alice"}})
|
||||
requireIAMError(t, deleteKeyOnly, http.StatusConflict, "Sender", "DeleteConflict", "Cannot delete entity, must delete access keys first.")
|
||||
}
|
||||
|
||||
func newIAMControllerTestServer(t *testing.T) *IAMApiServer {
|
||||
t.Helper()
|
||||
|
||||
@@ -506,6 +856,25 @@ func doIAMAction(t *testing.T, server *IAMApiServer, params url.Values) *http.Re
|
||||
return resp
|
||||
}
|
||||
|
||||
// doIAMActionPost signs and sends params as a POST form body rather than a
|
||||
// GET query string, for requests too large to fit a GET request's
|
||||
// header/URL buffer (e.g. an oversized PolicyDocument).
|
||||
func doIAMActionPost(t *testing.T, server *IAMApiServer, params url.Values) *http.Response {
|
||||
t.Helper()
|
||||
if !params.Has("Version") {
|
||||
params.Set("Version", iamAPIVersion)
|
||||
}
|
||||
|
||||
req := signedIAMRequest(t, http.MethodPost, "http://example.com/", []byte(params.Encode()), testRoot.Secret)
|
||||
req.Header.Set("Content-Type", fiber.MIMEApplicationForm)
|
||||
|
||||
resp, err := server.app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func unmarshalXML(t *testing.T, body string, out any) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
+31
-7
@@ -54,12 +54,12 @@ const (
|
||||
ErrInvalidClientTokenID
|
||||
ErrInvalidContentLength
|
||||
ErrThrottling
|
||||
ErrMissingUserNameValue
|
||||
ErrTooManyTags
|
||||
ErrInvalidPathPrefix
|
||||
ErrDuplicateTagKeys
|
||||
ErrInvalidAccessKeyIDChars
|
||||
ErrDeleteConflict
|
||||
ErrDeleteConflictPolicies
|
||||
)
|
||||
|
||||
type APIError interface {
|
||||
@@ -207,12 +207,6 @@ var errorCodeResponse = map[ErrorCode]Error{
|
||||
Message: "'Host' or ':authority' must be a 'SignedHeader' in the AWS Authorization.",
|
||||
HTTPStatusCode: http.StatusForbidden,
|
||||
},
|
||||
ErrMissingUserNameValue: {
|
||||
Type: TypeSender,
|
||||
Code: "ValidationError",
|
||||
Message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must not be null",
|
||||
HTTPStatusCode: http.StatusBadRequest,
|
||||
},
|
||||
ErrInvalidPathPrefix: {
|
||||
Type: TypeSender,
|
||||
Code: "ValidationError",
|
||||
@@ -243,6 +237,12 @@ var errorCodeResponse = map[ErrorCode]Error{
|
||||
Message: "Cannot delete entity, must delete access keys first.",
|
||||
HTTPStatusCode: http.StatusConflict,
|
||||
},
|
||||
ErrDeleteConflictPolicies: {
|
||||
Type: TypeSender,
|
||||
Code: "DeleteConflict",
|
||||
Message: "Cannot delete entity, must delete policies first.",
|
||||
HTTPStatusCode: http.StatusConflict,
|
||||
},
|
||||
}
|
||||
|
||||
func GetAPIError(code ErrorCode) Error {
|
||||
@@ -405,6 +405,30 @@ func InvalidTagValue(index int) Error {
|
||||
return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'tags.%d.member.value' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*", index))
|
||||
}
|
||||
|
||||
func MissingValue(field string) Error {
|
||||
return ValidationError(fmt.Sprintf("1 validation error detected: Value at '%s' failed to satisfy constraint: Member must not be null", field))
|
||||
}
|
||||
|
||||
func ValueTooLong(field string, maxLength int) Error {
|
||||
return ValidationError(fmt.Sprintf("1 validation error detected: Value at '%s' failed to satisfy constraint: Member must have length less than or equal to %d", field, maxLength))
|
||||
}
|
||||
|
||||
func InvalidCharset(field string) Error {
|
||||
return ValidationError(fmt.Sprintf("The specified value for %s is invalid. It must contain only printable ASCII characters.", field))
|
||||
}
|
||||
|
||||
func MalformedPolicyDocument(message string) Error {
|
||||
return newSenderError("MalformedPolicyDocument", message, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func NoSuchEntityUserPolicy(userName, policyName string) Error {
|
||||
return newSenderError("NoSuchEntity", fmt.Sprintf("The user policy with name %s cannot be found.", policyName), http.StatusNotFound)
|
||||
}
|
||||
|
||||
func InlinePolicyQuotaExceeded(entityKind, entityName string, maxBytes int) Error {
|
||||
return newSenderError("LimitExceeded", fmt.Sprintf("Maximum policy size of %d bytes exceeded for %s %s", maxBytes, entityKind, entityName), http.StatusConflict)
|
||||
}
|
||||
|
||||
func newSenderError(code, message string, statusCode int) Error {
|
||||
return Error{
|
||||
Type: TypeSender,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package iamutil
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EncodePolicyDocument RFC 3986 percent-encodes a policy document string
|
||||
// the way real IAM encodes the PolicyDocument element of GetUserPolicy (and
|
||||
// will for GetRolePolicy) responses: every character outside the unreserved
|
||||
// set is percent-encoded, with the space character encoded as %20 rather
|
||||
// than the "+" that url.QueryEscape alone would produce.
|
||||
func EncodePolicyDocument(s string) string {
|
||||
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
@@ -43,9 +44,9 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
userNamePattern = regexp.MustCompile(`^[A-Za-z0-9+=,.@_-]+$`)
|
||||
tagKeyPattern = regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-@]+$`)
|
||||
tagValPattern = regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$`)
|
||||
namePattern = regexp.MustCompile(`^[A-Za-z0-9+=,.@_-]+$`)
|
||||
tagKeyPattern = regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-@]+$`)
|
||||
tagValPattern = regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$`)
|
||||
)
|
||||
|
||||
// RequestParam looks up key first in URL query args, then in the POST body.
|
||||
@@ -63,6 +64,42 @@ func RequestParam(ctx fiber.Ctx, key string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// GetUserName resolves the UserName request parameter and validates it
|
||||
// against maxLen, returning missingErr if the parameter is absent or empty.
|
||||
// operation is included in the debug log on failure (e.g. "DeleteUser").
|
||||
// missingErr lets callers match the exact AWS error their operation is
|
||||
// verified against (e.g. iamerr.MissingValue vs iamerr.MissingParameter).
|
||||
func GetUserName(ctx fiber.Ctx, operation string, maxLen int, missingErr error) (string, error) {
|
||||
userName, ok := RequestParam(ctx, "UserName")
|
||||
if !ok || userName == "" {
|
||||
debuglogger.Logf("missing required %s parameter: UserName", operation)
|
||||
return "", missingErr
|
||||
}
|
||||
if err := ValidateName("userName", userName, maxLen); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return userName, nil
|
||||
}
|
||||
|
||||
// ParseMaxItems reads the MaxItems request parameter, defaulting to
|
||||
// DefaultMaxItems when absent. operation is included in the debug log on
|
||||
// parse failure (e.g. "ListUsers", "ListAccessKeys").
|
||||
func ParseMaxItems(ctx fiber.Ctx, operation string) (int32, error) {
|
||||
rawMaxItems, ok := RequestParam(ctx, "MaxItems")
|
||||
if !ok || rawMaxItems == "" {
|
||||
return int32(DefaultMaxItems), nil
|
||||
}
|
||||
|
||||
parsed, err := strconv.ParseInt(rawMaxItems, 10, 32)
|
||||
if err != nil || parsed < 1 || parsed > MaxListItems {
|
||||
debuglogger.Logf("invalid %s MaxItems value %q: parse_error=%v", operation, rawMaxItems, err)
|
||||
return 0, iamerr.InvalidMaxItems(rawMaxItems)
|
||||
}
|
||||
|
||||
return int32(parsed), nil
|
||||
}
|
||||
|
||||
// ParseTags reads IAM tag members from the request (up to 50), validates each, and returns the list.
|
||||
func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) {
|
||||
var tags []types.Tag
|
||||
@@ -106,14 +143,16 @@ func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) {
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
// ValidateUserName checks that userName is non-empty, matches the allowed character set, and fits within maxLength.
|
||||
func ValidateUserName(field, userName string, maxLength int) error {
|
||||
if len(userName) > maxLength {
|
||||
debuglogger.Logf("IAM user name exceeds maximum length: field=%s length=%d max=%d", field, len(userName), maxLength)
|
||||
// ValidateName checks that name (an IAM identity or policy name, e.g.
|
||||
// userName or policyName) is non-empty, matches the allowed character set,
|
||||
// and fits within maxLength.
|
||||
func ValidateName(field, name string, maxLength int) error {
|
||||
if len(name) > maxLength {
|
||||
debuglogger.Logf("IAM name exceeds maximum length: field=%s length=%d max=%d", field, len(name), maxLength)
|
||||
return iamerr.UserNameTooLong(field, maxLength)
|
||||
}
|
||||
if userName == "" || !userNamePattern.MatchString(userName) {
|
||||
debuglogger.Logf("invalid IAM user name: field=%s value=%q", field, userName)
|
||||
if name == "" || !namePattern.MatchString(name) {
|
||||
debuglogger.Logf("invalid IAM name: field=%s value=%q", field, name)
|
||||
return iamerr.InvalidUserName(field)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package policy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// Recognized values for a policy document's Version element.
|
||||
const (
|
||||
Version2008 = "2008-10-17"
|
||||
Version2012 = "2012-10-17"
|
||||
)
|
||||
|
||||
// Document is a parsed AWS IAM policy document.
|
||||
type Document struct {
|
||||
Version string
|
||||
Statement []Statement
|
||||
}
|
||||
|
||||
// Statement is a single element of a policy document's Statement list.
|
||||
type Statement struct {
|
||||
Sid string
|
||||
Effect string
|
||||
Action StringOrSlice
|
||||
NotAction StringOrSlice
|
||||
Resource StringOrSlice
|
||||
NotResource StringOrSlice
|
||||
Principal json.RawMessage
|
||||
NotPrincipal json.RawMessage
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts Statement as either a single JSON object or an
|
||||
// array of objects, matching the AWS IAM policy grammar. A missing or
|
||||
// JSON-null Statement leaves Document.Statement nil rather than erroring
|
||||
// here — Validate reports that as a grammar error so all "empty document"
|
||||
// shapes produce the same message.
|
||||
func (d *Document) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
Version string
|
||||
Statement json.RawMessage
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
d.Version = raw.Version
|
||||
|
||||
if len(raw.Statement) == 0 || string(bytes.TrimSpace(raw.Statement)) == "null" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var stmts []Statement
|
||||
if err := json.Unmarshal(raw.Statement, &stmts); err == nil {
|
||||
d.Statement = stmts
|
||||
return nil
|
||||
}
|
||||
|
||||
var single Statement
|
||||
if err := json.Unmarshal(raw.Statement, &single); err != nil {
|
||||
return err
|
||||
}
|
||||
d.Statement = []Statement{single}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StringOrSlice decodes a JSON value that may be either a single string or
|
||||
// an array of strings, matching the AWS IAM policy grammar for Action,
|
||||
// NotAction, Resource, and NotResource. A JSON-null value decodes to a nil
|
||||
// StringOrSlice, identical to the key being absent.
|
||||
type StringOrSlice []string
|
||||
|
||||
func (s *StringOrSlice) UnmarshalJSON(data []byte) error {
|
||||
if string(bytes.TrimSpace(data)) == "null" {
|
||||
*s = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
var single string
|
||||
if err := json.Unmarshal(data, &single); err == nil {
|
||||
*s = StringOrSlice{single}
|
||||
return nil
|
||||
}
|
||||
|
||||
var multi []string
|
||||
if err := json.Unmarshal(data, &multi); err != nil {
|
||||
return err
|
||||
}
|
||||
*s = StringOrSlice(multi)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package policy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStringOrSliceUnmarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
want StringOrSlice
|
||||
}{
|
||||
{"single string", `"s3:GetObject"`, StringOrSlice{"s3:GetObject"}},
|
||||
{"array of strings", `["s3:GetObject","s3:PutObject"]`, StringOrSlice{"s3:GetObject", "s3:PutObject"}},
|
||||
{"empty array", `[]`, StringOrSlice{}},
|
||||
{"empty string", `""`, StringOrSlice{""}},
|
||||
{"null", `null`, nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var got StringOrSlice
|
||||
if err := json.Unmarshal([]byte(tt.json), &got); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("Unmarshal() = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentUnmarshalJSON(t *testing.T) {
|
||||
t.Run("statement as array", func(t *testing.T) {
|
||||
var doc Document
|
||||
err := json.Unmarshal([]byte(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`), &doc)
|
||||
if err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(doc.Statement) != 1 {
|
||||
t.Fatalf("got %d statements, want 1", len(doc.Statement))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("statement as single object", func(t *testing.T) {
|
||||
var doc Document
|
||||
err := json.Unmarshal([]byte(`{"Version":"2012-10-17","Statement":{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}}`), &doc)
|
||||
if err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(doc.Statement) != 1 {
|
||||
t.Fatalf("got %d statements, want 1", len(doc.Statement))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("statement absent leaves nil, not an unmarshal error", func(t *testing.T) {
|
||||
var doc Document
|
||||
err := json.Unmarshal([]byte(`{"Version":"2012-10-17"}`), &doc)
|
||||
if err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if doc.Statement != nil {
|
||||
t.Fatalf("Statement = %#v, want nil", doc.Statement)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("statement null leaves nil, not an unmarshal error", func(t *testing.T) {
|
||||
var doc Document
|
||||
err := json.Unmarshal([]byte(`{"Version":"2012-10-17","Statement":null}`), &doc)
|
||||
if err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if doc.Statement != nil {
|
||||
t.Fatalf("Statement = %#v, want nil", doc.Statement)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version absent leaves empty string, not defaulted", func(t *testing.T) {
|
||||
// Unlike auth's S3 bucket-policy engine (which defaults a missing
|
||||
// Version to 2008-10-17), real IAM leaves an omitted Version on an
|
||||
// identity policy exactly as submitted - no default is injected.
|
||||
var doc Document
|
||||
err := json.Unmarshal([]byte(`{"Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`), &doc)
|
||||
if err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if doc.Version != "" {
|
||||
t.Fatalf("Version = %q, want empty", doc.Version)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("top-level non-object is an unmarshal error", func(t *testing.T) {
|
||||
var doc Document
|
||||
if err := json.Unmarshal([]byte(`"hello"`), &doc); err == nil {
|
||||
t.Fatal("Unmarshal() error = nil, want non-nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package policy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/versity/versitygw/iamapi/iamerr"
|
||||
)
|
||||
|
||||
// MaxDocumentLength is IAM's parameter-level maximum length for a
|
||||
// PolicyDocument value.
|
||||
const MaxDocumentLength = 131072
|
||||
|
||||
// vendorPattern is the inferred grammar for the service prefix of a policy
|
||||
// action/resource (the text before the first ':', e.g. "s3", "iam",
|
||||
// "elasticloadbalancing"). AWS does not publish this pattern; alphanumeric
|
||||
// + hyphen matches every real service prefix and was verified to reject an
|
||||
// empty or space-containing prefix the same way live IAM does.
|
||||
var vendorPattern = regexp.MustCompile(`^[A-Za-z0-9-]+$`)
|
||||
|
||||
// validPartition is the only ARN partition name supported byt the gateway: real
|
||||
// IAM also accepts "aws-cn", "aws-us-gov", and the "aws-iso*" partitions,
|
||||
// but this deployment only ever runs in the standard "aws" partition, so a
|
||||
// resource ARN whose partition field is anything else is rejected
|
||||
const validPartition = "aws"
|
||||
|
||||
var (
|
||||
errSyntax = iamerr.MalformedPolicyDocument("Syntax errors in policy.")
|
||||
errMissingActions = iamerr.MalformedPolicyDocument("Policy statement must contain actions.")
|
||||
errMissingResources = iamerr.MalformedPolicyDocument("Policy statement must contain resources.")
|
||||
errPrincipalNotAllowed = iamerr.MalformedPolicyDocument("Policy document should not specify a principal.")
|
||||
errDuplicateSid = iamerr.MalformedPolicyDocument("Statement IDs (SID) in a single policy must be unique.")
|
||||
errMissingVendorPrefix = iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.")
|
||||
errLegacyParsing = iamerr.MalformedPolicyDocument("The policy failed legacy parsing")
|
||||
)
|
||||
|
||||
// Validate checks raw against IAM's parameter-level constraints for a
|
||||
// PolicyDocument value: a maximum length of 131072 and the allowed
|
||||
// character set (tab/LF/CR plus printable Latin-1, U+0020-U+00FF, with at
|
||||
// least one such character present — so an empty value is rejected here
|
||||
// too, as a charset violation).
|
||||
func Validate(field, raw string) error {
|
||||
if len(raw) > MaxDocumentLength {
|
||||
return iamerr.ValueTooLong(field, MaxDocumentLength)
|
||||
}
|
||||
if !isValidDocumentCharset(raw) {
|
||||
return iamerr.InvalidCharset(field)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidDocumentCharset(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\t', '\n', '\r':
|
||||
continue
|
||||
}
|
||||
if r < 0x20 || r > 0xFF {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Parse parses raw as an IAM policy document and checks it against IAM
|
||||
// policy grammar
|
||||
func Parse(raw string) error {
|
||||
var doc Document
|
||||
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
|
||||
return errSyntax
|
||||
}
|
||||
return doc.Validate()
|
||||
}
|
||||
|
||||
// Validate checks d against IAM policy document grammar: a valid Version if
|
||||
// present, a non-empty Statement (single object or array), document-wide
|
||||
// unique Sids, and per statement, the rules enforced by Statement.Validate.
|
||||
func (d Document) Validate() error {
|
||||
if d.Version != "" && d.Version != Version2008 && d.Version != Version2012 {
|
||||
return errSyntax
|
||||
}
|
||||
if len(d.Statement) == 0 {
|
||||
return errSyntax
|
||||
}
|
||||
|
||||
seenSids := make(map[string]struct{}, len(d.Statement))
|
||||
for _, stmt := range d.Statement {
|
||||
if err := stmt.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if stmt.Sid != "" {
|
||||
if _, ok := seenSids[stmt.Sid]; ok {
|
||||
return errDuplicateSid
|
||||
}
|
||||
seenSids[stmt.Sid] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks s against IAM policy statement grammar: a valid Effect,
|
||||
// no Principal/NotPrincipal, an Action or NotAction (not both) with
|
||||
// vendor-prefixed values, and a Resource or NotResource (not both) with
|
||||
// ARN-shaped values. Condition is not modeled or validated.
|
||||
func (s Statement) Validate() error {
|
||||
switch s.Effect {
|
||||
case "Allow", "Deny":
|
||||
default:
|
||||
return errSyntax
|
||||
}
|
||||
|
||||
if len(s.Principal) > 0 || len(s.NotPrincipal) > 0 {
|
||||
return errPrincipalNotAllowed
|
||||
}
|
||||
|
||||
if len(s.Action) > 0 && len(s.NotAction) > 0 {
|
||||
return errSyntax
|
||||
}
|
||||
if len(s.Action) == 0 && len(s.NotAction) == 0 {
|
||||
return errMissingActions
|
||||
}
|
||||
for _, action := range s.Action {
|
||||
if err := validateActionVendor(action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, action := range s.NotAction {
|
||||
if err := validateActionVendor(action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(s.Resource) > 0 && len(s.NotResource) > 0 {
|
||||
return errSyntax
|
||||
}
|
||||
if len(s.Resource) == 0 && len(s.NotResource) == 0 {
|
||||
return errMissingResources
|
||||
}
|
||||
for _, resource := range s.Resource {
|
||||
if err := validateResourceARN(resource); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, resource := range s.NotResource {
|
||||
if err := validateResourceARN(resource); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateActionVendor checks that action is either the bare wildcard "*"
|
||||
// or has a syntactically valid "vendor:name" shape. The action name after
|
||||
// the colon is not checked against any known service/action list — real
|
||||
// IAM accepts unrecognized service/action names at this stage too.
|
||||
func validateActionVendor(action string) error {
|
||||
if action == "*" {
|
||||
return nil
|
||||
}
|
||||
before, _, ok := strings.Cut(action, ":")
|
||||
if !ok {
|
||||
return errMissingVendorPrefix
|
||||
}
|
||||
vendor := before
|
||||
if !vendorPattern.MatchString(vendor) {
|
||||
return iamerr.MalformedPolicyDocument(fmt.Sprintf("Vendor %s is not valid", vendor))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateResourceARN checks a single Resource/NotResource entry against
|
||||
// IAM's ARN grammar: either the bare wildcard "*", or
|
||||
// "arn:partition:service:region:account:resource". The service, region,
|
||||
// account, and resource fields are not further validated — only the
|
||||
// partition is checked, matching what real IAM enforces at this stage
|
||||
func validateResourceARN(resource string) error {
|
||||
if resource == "*" {
|
||||
return nil
|
||||
}
|
||||
if !strings.Contains(resource, ":") {
|
||||
return iamerr.MalformedPolicyDocument(fmt.Sprintf("Resource %s must be in ARN format or \"*\".", resource))
|
||||
}
|
||||
|
||||
if strings.HasPrefix(resource, "arn:") {
|
||||
fields := strings.SplitN(resource[len("arn:"):], ":", 5)
|
||||
if len(fields) < 5 {
|
||||
return errLegacyParsing
|
||||
}
|
||||
partition := fields[0]
|
||||
if partition != validPartition {
|
||||
return iamerr.MalformedPolicyDocument(fmt.Sprintf("Partition %q is not valid for resource %q.", partition, resource))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
tokens := strings.SplitN(resource, ":", 6)
|
||||
field := func(i int) string {
|
||||
if i < len(tokens) {
|
||||
return tokens[i]
|
||||
}
|
||||
return "*"
|
||||
}
|
||||
partition := field(1)
|
||||
reconstructed := fmt.Sprintf("arn:%s:%s:%s:%s:%s", partition, field(2), field(3), field(4), field(5))
|
||||
return iamerr.MalformedPolicyDocument(fmt.Sprintf("Partition %q is not valid for resource %q.", partition, reconstructed))
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package policy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/versity/versitygw/iamapi/iamerr"
|
||||
)
|
||||
|
||||
// Every case below was verified against a live AWS IAM account.
|
||||
func TestValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
doc string
|
||||
wantErr error // nil means Validate must succeed
|
||||
}{
|
||||
{"valid single statement", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, nil},
|
||||
{"valid statement as single object, not array", `{"Version":"2012-10-17","Statement":{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}}`, nil},
|
||||
{"valid without version", `{"Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, nil},
|
||||
{"valid bare wildcard action and resource", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}`, nil},
|
||||
{"valid NotAction alone", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotAction":"s3:GetObject","Resource":"*"}]}`, nil},
|
||||
{"valid NotResource alone", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotResource":"*"}]}`, nil},
|
||||
{"valid unrecognized vendor/action accepted", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"totallyfakeservice:DoSomething","Resource":"*"}]}`, nil},
|
||||
{"valid multiple unique sids", `{"Version":"2012-10-17","Statement":[{"Sid":"A","Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Sid":"B","Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`, nil},
|
||||
{"valid action array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject","s3:ListBucket"],"Resource":["arn:aws:s3:::b","arn:aws:s3:::b/*"]}]}`, nil},
|
||||
|
||||
{"invalid json syntax", `{invalid json`, errSyntax},
|
||||
{"empty object", `{}`, errSyntax},
|
||||
{"invalid version", `{"Version":"2020-01-01","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, errSyntax},
|
||||
{"missing statement", `{"Version":"2012-10-17"}`, errSyntax},
|
||||
{"null statement", `{"Version":"2012-10-17","Statement":null}`, errSyntax},
|
||||
{"empty statement array", `{"Version":"2012-10-17","Statement":[]}`, errSyntax},
|
||||
{"statement is a string", `{"Version":"2012-10-17","Statement":"hello"}`, errSyntax},
|
||||
{"missing effect", `{"Version":"2012-10-17","Statement":[{"Action":"s3:GetObject","Resource":"*"}]}`, errSyntax},
|
||||
{"invalid effect value", `{"Version":"2012-10-17","Statement":[{"Effect":"Maybe","Action":"s3:GetObject","Resource":"*"}]}`, errSyntax},
|
||||
{"action and notaction both present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotAction":"s3:PutObject","Resource":"*"}]}`, errSyntax},
|
||||
{"resource and notresource both present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","NotResource":"foo"}]}`, errSyntax},
|
||||
{"numeric action wrong type", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":123,"Resource":"*"}]}`, errSyntax},
|
||||
|
||||
{"missing action and notaction", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Resource":"*"}]}`, errMissingActions},
|
||||
|
||||
{"missing resource and notresource", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject"}]}`, errMissingResources},
|
||||
{"empty resource array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":[]}]}`, errMissingResources},
|
||||
|
||||
{"empty string action", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"","Resource":"*"}]}`, errMissingVendorPrefix},
|
||||
{"action missing vendor colon", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"GetObject","Resource":"*"}]}`, errMissingVendorPrefix},
|
||||
|
||||
{"principal present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"*"}]}`, errPrincipalNotAllowed},
|
||||
{"notprincipal present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotPrincipal":"*","Action":"s3:GetObject","Resource":"*"}]}`, errPrincipalNotAllowed},
|
||||
|
||||
{"duplicate sid across statements", `{"Version":"2012-10-17","Statement":[{"Sid":"Dup","Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Sid":"Dup","Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`, errDuplicateSid},
|
||||
|
||||
{"empty vendor prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":":GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Vendor is not valid")},
|
||||
{"vendor with invalid character", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam :Get","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Vendor iam is not valid")},
|
||||
|
||||
{"resource with no colon at all", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"invalid"}]}`, iamerr.MalformedPolicyDocument(`Resource invalid must be in ARN format or "*".`)},
|
||||
{"resource with colon but no arn prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"s3::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument(`Partition "" is not valid for resource "arn::example-bucket/*:*:*:*".`)},
|
||||
{"resource with arn prefix but too few fields", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:awss3::example-bucket/*"}]}`, errLegacyParsing},
|
||||
{"resource with invalid partition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws2:s3:::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument(`Partition "aws2" is not valid for resource "arn:aws2:s3:::example-bucket/*".`)},
|
||||
{"notresource with invalid shape", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotResource":"invalid"}]}`, iamerr.MalformedPolicyDocument(`Resource invalid must be in ARN format or "*".`)},
|
||||
{"principal only, no action or resource", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:user/bob"}}]}`, errPrincipalNotAllowed},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := Parse(tt.doc)
|
||||
if tt.wantErr == nil {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() = %v, want nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("Validate() = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantErr error
|
||||
}{
|
||||
{"valid small document", `{}`, nil},
|
||||
{"tab, newline, and carriage return allowed", "a\tb\nc\rd", nil},
|
||||
{"empty", "", iamerr.InvalidCharset("policyDocument")},
|
||||
{"exactly at max length", strings.Repeat("x", MaxDocumentLength), nil},
|
||||
{"one over max length", strings.Repeat("x", MaxDocumentLength+1), iamerr.ValueTooLong("policyDocument", MaxDocumentLength)},
|
||||
{"non-latin1 rune rejected", "emoji\U0001F600test", iamerr.InvalidCharset("policyDocument")},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := Validate("policyDocument", tt.raw)
|
||||
if tt.wantErr == nil {
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateSize() = %v, want nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("ValidateSize() = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,11 @@ func (r *IAMApiRouter) Init() {
|
||||
"DeleteAccessKey": ctrl.DeleteAccessKey,
|
||||
"GetAccessKeyLastUsed": ctrl.GetAccessKeyLastUsed,
|
||||
"ListAccessKeys": ctrl.ListAccessKeys,
|
||||
// User Inline Policy CRUD
|
||||
"PutUserPolicy": ctrl.PutUserPolicy,
|
||||
"GetUserPolicy": ctrl.GetUserPolicy,
|
||||
"DeleteUserPolicy": ctrl.DeleteUserPolicy,
|
||||
"ListUserPolicies": ctrl.ListUserPolicies,
|
||||
}
|
||||
|
||||
actionRoute := ProcessHandlers(r.routeAction, iammiddleware.VerifyIAMAuth(r.rootCreds))
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/versity/versitygw/iamapi/iamerr"
|
||||
"github.com/versity/versitygw/iamapi/types"
|
||||
@@ -113,6 +114,9 @@ func (s *InternalStore) DeleteUser(_ context.Context, username string) error {
|
||||
if !ok {
|
||||
return nil, iamerr.NoSuchEntityUser(username)
|
||||
}
|
||||
if len(user.Policies.Inline) > 0 {
|
||||
return nil, iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies)
|
||||
}
|
||||
if len(user.AccessKeys) > 0 {
|
||||
return nil, iamerr.GetAPIError(iamerr.ErrDeleteConflict)
|
||||
}
|
||||
@@ -445,9 +449,163 @@ func (s *InternalStore) ListAccessKeys(_ context.Context, input ListAccessKeysIn
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *InternalStore) PutUserPolicy(_ context.Context, input PutUserPolicyInput) error {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
|
||||
err := s.engine.StoreIAM(func(data []byte) ([]byte, error) {
|
||||
conf, err := s.engine.ParseIAM(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, ok := conf.Users[input.UserName]
|
||||
if !ok {
|
||||
return nil, iamerr.NoSuchEntityUser(input.UserName)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
newTotal := len(input.PolicyDocument)
|
||||
replaceAt := -1
|
||||
for i, p := range user.Policies.Inline {
|
||||
if p.PolicyName == input.PolicyName {
|
||||
replaceAt = i
|
||||
continue
|
||||
}
|
||||
newTotal += len(p.PolicyDocument)
|
||||
}
|
||||
if newTotal > MaxInlinePolicyBytesPerUser {
|
||||
return nil, iamerr.InlinePolicyQuotaExceeded("user", input.UserName, MaxInlinePolicyBytesPerUser)
|
||||
}
|
||||
|
||||
if replaceAt >= 0 {
|
||||
user.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument
|
||||
user.Policies.Inline[replaceAt].UpdateDate = now
|
||||
} else {
|
||||
user.Policies.Inline = append(user.Policies.Inline, types.PolicyEntry{
|
||||
PolicyName: input.PolicyName,
|
||||
PolicyDocument: input.PolicyDocument,
|
||||
CreateDate: now,
|
||||
UpdateDate: now,
|
||||
})
|
||||
}
|
||||
|
||||
conf.Users[input.UserName] = user
|
||||
return json.Marshal(conf)
|
||||
})
|
||||
return unwrapAPIError(err)
|
||||
}
|
||||
|
||||
func (s *InternalStore) GetUserPolicy(_ context.Context, userName, policyName string) (*types.PolicyEntry, error) {
|
||||
s.RLock()
|
||||
defer s.RUnlock()
|
||||
|
||||
conf, err := s.engine.GetIAM()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, ok := conf.Users[userName]
|
||||
if !ok {
|
||||
return nil, iamerr.NoSuchEntityUser(userName)
|
||||
}
|
||||
|
||||
for _, p := range user.Policies.Inline {
|
||||
if p.PolicyName == policyName {
|
||||
cloned := p
|
||||
return &cloned, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, iamerr.NoSuchEntityUserPolicy(userName, policyName)
|
||||
}
|
||||
|
||||
func (s *InternalStore) DeleteUserPolicy(_ context.Context, userName, policyName string) error {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
|
||||
err := s.engine.StoreIAM(func(data []byte) ([]byte, error) {
|
||||
conf, err := s.engine.ParseIAM(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, ok := conf.Users[userName]
|
||||
if !ok {
|
||||
return nil, iamerr.NoSuchEntityUser(userName)
|
||||
}
|
||||
|
||||
idx := -1
|
||||
for i, p := range user.Policies.Inline {
|
||||
if p.PolicyName == policyName {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx == -1 {
|
||||
return nil, iamerr.NoSuchEntityUserPolicy(userName, policyName)
|
||||
}
|
||||
|
||||
user.Policies.Inline = slices.Delete(user.Policies.Inline, idx, idx+1)
|
||||
conf.Users[userName] = user
|
||||
return json.Marshal(conf)
|
||||
})
|
||||
return unwrapAPIError(err)
|
||||
}
|
||||
|
||||
func (s *InternalStore) ListUserPolicies(_ context.Context, input ListUserPoliciesInput) (*ListUserPoliciesOutput, error) {
|
||||
s.RLock()
|
||||
defer s.RUnlock()
|
||||
|
||||
conf, err := s.engine.GetIAM()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, ok := conf.Users[input.UserName]
|
||||
if !ok {
|
||||
return nil, iamerr.NoSuchEntityUser(input.UserName)
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(user.Policies.Inline))
|
||||
for _, p := range user.Policies.Inline {
|
||||
names = append(names, p.PolicyName)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
start := 0
|
||||
if input.Marker != "" {
|
||||
start = len(names)
|
||||
for i, name := range names {
|
||||
if name == input.Marker {
|
||||
start = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
names = names[start:]
|
||||
|
||||
limit := len(names)
|
||||
if input.MaxItems > 0 && int(input.MaxItems) < limit {
|
||||
limit = int(input.MaxItems)
|
||||
}
|
||||
|
||||
out := &ListUserPoliciesOutput{
|
||||
PolicyNames: make([]string, limit),
|
||||
}
|
||||
copy(out.PolicyNames, names[:limit])
|
||||
if limit < len(names) {
|
||||
out.IsTruncated = true
|
||||
out.Marker = out.PolicyNames[limit-1]
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func cloneUser(user types.User) *types.User {
|
||||
cloned := user
|
||||
cloned.Tags = slices.Clone(user.Tags)
|
||||
cloned.AccessKeys = slices.Clone(user.AccessKeys)
|
||||
cloned.Policies.Inline = slices.Clone(user.Policies.Inline)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ import (
|
||||
// user may hold at once, matching the AWS IAM quota.
|
||||
const MaxAccessKeysPerUser = 2
|
||||
|
||||
// MaxInlinePolicyBytesPerUser is the maximum aggregate size, in bytes, of
|
||||
// all of a single IAM user's inline policy documents combined
|
||||
const MaxInlinePolicyBytesPerUser = 2048
|
||||
|
||||
var (
|
||||
ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists")
|
||||
ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists")
|
||||
@@ -86,6 +90,24 @@ type GetAccessKeyLastUsedOutput struct {
|
||||
Region string
|
||||
}
|
||||
|
||||
type PutUserPolicyInput struct {
|
||||
UserName string
|
||||
PolicyName string
|
||||
PolicyDocument string
|
||||
}
|
||||
|
||||
type ListUserPoliciesInput struct {
|
||||
UserName string
|
||||
Marker string
|
||||
MaxItems int32
|
||||
}
|
||||
|
||||
type ListUserPoliciesOutput struct {
|
||||
PolicyNames []string
|
||||
IsTruncated bool
|
||||
Marker string
|
||||
}
|
||||
|
||||
// Storer is the IAM API storage backend contract.
|
||||
type Storer interface {
|
||||
CreateUser(ctx context.Context, user types.User) (*types.User, error)
|
||||
@@ -99,6 +121,11 @@ type Storer interface {
|
||||
DeleteAccessKey(ctx context.Context, username, accessKeyID string) error
|
||||
GetAccessKeyLastUsed(ctx context.Context, accessKeyID string) (*GetAccessKeyLastUsedOutput, error)
|
||||
ListAccessKeys(ctx context.Context, input ListAccessKeysInput) (*ListAccessKeysOutput, error)
|
||||
|
||||
PutUserPolicy(ctx context.Context, input PutUserPolicyInput) error
|
||||
GetUserPolicy(ctx context.Context, userName, policyName string) (*types.PolicyEntry, error)
|
||||
DeleteUserPolicy(ctx context.Context, userName, policyName string) error
|
||||
ListUserPolicies(ctx context.Context, input ListUserPoliciesInput) (*ListUserPoliciesOutput, error)
|
||||
}
|
||||
|
||||
func unwrapAPIError(err error) error {
|
||||
|
||||
@@ -233,6 +233,9 @@ func (s *VaultStore) DeleteUser(ctx context.Context, username string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(user.Policies.Inline) > 0 {
|
||||
return iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies)
|
||||
}
|
||||
if len(user.AccessKeys) > 0 {
|
||||
return iamerr.GetAPIError(iamerr.ErrDeleteConflict)
|
||||
}
|
||||
@@ -553,6 +556,122 @@ func (s *VaultStore) ListAccessKeys(ctx context.Context, input ListAccessKeysInp
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *VaultStore) PutUserPolicy(ctx context.Context, input PutUserPolicyInput) error {
|
||||
user, err := s.GetUser(ctx, input.UserName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newTotal := len(input.PolicyDocument)
|
||||
replaceAt := -1
|
||||
for i, p := range user.Policies.Inline {
|
||||
if p.PolicyName == input.PolicyName {
|
||||
replaceAt = i
|
||||
continue
|
||||
}
|
||||
newTotal += len(p.PolicyDocument)
|
||||
}
|
||||
if newTotal > MaxInlinePolicyBytesPerUser {
|
||||
return iamerr.InlinePolicyQuotaExceeded("user", input.UserName, MaxInlinePolicyBytesPerUser)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
if replaceAt >= 0 {
|
||||
user.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument
|
||||
user.Policies.Inline[replaceAt].UpdateDate = now
|
||||
} else {
|
||||
user.Policies.Inline = append(user.Policies.Inline, types.PolicyEntry{
|
||||
PolicyName: input.PolicyName,
|
||||
PolicyDocument: input.PolicyDocument,
|
||||
CreateDate: now,
|
||||
UpdateDate: now,
|
||||
})
|
||||
}
|
||||
|
||||
_, err = s.replaceUser(ctx, *user)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *VaultStore) GetUserPolicy(ctx context.Context, userName, policyName string) (*types.PolicyEntry, error) {
|
||||
user, err := s.GetUser(ctx, userName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, p := range user.Policies.Inline {
|
||||
if p.PolicyName == policyName {
|
||||
cloned := p
|
||||
return &cloned, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, iamerr.NoSuchEntityUserPolicy(userName, policyName)
|
||||
}
|
||||
|
||||
func (s *VaultStore) DeleteUserPolicy(ctx context.Context, userName, policyName string) error {
|
||||
user, err := s.GetUser(ctx, userName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
idx := -1
|
||||
for i, p := range user.Policies.Inline {
|
||||
if p.PolicyName == policyName {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx == -1 {
|
||||
return iamerr.NoSuchEntityUserPolicy(userName, policyName)
|
||||
}
|
||||
|
||||
user.Policies.Inline = slices.Delete(user.Policies.Inline, idx, idx+1)
|
||||
|
||||
_, err = s.replaceUser(ctx, *user)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *VaultStore) ListUserPolicies(ctx context.Context, input ListUserPoliciesInput) (*ListUserPoliciesOutput, error) {
|
||||
user, err := s.GetUser(ctx, input.UserName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(user.Policies.Inline))
|
||||
for _, p := range user.Policies.Inline {
|
||||
names = append(names, p.PolicyName)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
start := 0
|
||||
if input.Marker != "" {
|
||||
start = len(names)
|
||||
for i, name := range names {
|
||||
if name == input.Marker {
|
||||
start = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
names = names[start:]
|
||||
|
||||
limit := len(names)
|
||||
if input.MaxItems > 0 && int(input.MaxItems) < limit {
|
||||
limit = int(input.MaxItems)
|
||||
}
|
||||
|
||||
out := &ListUserPoliciesOutput{
|
||||
PolicyNames: make([]string, limit),
|
||||
}
|
||||
copy(out.PolicyNames, names[:limit])
|
||||
if limit < len(names) {
|
||||
out.IsTruncated = true
|
||||
out.Marker = out.PolicyNames[limit-1]
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// deleteByPath permanently removes a secret and all its versions without
|
||||
// checking for existence first.
|
||||
func (s *VaultStore) deleteByPath(username string) error {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Policies holds every kind of policy attached to an identity (user, role ...)
|
||||
// Inline is the only populated field for now
|
||||
type Policies struct {
|
||||
Inline []PolicyEntry `json:"inline,omitempty"`
|
||||
}
|
||||
|
||||
// PolicyEntry is the storage representation of a single inline policy. It
|
||||
// round-trips through JSON for the internal and Vault storers and is
|
||||
// never marshaled to XML directly — mirrors AccessKeyEntry. PolicyDocument
|
||||
// holds the exact bytes submitted by the caller (after validation), not a
|
||||
// re-serialized form
|
||||
type PolicyEntry struct {
|
||||
PolicyName string
|
||||
PolicyDocument string
|
||||
CreateDate time.Time
|
||||
UpdateDate time.Time
|
||||
}
|
||||
|
||||
type PutUserPolicyResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ PutUserPolicyResponse"`
|
||||
ResponseMetadata ResponseMetadata
|
||||
}
|
||||
|
||||
func (r *PutUserPolicyResponse) SetRequestID(requestID string) {
|
||||
r.ResponseMetadata.RequestID = requestID
|
||||
}
|
||||
|
||||
type DeleteUserPolicyResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteUserPolicyResponse"`
|
||||
ResponseMetadata ResponseMetadata
|
||||
}
|
||||
|
||||
func (r *DeleteUserPolicyResponse) SetRequestID(requestID string) {
|
||||
r.ResponseMetadata.RequestID = requestID
|
||||
}
|
||||
|
||||
type GetUserPolicyResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetUserPolicyResponse"`
|
||||
Result GetUserPolicyResult `xml:"GetUserPolicyResult"`
|
||||
ResponseMetadata ResponseMetadata
|
||||
}
|
||||
|
||||
func (r *GetUserPolicyResponse) SetRequestID(requestID string) {
|
||||
r.ResponseMetadata.RequestID = requestID
|
||||
}
|
||||
|
||||
// GetUserPolicyResult's PolicyDocument must be RFC 3986 percent-encoded by
|
||||
// the caller before assignment — see iamutil.EncodePolicyDocument. Real
|
||||
// IAM returns PolicyDocument URL-encoded; xml.Marshal does not do this
|
||||
// encoding on its own.
|
||||
type GetUserPolicyResult struct {
|
||||
UserName string
|
||||
PolicyName string
|
||||
PolicyDocument string
|
||||
}
|
||||
|
||||
type ListUserPoliciesResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListUserPoliciesResponse"`
|
||||
Result ListUserPoliciesResult `xml:"ListUserPoliciesResult"`
|
||||
ResponseMetadata ResponseMetadata
|
||||
}
|
||||
|
||||
func (r *ListUserPoliciesResponse) SetRequestID(requestID string) {
|
||||
r.ResponseMetadata.RequestID = requestID
|
||||
}
|
||||
|
||||
type ListUserPoliciesResult struct {
|
||||
PolicyNames PolicyNameList
|
||||
IsTruncated bool
|
||||
Marker string `xml:",omitempty"`
|
||||
}
|
||||
|
||||
type PolicyNameList struct {
|
||||
Members []string `xml:"member"`
|
||||
}
|
||||
@@ -106,6 +106,7 @@ type User struct {
|
||||
CreateDate time.Time `xml:"CreateDate"`
|
||||
Tags []Tag `xml:"Tags>member,omitempty"`
|
||||
AccessKeys []AccessKeyEntry `xml:"-"`
|
||||
Policies Policies `xml:"-"`
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
|
||||
@@ -1233,6 +1233,47 @@ func TestIAMListAccessKeys(ts *TestState) {
|
||||
ts.Run(IAMListAccessKeys_pagination)
|
||||
}
|
||||
|
||||
func TestIAMPutUserPolicy(ts *TestState) {
|
||||
ts.Run(IAMPutUserPolicy_missing_user_name)
|
||||
ts.Run(IAMPutUserPolicy_missing_policy_name)
|
||||
ts.Run(IAMPutUserPolicy_missing_policy_document)
|
||||
ts.Run(IAMPutUserPolicy_invalid_policy_name)
|
||||
ts.Run(IAMPutUserPolicy_long_policy_name)
|
||||
ts.Run(IAMPutUserPolicy_non_ascii_policy_document)
|
||||
ts.Run(IAMPutUserPolicy_non_existing_user)
|
||||
ts.Run(IAMPutUserPolicy_malformed_policy_document)
|
||||
ts.Run(IAMPutUserPolicy_principal_not_allowed)
|
||||
ts.Run(IAMPutUserPolicy_limit_exceeded)
|
||||
ts.Run(IAMPutUserPolicy_success)
|
||||
ts.Run(IAMPutUserPolicy_overwrite_updates_existing)
|
||||
}
|
||||
|
||||
func TestIAMGetUserPolicy(ts *TestState) {
|
||||
ts.Run(IAMGetUserPolicy_missing_user_name)
|
||||
ts.Run(IAMGetUserPolicy_missing_policy_name)
|
||||
ts.Run(IAMGetUserPolicy_non_existing_user)
|
||||
ts.Run(IAMGetUserPolicy_non_existing_policy)
|
||||
ts.Run(IAMGetUserPolicy_success)
|
||||
}
|
||||
|
||||
func TestIAMDeleteUserPolicy(ts *TestState) {
|
||||
ts.Run(IAMDeleteUserPolicy_missing_user_name)
|
||||
ts.Run(IAMDeleteUserPolicy_missing_policy_name)
|
||||
ts.Run(IAMDeleteUserPolicy_non_existing_user)
|
||||
ts.Run(IAMDeleteUserPolicy_non_existing_policy)
|
||||
ts.Run(IAMDeleteUserPolicy_success)
|
||||
ts.Run(IAMDeleteUserPolicy_blocks_user_deletion)
|
||||
}
|
||||
|
||||
func TestIAMListUserPolicies(ts *TestState) {
|
||||
ts.Run(IAMListUserPolicies_missing_user_name)
|
||||
ts.Run(IAMListUserPolicies_non_existing_user)
|
||||
ts.Run(IAMListUserPolicies_invalid_max_items)
|
||||
ts.Run(IAMListUserPolicies_empty_result)
|
||||
ts.Run(IAMListUserPolicies_success)
|
||||
ts.Run(IAMListUserPolicies_pagination)
|
||||
}
|
||||
|
||||
func TestIAM(ts *TestState) {
|
||||
TestIAMAuth(ts)
|
||||
TestIAMQueryAuth(ts)
|
||||
@@ -1246,6 +1287,10 @@ func TestIAM(ts *TestState) {
|
||||
TestIAMDeleteAccessKey(ts)
|
||||
TestIAMGetAccessKeyLastUsed(ts)
|
||||
TestIAMListAccessKeys(ts)
|
||||
TestIAMPutUserPolicy(ts)
|
||||
TestIAMGetUserPolicy(ts)
|
||||
TestIAMDeleteUserPolicy(ts)
|
||||
TestIAMListUserPolicies(ts)
|
||||
}
|
||||
|
||||
func TestAccessControl(ts *TestState) {
|
||||
@@ -1674,6 +1719,35 @@ func GetIntTests() IntTests {
|
||||
"IAMListAccessKeys_empty_result": IAMListAccessKeys_empty_result,
|
||||
"IAMListAccessKeys_success": IAMListAccessKeys_success,
|
||||
"IAMListAccessKeys_pagination": IAMListAccessKeys_pagination,
|
||||
"IAMPutUserPolicy_missing_user_name": IAMPutUserPolicy_missing_user_name,
|
||||
"IAMPutUserPolicy_missing_policy_name": IAMPutUserPolicy_missing_policy_name,
|
||||
"IAMPutUserPolicy_missing_policy_document": IAMPutUserPolicy_missing_policy_document,
|
||||
"IAMPutUserPolicy_invalid_policy_name": IAMPutUserPolicy_invalid_policy_name,
|
||||
"IAMPutUserPolicy_long_policy_name": IAMPutUserPolicy_long_policy_name,
|
||||
"IAMPutUserPolicy_non_ascii_policy_document": IAMPutUserPolicy_non_ascii_policy_document,
|
||||
"IAMPutUserPolicy_non_existing_user": IAMPutUserPolicy_non_existing_user,
|
||||
"IAMPutUserPolicy_malformed_policy_document": IAMPutUserPolicy_malformed_policy_document,
|
||||
"IAMPutUserPolicy_principal_not_allowed": IAMPutUserPolicy_principal_not_allowed,
|
||||
"IAMPutUserPolicy_limit_exceeded": IAMPutUserPolicy_limit_exceeded,
|
||||
"IAMPutUserPolicy_success": IAMPutUserPolicy_success,
|
||||
"IAMPutUserPolicy_overwrite_updates_existing": IAMPutUserPolicy_overwrite_updates_existing,
|
||||
"IAMGetUserPolicy_missing_user_name": IAMGetUserPolicy_missing_user_name,
|
||||
"IAMGetUserPolicy_missing_policy_name": IAMGetUserPolicy_missing_policy_name,
|
||||
"IAMGetUserPolicy_non_existing_user": IAMGetUserPolicy_non_existing_user,
|
||||
"IAMGetUserPolicy_non_existing_policy": IAMGetUserPolicy_non_existing_policy,
|
||||
"IAMGetUserPolicy_success": IAMGetUserPolicy_success,
|
||||
"IAMDeleteUserPolicy_missing_user_name": IAMDeleteUserPolicy_missing_user_name,
|
||||
"IAMDeleteUserPolicy_missing_policy_name": IAMDeleteUserPolicy_missing_policy_name,
|
||||
"IAMDeleteUserPolicy_non_existing_user": IAMDeleteUserPolicy_non_existing_user,
|
||||
"IAMDeleteUserPolicy_non_existing_policy": IAMDeleteUserPolicy_non_existing_policy,
|
||||
"IAMDeleteUserPolicy_success": IAMDeleteUserPolicy_success,
|
||||
"IAMDeleteUserPolicy_blocks_user_deletion": IAMDeleteUserPolicy_blocks_user_deletion,
|
||||
"IAMListUserPolicies_missing_user_name": IAMListUserPolicies_missing_user_name,
|
||||
"IAMListUserPolicies_non_existing_user": IAMListUserPolicies_non_existing_user,
|
||||
"IAMListUserPolicies_invalid_max_items": IAMListUserPolicies_invalid_max_items,
|
||||
"IAMListUserPolicies_empty_result": IAMListUserPolicies_empty_result,
|
||||
"IAMListUserPolicies_success": IAMListUserPolicies_success,
|
||||
"IAMListUserPolicies_pagination": IAMListUserPolicies_pagination,
|
||||
"PresignedAuth_security_token_not_supported": PresignedAuth_security_token_not_supported,
|
||||
"PresignedAuth_unsupported_algorithm": PresignedAuth_unsupported_algorithm,
|
||||
"PresignedAuth_ECDSA_not_supported": PresignedAuth_ECDSA_not_supported,
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
|
||||
"github.com/aws/aws-sdk-go-v2/service/iam"
|
||||
"github.com/versity/versitygw/iamapi/iamerr"
|
||||
)
|
||||
|
||||
func IAMDeleteUserPolicy_missing_user_name(s *S3Conf) error {
|
||||
testName := "IAMDeleteUserPolicy_missing_user_name"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"DeleteUserPolicy"},
|
||||
"Version": {"2010-05-08"},
|
||||
"PolicyName": {"p"},
|
||||
}.Encode())
|
||||
return authHandler(s, &authConfig{
|
||||
testName: testName,
|
||||
method: http.MethodPost,
|
||||
service: "iam",
|
||||
region: iamAuthRegion,
|
||||
body: body,
|
||||
date: time.Now().UTC(),
|
||||
headers: map[string]string{
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
}, func(req *http.Request) error {
|
||||
return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMDeleteUserPolicy_missing_policy_name(s *S3Conf) error {
|
||||
testName := "IAMDeleteUserPolicy_missing_policy_name"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"DeleteUserPolicy"},
|
||||
"Version": {"2010-05-08"},
|
||||
"UserName": {newIAMUserName()},
|
||||
}.Encode())
|
||||
return authHandler(s, &authConfig{
|
||||
testName: testName,
|
||||
method: http.MethodPost,
|
||||
service: "iam",
|
||||
region: iamAuthRegion,
|
||||
body: body,
|
||||
date: time.Now().UTC(),
|
||||
headers: map[string]string{
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
}, func(req *http.Request) error {
|
||||
return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyName"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMDeleteUserPolicy_non_existing_user(s *S3Conf) error {
|
||||
testName := "IAMDeleteUserPolicy_non_existing_user"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
userName := "non-existing-" + genRandString(16)
|
||||
_, err := deleteIAMUserPolicyRaw(client, &iam.DeleteUserPolicyInput{
|
||||
UserName: &userName,
|
||||
PolicyName: aws.String("p"),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMDeleteUserPolicy_non_existing_policy(s *S3Conf) error {
|
||||
testName := "IAMDeleteUserPolicy_non_existing_policy"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
userName := newIAMUserName()
|
||||
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := checkIAMApiErr(
|
||||
func() error {
|
||||
_, err := deleteIAMUserPolicyRaw(client, &iam.DeleteUserPolicyInput{UserName: &userName, PolicyName: aws.String("missing")})
|
||||
return err
|
||||
}(),
|
||||
iamerr.NoSuchEntityUserPolicy(userName, "missing"),
|
||||
)
|
||||
|
||||
deleteErr := deleteIAMUser(client, userName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func IAMDeleteUserPolicy_success(s *S3Conf) error {
|
||||
testName := "IAMDeleteUserPolicy_success"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
userName := newIAMUserName()
|
||||
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{
|
||||
UserName: &userName,
|
||||
PolicyName: aws.String("p"),
|
||||
PolicyDocument: aws.String(validIAMPolicyDocument),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := deleteIAMUserPolicyRaw(client, &iam.DeleteUserPolicyInput{UserName: &userName, PolicyName: aws.String("p")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" {
|
||||
return fmt.Errorf("expected DeleteUserPolicy response request id")
|
||||
}
|
||||
|
||||
_, err = getIAMUserPolicy(client, &iam.GetUserPolicyInput{UserName: &userName, PolicyName: aws.String("p")})
|
||||
return checkIAMApiErr(err, iamerr.NoSuchEntityUserPolicy(userName, "p"))
|
||||
}()
|
||||
|
||||
deleteErr := deleteIAMUser(client, userName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func IAMDeleteUserPolicy_blocks_user_deletion(s *S3Conf) error {
|
||||
testName := "IAMDeleteUserPolicy_blocks_user_deletion"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
userName := newIAMUserName()
|
||||
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{
|
||||
UserName: &userName,
|
||||
PolicyName: aws.String("p"),
|
||||
PolicyDocument: aws.String(validIAMPolicyDocument),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := checkIAMApiErr(deleteIAMUser(client, userName), iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies))
|
||||
|
||||
deletePolicyErr := deleteIAMUserPolicy(client, userName, "p")
|
||||
deleteUserErr := deleteIAMUser(client, userName)
|
||||
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
if deletePolicyErr != nil {
|
||||
return deletePolicyErr
|
||||
}
|
||||
return deleteUserErr
|
||||
})
|
||||
}
|
||||
|
||||
func deleteIAMUserPolicyRaw(client *iam.Client, input *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
defer cancel()
|
||||
return client.DeleteUserPolicy(ctx, input)
|
||||
}
|
||||
|
||||
func deleteIAMUserPolicy(client *iam.Client, userName, policyName string) error {
|
||||
_, err := deleteIAMUserPolicyRaw(client, &iam.DeleteUserPolicyInput{UserName: &userName, PolicyName: &policyName})
|
||||
return err
|
||||
}
|
||||
|
||||
// deleteIAMUserAndPolicies deletes all of the user's inline policies before
|
||||
// deleting the user, since DeleteUser rejects users with policies still
|
||||
// attached. Use this for test cleanup after a test has created inline
|
||||
// policies.
|
||||
func deleteIAMUserAndPolicies(client *iam.Client, userName string) error {
|
||||
out, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, policyName := range out.PolicyNames {
|
||||
if err := deleteIAMUserPolicy(client, userName, policyName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return deleteIAMUser(client, userName)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
|
||||
"github.com/aws/aws-sdk-go-v2/service/iam"
|
||||
"github.com/versity/versitygw/iamapi/iamerr"
|
||||
)
|
||||
|
||||
func IAMGetUserPolicy_missing_user_name(s *S3Conf) error {
|
||||
testName := "IAMGetUserPolicy_missing_user_name"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"GetUserPolicy"},
|
||||
"Version": {"2010-05-08"},
|
||||
"PolicyName": {"p"},
|
||||
}.Encode())
|
||||
return authHandler(s, &authConfig{
|
||||
testName: testName,
|
||||
method: http.MethodPost,
|
||||
service: "iam",
|
||||
region: iamAuthRegion,
|
||||
body: body,
|
||||
date: time.Now().UTC(),
|
||||
headers: map[string]string{
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
}, func(req *http.Request) error {
|
||||
return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMGetUserPolicy_missing_policy_name(s *S3Conf) error {
|
||||
testName := "IAMGetUserPolicy_missing_policy_name"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"GetUserPolicy"},
|
||||
"Version": {"2010-05-08"},
|
||||
"UserName": {newIAMUserName()},
|
||||
}.Encode())
|
||||
return authHandler(s, &authConfig{
|
||||
testName: testName,
|
||||
method: http.MethodPost,
|
||||
service: "iam",
|
||||
region: iamAuthRegion,
|
||||
body: body,
|
||||
date: time.Now().UTC(),
|
||||
headers: map[string]string{
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
}, func(req *http.Request) error {
|
||||
return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyName"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMGetUserPolicy_non_existing_user(s *S3Conf) error {
|
||||
testName := "IAMGetUserPolicy_non_existing_user"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
userName := "non-existing-" + genRandString(16)
|
||||
_, err := getIAMUserPolicy(client, &iam.GetUserPolicyInput{
|
||||
UserName: &userName,
|
||||
PolicyName: aws.String("p"),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMGetUserPolicy_non_existing_policy(s *S3Conf) error {
|
||||
testName := "IAMGetUserPolicy_non_existing_policy"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
userName := newIAMUserName()
|
||||
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := checkIAMApiErr(
|
||||
func() error {
|
||||
_, err := getIAMUserPolicy(client, &iam.GetUserPolicyInput{UserName: &userName, PolicyName: aws.String("missing")})
|
||||
return err
|
||||
}(),
|
||||
iamerr.NoSuchEntityUserPolicy(userName, "missing"),
|
||||
)
|
||||
|
||||
deleteErr := deleteIAMUser(client, userName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func IAMGetUserPolicy_success(s *S3Conf) error {
|
||||
testName := "IAMGetUserPolicy_success"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
userName := newIAMUserName()
|
||||
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{
|
||||
UserName: &userName,
|
||||
PolicyName: aws.String("ReadOnly"),
|
||||
PolicyDocument: aws.String(validIAMPolicyDocument),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := getIAMUserPolicy(client, &iam.GetUserPolicyInput{UserName: &userName, PolicyName: aws.String("ReadOnly")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out == nil {
|
||||
return fmt.Errorf("expected GetUserPolicy output")
|
||||
}
|
||||
if aws.ToString(out.UserName) != userName {
|
||||
return fmt.Errorf("expected user name %q, instead got %q", userName, aws.ToString(out.UserName))
|
||||
}
|
||||
if aws.ToString(out.PolicyName) != "ReadOnly" {
|
||||
return fmt.Errorf("expected policy name %q, instead got %q", "ReadOnly", aws.ToString(out.PolicyName))
|
||||
}
|
||||
gotDocument, err := url.QueryUnescape(aws.ToString(out.PolicyDocument))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to url-decode policy document %q: %w", aws.ToString(out.PolicyDocument), err)
|
||||
}
|
||||
if gotDocument != validIAMPolicyDocument {
|
||||
return fmt.Errorf("expected policy document %q, instead got %q", validIAMPolicyDocument, gotDocument)
|
||||
}
|
||||
if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" {
|
||||
return fmt.Errorf("expected GetUserPolicy response request id")
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
deleteErr := deleteIAMUserAndPolicies(client, userName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func getIAMUserPolicy(client *iam.Client, input *iam.GetUserPolicyInput) (*iam.GetUserPolicyOutput, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
defer cancel()
|
||||
return client.GetUserPolicy(ctx, input)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
|
||||
"github.com/aws/aws-sdk-go-v2/service/iam"
|
||||
"github.com/versity/versitygw/iamapi/iamerr"
|
||||
)
|
||||
|
||||
func IAMListUserPolicies_missing_user_name(s *S3Conf) error {
|
||||
testName := "IAMListUserPolicies_missing_user_name"
|
||||
body := []byte("Action=ListUserPolicies&Version=2010-05-08")
|
||||
return authHandler(s, &authConfig{
|
||||
testName: testName,
|
||||
method: http.MethodPost,
|
||||
service: "iam",
|
||||
region: iamAuthRegion,
|
||||
body: body,
|
||||
date: time.Now().UTC(),
|
||||
headers: map[string]string{
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
}, func(req *http.Request) error {
|
||||
return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMListUserPolicies_non_existing_user(s *S3Conf) error {
|
||||
testName := "IAMListUserPolicies_non_existing_user"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
userName := "non-existing-" + genRandString(16)
|
||||
_, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName})
|
||||
return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMListUserPolicies_invalid_max_items(s *S3Conf) error {
|
||||
testName := "IAMListUserPolicies_invalid_max_items"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
userName := newIAMUserName()
|
||||
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := checkIAMApiErr(
|
||||
func() error {
|
||||
_, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName, MaxItems: aws.Int32(1001)})
|
||||
return err
|
||||
}(),
|
||||
iamerr.InvalidMaxItems("1001"),
|
||||
)
|
||||
|
||||
deleteErr := deleteIAMUser(client, userName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func IAMListUserPolicies_empty_result(s *S3Conf) error {
|
||||
testName := "IAMListUserPolicies_empty_result"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
userName := newIAMUserName()
|
||||
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
out, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(out.PolicyNames) != 0 {
|
||||
return fmt.Errorf("expected no policies, instead got %v", out.PolicyNames)
|
||||
}
|
||||
if out.IsTruncated {
|
||||
return fmt.Errorf("expected IsTruncated to be false")
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
deleteErr := deleteIAMUser(client, userName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func IAMListUserPolicies_success(s *S3Conf) error {
|
||||
testName := "IAMListUserPolicies_success"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
userName := newIAMUserName()
|
||||
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
want := []string{"Alpha", "Beta"}
|
||||
for _, name := range want {
|
||||
if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{
|
||||
UserName: &userName,
|
||||
PolicyName: aws.String(name),
|
||||
PolicyDocument: aws.String(validIAMPolicyDocument),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
out, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" {
|
||||
return fmt.Errorf("expected ListUserPolicies response request id")
|
||||
}
|
||||
got := slices.Clone(out.PolicyNames)
|
||||
slices.Sort(got)
|
||||
if !slices.Equal(got, want) {
|
||||
return fmt.Errorf("expected policy names %v, instead got %v", want, got)
|
||||
}
|
||||
if out.IsTruncated {
|
||||
return fmt.Errorf("expected IsTruncated to be false")
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
deleteErr := deleteIAMUserAndPolicies(client, userName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func IAMListUserPolicies_pagination(s *S3Conf) error {
|
||||
testName := "IAMListUserPolicies_pagination"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
userName := newIAMUserName()
|
||||
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
want := []string{"Alpha", "Beta", "Gamma"}
|
||||
for _, name := range want {
|
||||
if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{
|
||||
UserName: &userName,
|
||||
PolicyName: aws.String(name),
|
||||
PolicyDocument: aws.String(validIAMPolicyDocument),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
input := iam.ListUserPoliciesInput{UserName: &userName, MaxItems: aws.Int32(1)}
|
||||
var pages []*iam.ListUserPoliciesOutput
|
||||
for {
|
||||
out, err := listIAMUserPolicies(client, &input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pages = append(pages, out)
|
||||
if !out.IsTruncated {
|
||||
break
|
||||
}
|
||||
input.Marker = out.Marker
|
||||
}
|
||||
|
||||
if len(pages) != len(want) {
|
||||
return fmt.Errorf("expected %d pages, instead got %d", len(want), len(pages))
|
||||
}
|
||||
var got []string
|
||||
for i, page := range pages {
|
||||
if len(page.PolicyNames) != 1 {
|
||||
return fmt.Errorf("expected page %d to contain 1 policy, instead got %d", i+1, len(page.PolicyNames))
|
||||
}
|
||||
if page.IsTruncated != (i < len(pages)-1) {
|
||||
return fmt.Errorf("unexpected IsTruncated value on page %d", i+1)
|
||||
}
|
||||
got = append(got, page.PolicyNames...)
|
||||
}
|
||||
slices.Sort(got)
|
||||
if !slices.Equal(got, want) {
|
||||
return fmt.Errorf("expected policy names %v, instead got %v", want, got)
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
deleteErr := deleteIAMUserAndPolicies(client, userName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func listIAMUserPolicies(client *iam.Client, input *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
defer cancel()
|
||||
return client.ListUserPolicies(ctx, input)
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
|
||||
"github.com/aws/aws-sdk-go-v2/service/iam"
|
||||
"github.com/versity/versitygw/iamapi/iamerr"
|
||||
"github.com/versity/versitygw/iamapi/storage"
|
||||
)
|
||||
|
||||
const validIAMPolicyDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`
|
||||
|
||||
func IAMPutUserPolicy_missing_user_name(s *S3Conf) error {
|
||||
testName := "IAMPutUserPolicy_missing_user_name"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"PutUserPolicy"},
|
||||
"Version": {"2010-05-08"},
|
||||
"PolicyName": {"p"},
|
||||
"PolicyDocument": {validIAMPolicyDocument},
|
||||
}.Encode())
|
||||
return authHandler(s, &authConfig{
|
||||
testName: testName,
|
||||
method: http.MethodPost,
|
||||
service: "iam",
|
||||
region: iamAuthRegion,
|
||||
body: body,
|
||||
date: time.Now().UTC(),
|
||||
headers: map[string]string{
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
}, func(req *http.Request) error {
|
||||
return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMPutUserPolicy_missing_policy_name(s *S3Conf) error {
|
||||
testName := "IAMPutUserPolicy_missing_policy_name"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"PutUserPolicy"},
|
||||
"Version": {"2010-05-08"},
|
||||
"UserName": {newIAMUserName()},
|
||||
"PolicyDocument": {validIAMPolicyDocument},
|
||||
}.Encode())
|
||||
return authHandler(s, &authConfig{
|
||||
testName: testName,
|
||||
method: http.MethodPost,
|
||||
service: "iam",
|
||||
region: iamAuthRegion,
|
||||
body: body,
|
||||
date: time.Now().UTC(),
|
||||
headers: map[string]string{
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
}, func(req *http.Request) error {
|
||||
return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyName"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMPutUserPolicy_missing_policy_document(s *S3Conf) error {
|
||||
testName := "IAMPutUserPolicy_missing_policy_document"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"PutUserPolicy"},
|
||||
"Version": {"2010-05-08"},
|
||||
"UserName": {newIAMUserName()},
|
||||
"PolicyName": {"p"},
|
||||
}.Encode())
|
||||
return authHandler(s, &authConfig{
|
||||
testName: testName,
|
||||
method: http.MethodPost,
|
||||
service: "iam",
|
||||
region: iamAuthRegion,
|
||||
body: body,
|
||||
date: time.Now().UTC(),
|
||||
headers: map[string]string{
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
}, func(req *http.Request) error {
|
||||
return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyDocument"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMPutUserPolicy_invalid_policy_name(s *S3Conf) error {
|
||||
testName := "IAMPutUserPolicy_invalid_policy_name"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
_, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{
|
||||
UserName: aws.String(newIAMUserName()),
|
||||
PolicyName: aws.String("bad/name"),
|
||||
PolicyDocument: aws.String(validIAMPolicyDocument),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.InvalidUserName("policyName"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMPutUserPolicy_long_policy_name(s *S3Conf) error {
|
||||
testName := "IAMPutUserPolicy_long_policy_name"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
_, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{
|
||||
UserName: aws.String(newIAMUserName()),
|
||||
PolicyName: aws.String(strings.Repeat("p", 129)),
|
||||
PolicyDocument: aws.String(validIAMPolicyDocument),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.UserNameTooLong("policyName", 128))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMPutUserPolicy_non_ascii_policy_document(s *S3Conf) error {
|
||||
testName := "IAMPutUserPolicy_non_ascii_policy_document"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
_, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{
|
||||
UserName: aws.String(newIAMUserName()),
|
||||
PolicyName: aws.String("p"),
|
||||
PolicyDocument: aws.String("emoji\U0001F600test"),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.InvalidCharset("policyDocument"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMPutUserPolicy_non_existing_user(s *S3Conf) error {
|
||||
testName := "IAMPutUserPolicy_non_existing_user"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
userName := "non-existing-" + genRandString(16)
|
||||
_, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{
|
||||
UserName: &userName,
|
||||
PolicyName: aws.String("p"),
|
||||
PolicyDocument: aws.String(validIAMPolicyDocument),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMPutUserPolicy_malformed_policy_document(s *S3Conf) error {
|
||||
testName := "IAMPutUserPolicy_malformed_policy_document"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
cases := []struct {
|
||||
name string
|
||||
doc string
|
||||
wantErr iamerr.APIError
|
||||
}{
|
||||
{"invalid json syntax", `{not valid json`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")},
|
||||
{"empty object", `{}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")},
|
||||
{"invalid version", `{"Version":"2020-01-01","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")},
|
||||
{"missing statement", `{"Version":"2012-10-17"}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")},
|
||||
{"null statement", `{"Version":"2012-10-17","Statement":null}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")},
|
||||
{"empty statement array", `{"Version":"2012-10-17","Statement":[]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")},
|
||||
{"statement is a string", `{"Version":"2012-10-17","Statement":"hello"}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")},
|
||||
{"missing effect", `{"Version":"2012-10-17","Statement":[{"Action":"s3:GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")},
|
||||
{"invalid effect value", `{"Version":"2012-10-17","Statement":[{"Effect":"Maybe","Action":"s3:GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")},
|
||||
{"action and notaction both present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotAction":"s3:PutObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")},
|
||||
{"resource and notresource both present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","NotResource":"foo"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")},
|
||||
{"numeric action wrong type", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":123,"Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")},
|
||||
|
||||
{"missing action and notaction", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Policy statement must contain actions.")},
|
||||
|
||||
{"missing resource and notresource", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject"}]}`, iamerr.MalformedPolicyDocument("Policy statement must contain resources.")},
|
||||
{"empty resource array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":[]}]}`, iamerr.MalformedPolicyDocument("Policy statement must contain resources.")},
|
||||
|
||||
{"empty string action", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.")},
|
||||
{"action missing vendor colon", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.")},
|
||||
{"notaction missing vendor colon", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotAction":"GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.")},
|
||||
{"empty vendor prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":":GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Vendor is not valid")},
|
||||
{"vendor with invalid character", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam :Get","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Vendor iam is not valid")},
|
||||
|
||||
{"resource with no colon at all", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"invalid"}]}`, iamerr.MalformedPolicyDocument(`Resource invalid must be in ARN format or "*".`)},
|
||||
{"notresource with no colon at all", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotResource":"invalid"}]}`, iamerr.MalformedPolicyDocument(`Resource invalid must be in ARN format or "*".`)},
|
||||
{"resource with colon but no arn prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"s3::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument(`Partition "" is not valid for resource "arn::example-bucket/*:*:*:*".`)},
|
||||
{"resource with arn prefix but too few fields", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:awss3::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument("The policy failed legacy parsing")},
|
||||
{"resource with invalid partition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws2:s3:::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument(`Partition "aws2" is not valid for resource "arn:aws2:s3:::example-bucket/*".`)},
|
||||
|
||||
{"duplicate sid across statements", `{"Version":"2012-10-17","Statement":[{"Sid":"Dup","Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Sid":"Dup","Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Statement IDs (SID) in a single policy must be unique.")},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
if err := func() error {
|
||||
userName := newIAMUserName()
|
||||
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
|
||||
return fmt.Errorf("%s: %w", c.name, err)
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
_, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{
|
||||
UserName: &userName,
|
||||
PolicyName: aws.String("p"),
|
||||
PolicyDocument: aws.String(c.doc),
|
||||
})
|
||||
if err := checkIAMApiErr(err, c.wantErr); err != nil {
|
||||
return fmt.Errorf("%s: %w", c.name, err)
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
deleteErr := deleteIAMUser(client, userName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func IAMPutUserPolicy_principal_not_allowed(s *S3Conf) error {
|
||||
testName := "IAMPutUserPolicy_principal_not_allowed"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
userName := newIAMUserName()
|
||||
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
doc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"*"}]}`
|
||||
_, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{
|
||||
UserName: &userName,
|
||||
PolicyName: aws.String("p"),
|
||||
PolicyDocument: aws.String(doc),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.MalformedPolicyDocument("Policy document should not specify a principal."))
|
||||
}()
|
||||
|
||||
deleteErr := deleteIAMUser(client, userName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func IAMPutUserPolicy_limit_exceeded(s *S3Conf) error {
|
||||
testName := "IAMPutUserPolicy_limit_exceeded"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
userName := newIAMUserName()
|
||||
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
oversized := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 2000) + `","Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`
|
||||
_, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{
|
||||
UserName: &userName,
|
||||
PolicyName: aws.String("p"),
|
||||
PolicyDocument: aws.String(oversized),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.InlinePolicyQuotaExceeded("user", userName, storage.MaxInlinePolicyBytesPerUser))
|
||||
}()
|
||||
|
||||
deleteErr := deleteIAMUser(client, userName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func IAMPutUserPolicy_success(s *S3Conf) error {
|
||||
testName := "IAMPutUserPolicy_success"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
userName := newIAMUserName()
|
||||
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{
|
||||
UserName: &userName,
|
||||
PolicyName: aws.String("ReadOnly"),
|
||||
PolicyDocument: aws.String(validIAMPolicyDocument),
|
||||
})
|
||||
checkErr := func() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out == nil {
|
||||
return fmt.Errorf("expected PutUserPolicy output")
|
||||
}
|
||||
if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" {
|
||||
return fmt.Errorf("expected PutUserPolicy response request id")
|
||||
}
|
||||
|
||||
got, err := getIAMUserPolicy(client, &iam.GetUserPolicyInput{UserName: &userName, PolicyName: aws.String("ReadOnly")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gotDocument, err := url.QueryUnescape(aws.ToString(got.PolicyDocument))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to url-decode policy document %q: %w", aws.ToString(got.PolicyDocument), err)
|
||||
}
|
||||
if gotDocument != validIAMPolicyDocument {
|
||||
return fmt.Errorf("expected policy document %q, instead got %q", validIAMPolicyDocument, gotDocument)
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
deleteErr := deleteIAMUserAndPolicies(client, userName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func IAMPutUserPolicy_overwrite_updates_existing(s *S3Conf) error {
|
||||
testName := "IAMPutUserPolicy_overwrite_updates_existing"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
userName := newIAMUserName()
|
||||
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{
|
||||
UserName: &userName,
|
||||
PolicyName: aws.String("p"),
|
||||
PolicyDocument: aws.String(validIAMPolicyDocument),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updated := `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`
|
||||
if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{
|
||||
UserName: &userName,
|
||||
PolicyName: aws.String("p"),
|
||||
PolicyDocument: aws.String(updated),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
got, err := getIAMUserPolicy(client, &iam.GetUserPolicyInput{UserName: &userName, PolicyName: aws.String("p")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gotDocument, err := url.QueryUnescape(aws.ToString(got.PolicyDocument))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to url-decode policy document %q: %w", aws.ToString(got.PolicyDocument), err)
|
||||
}
|
||||
if gotDocument != updated {
|
||||
return fmt.Errorf("expected overwritten policy document %q, instead got %q", updated, gotDocument)
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
deleteErr := deleteIAMUserAndPolicies(client, userName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func putIAMUserPolicy(client *iam.Client, input *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
defer cancel()
|
||||
return client.PutUserPolicy(ctx, input)
|
||||
}
|
||||
Reference in New Issue
Block a user