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:
niksis02
2026-08-25 01:03:22 +04:00
parent 9b352a3f00
commit 3328501fea
20 changed files with 2645 additions and 78 deletions
+29
View File
@@ -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")
}
+48 -9
View File
@@ -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)
}