Files
versitygw/iamapi/iamerr/errors.go
T
niksis02 b590ac9eca feat: add AWS-compatible standalone IAM service
Closes #1640

Add a standalone AWS IAM Query API implementation for managing IAM users through standard AWS SDKs and the AWS CLI.

Server usage

Start the IAM server with internal file-backed storage:

    mkdir -p /tmp/versitygw-iam
    ./versitygw --port 127.0.0.1:7070 --access user --secret pass iam --dir /tmp/versitygw-iam

Start the IAM server with Vault KV v2 storage using AppRole:

    VGW_IAM_VAULT_ROLE_SECRET=<role-secret> ./versitygw --port 127.0.0.1:7070 --access user --secret pass iam --vault-endpoint-url http://127.0.0.1:8200 --vault-auth-method approle --vault-role-id <role-id> --vault-mount-path kv --vault-secret-storage-path iam

Vault authentication also supports root tokens, separate authentication and secret-storage namespaces, custom mount paths, server certificate validation, and mutual TLS client certificates.

Configure the AWS CLI credentials used by the IAM server:

    export AWS_ACCESS_KEY_ID=user
    export AWS_SECRET_ACCESS_KEY=pass
    export AWS_DEFAULT_REGION=us-east-1

Implemented IAM actions

CreateUser creates an IAM user with an AWS-compatible ARN, generated AIDA user ID, creation timestamp, optional path, and tags. It validates usernames, paths, tag limits, reserved tag prefixes, duplicate tag keys, and existing users.

    aws --endpoint-url http://127.0.0.1:7070 iam create-user --user-name bob

    aws --endpoint-url http://127.0.0.1:7070 iam create-user --user-name bob --path /engineering/ --tags Key=team,Value=storage

GetUser returns a stored user or the root identity when requested without a username through the IAM Query API.

    aws --endpoint-url http://127.0.0.1:7070 iam get-user --user-name bob

ListUsers returns users in deterministic username order and supports path filtering, marker-based pagination, and MaxItems limits.

    aws --endpoint-url http://127.0.0.1:7070 iam list-users

    aws --endpoint-url http://127.0.0.1:7070 iam list-users --path-prefix /engineering/ --max-items 100

UpdateUser updates the username and/or path, recalculates the user ARN, and rejects conflicts with existing users.

    aws --endpoint-url http://127.0.0.1:7070 iam update-user --user-name bob --new-user-name robert --new-path /platform/

DeleteUser permanently removes an IAM user and returns AWS-compatible errors for missing users.

    aws --endpoint-url http://127.0.0.1:7070 iam delete-user --user-name robert

IAM protocol and authentication

- Support the AWS IAM Query protocol version 2010-05-08 over GET and POST form requests.
- Return AWS-compatible XML responses, error documents, status codes, request IDs, user metadata, and pagination fields.
- Authenticate root credentials with AWS Signature Version 4 for the IAM service in us-east-1.
- Support both Authorization-header and query-string SigV4 authentication.
- Validate credential scope, signed headers, timestamps, clock skew, content length, signatures, and unsupported signature or session-token modes.
- Add IAM-specific validation and error mapping for malformed requests, invalid actions, duplicate entities, missing users, throttling, and internal failures.

Storage implementations

- Add an internal JSON-backed store using iam.json and iam.json.backup with atomic temporary-file replacement, concurrent access protection, stable ordering, pagination, and persistence across restarts.
- Add a Vault KV v2 store with one secret per user, CAS-based duplicate protection, permanent deletion, AppRole reauthentication, namespace support, configurable authentication and KV mounts, root-token authentication, and TLS/mTLS configuration.
- Introduce a common Storer interface and require exactly one storage backend to be configured.

Server and embedding support

- Register the new `versitygw iam` command with environment-variable and CLI configuration for both storage backends.
- Add `embedgw.RunIAMAPI` and `IAMConfig` for embedding the IAM service in Go applications.

Gateway-level internal packages

- Add `internal/iamstore` as a reusable generic file-backed IAM persistence engine and migrate the existing gateway internal IAM service to it.
- Add `internal/sigv4auth` for shared SigV4 header and presigned-query parsing, canonical request generation, signature verification, and structured authentication errors.
- Refactor the S3 authentication paths to use the shared SigV4 implementation while preserving S3-specific error responses.
- Add `internal/httpctx` for shared Fiber context keys and AWS-style request ID handling.
- Add `internal/routekit` for shared query, form, and header route matchers.
- Add `internal/netutil` for reusable certificate storage, hostname-aware listeners, multi-address serving, TLS listeners, and UNIX socket handling.
- Update the custom SigV4 signer to honor an explicitly supplied signed-header list so unrelated headers do not alter IAM signatures.

Testing and CI

- Add AWS IAM SDK-based integration coverage for all supported user actions, header authentication, query authentication, validation, errors, filtering, and pagination.
- Split standalone IAM tests into `versitygw test iam` and retain existing gateway IAM tests under `versitygw test gw-iam`.
- Add unit coverage for controllers, authentication, routing, storage, embedding, listeners, request matching, persistence, and signing behavior.
- Add `runiamtests.sh` to exercise internal storage over HTTP and HTTPS plus Vault storage through AppRole.
- Add a dedicated IAM functional-test workflow with a Vault service and merged runtime coverage reporting.
- Include the IAM test runner in shellcheck and add the AWS IAM SDK dependency.
2026-07-02 23:02:02 +04:00

394 lines
13 KiB
Go

// 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 iamerr
import (
"crypto/sha256"
"encoding/base64"
"encoding/xml"
"fmt"
"net/http"
"strings"
"time"
)
const (
Namespace = "https://iam.amazonaws.com/doc/2010-05-08/"
AWSFaultNamespace = "http://webservices.amazon.com/AWSFault/2005-15-09"
)
type ErrorType string
const (
TypeSender ErrorType = "Sender"
TypeReceiver ErrorType = "Receiver"
)
type ErrorCode int
const (
ErrInternalFailure ErrorCode = iota
ErrSignatureDoesNotMatch
ErrMissingAuthenticationToken
ErrIncompleteSignature
ErrUnsupportedSignatureVersion
ErrMissingAuthorizationComponents
ErrIncorrectService
ErrInvalidCredentialDate
ErrInvalidTerminal
ErrUnsupportedQueryAlgorithm
ErrInvalidRegion
ErrMissingHostSignedHeader
ErrInvalidClientTokenID
ErrInvalidContentLength
ErrThrottling
ErrMissingUserNameValue
ErrTooManyTags
ErrInvalidPathPrefix
ErrDuplicateTagKeys
)
type APIError interface {
error
StatusCode() int
XMLBody(requestID string) []byte
}
type Error struct {
Type ErrorType
Code string
Message string
HTTPStatusCode int
XMLNamespace string
}
func (e Error) Error() string {
return e.Code + ": " + e.Message
}
func (e Error) StatusCode() int {
return e.HTTPStatusCode
}
func (e Error) XMLBody(requestID string) []byte {
namespace := e.XMLNamespace
if namespace == "" {
namespace = Namespace
}
body, err := xml.Marshal(struct {
XMLName xml.Name
Error errorXML
RequestID string `xml:"RequestId"`
}{
XMLName: xml.Name{Space: namespace, Local: "ErrorResponse"},
Error: errorXML{
Type: e.Type,
Code: e.Code,
Message: e.Message,
},
RequestID: requestID,
})
if err != nil {
return nil
}
return append([]byte(xml.Header), body...)
}
type errorXML struct {
Type ErrorType
Code string
Message string
}
var errorCodeResponse = map[ErrorCode]Error{
ErrInternalFailure: {
Type: TypeReceiver,
Code: "InternalFailure",
Message: "The request processing has failed because of an unknown error, exception or failure.",
HTTPStatusCode: http.StatusInternalServerError,
},
ErrInvalidContentLength: {
Type: TypeSender,
Code: "InvalidRequest",
Message: "Content-Length must be a valid integer.",
HTTPStatusCode: http.StatusBadRequest,
},
ErrThrottling: {
Type: TypeSender,
Code: "Throttling",
Message: "Rate exceeded.",
HTTPStatusCode: http.StatusBadRequest,
},
ErrMissingAuthenticationToken: {
Type: TypeSender,
Code: "MissingAuthenticationToken",
Message: "Request is missing Authentication Token",
HTTPStatusCode: http.StatusForbidden,
},
ErrUnsupportedQueryAlgorithm: {
Type: TypeSender,
Code: "MissingAuthenticationToken",
Message: "Missing Authentication Token",
HTTPStatusCode: http.StatusForbidden,
},
ErrInvalidClientTokenID: {
Type: TypeSender,
Code: "InvalidClientTokenId",
Message: "The security token included in the request is invalid.",
HTTPStatusCode: http.StatusForbidden,
},
ErrIncompleteSignature: {
Type: TypeSender,
Code: "IncompleteSignature",
Message: "The request signature does not conform to AWS standards.",
HTTPStatusCode: http.StatusBadRequest,
},
ErrUnsupportedSignatureVersion: {
Type: TypeSender,
Code: "IncompleteSignature",
Message: "AWS Signature Version 2 is not supported.",
HTTPStatusCode: http.StatusBadRequest,
},
ErrMissingAuthorizationComponents: {
Type: TypeSender,
Code: "IncompleteSignature",
Message: "Authorization header requires Credential, SignedHeaders, and Signature.",
HTTPStatusCode: http.StatusBadRequest,
},
ErrSignatureDoesNotMatch: {
Type: TypeSender,
Code: "SignatureDoesNotMatch",
Message: "The request signature we calculated does not match the signature you provided. Check your AWS Secret Access Key and signing method. Consult the service documentation for details.",
HTTPStatusCode: http.StatusForbidden,
},
ErrIncorrectService: {
Type: TypeSender,
Code: "SignatureDoesNotMatch",
Message: "Credential should be scoped to correct service: 'iam'.",
HTTPStatusCode: http.StatusBadRequest,
},
ErrInvalidCredentialDate: {
Type: TypeSender,
Code: "SignatureDoesNotMatch",
Message: "Date in Credential scope does not match YYYYMMDD from ISO-8601 version of date from HTTP.",
HTTPStatusCode: http.StatusBadRequest,
},
ErrInvalidTerminal: {
Type: TypeSender,
Code: "SignatureDoesNotMatch",
Message: "Credential should be scoped with a valid terminator: 'aws4_request'.",
HTTPStatusCode: http.StatusForbidden,
},
ErrInvalidRegion: {
Type: TypeSender,
Code: "SignatureDoesNotMatch",
Message: "Credential should be scoped to a valid region. ",
HTTPStatusCode: http.StatusForbidden,
},
ErrMissingHostSignedHeader: {
Type: TypeSender,
Code: "SignatureDoesNotMatch",
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",
Message: "The specified value for pathPrefix is invalid. It must begin with the / character and contain only alphanumeric characters and/or / characters.",
HTTPStatusCode: http.StatusBadRequest,
},
ErrTooManyTags: {
Type: TypeSender,
Code: "ValidationError",
Message: "1 validation error detected: Value at 'tags' failed to satisfy constraint: Member must have length less than or equal to 50",
HTTPStatusCode: http.StatusBadRequest,
},
ErrDuplicateTagKeys: {
Type: TypeSender,
Code: "InvalidInput",
Message: "Duplicate tag keys found. Please note that Tag keys are case insensitive.",
HTTPStatusCode: http.StatusBadRequest,
},
}
func GetAPIError(code ErrorCode) Error {
if err, ok := errorCodeResponse[code]; ok {
return err
}
return errorCodeResponse[ErrInternalFailure]
}
func InvalidAction(action, version string) Error {
err := newSenderError("InvalidAction", fmt.Sprintf("Could not find operation %s for version %s", action, version), http.StatusBadRequest)
err.XMLNamespace = AWSFaultNamespace
return err
}
func MissingParameter(parameter string) Error {
return newSenderError("MissingParameter", fmt.Sprintf("The request must contain the parameter %s.", parameter), http.StatusBadRequest)
}
func IncompleteSignatureMalformedComponent(component string) Error {
err := GetAPIError(ErrIncompleteSignature)
err.Message = fmt.Sprintf("Authorization component %q is malformed.", component)
return err
}
func IncompleteSignatureMalformedCredential(credential string) Error {
return newSenderError(
"IncompleteSignature",
fmt.Sprintf("Credential must have exactly 5 slash-delimited elements, e.g. keyid/date/region/service/term, got '%s'", credential),
http.StatusBadRequest,
)
}
func IncompleteSignatureMissingAuthorizationComponent(component, authorization string) Error {
err := GetAPIError(ErrIncompleteSignature)
err.Message = fmt.Sprintf("Authorization header requires '%s' parameter. (Hashed with SHA-256 and encoded with Base64) Authorization=%s",
component,
hashAuthorization(authorization))
return err
}
func IncompleteSignatureMissingQueryParameter(parameter string) Error {
err := GetAPIError(ErrIncompleteSignature)
err.Message = fmt.Sprintf("AWS query-string parameters must include '%s'. Re-examine the query-string parameters.", parameter)
return err
}
func IncompleteSignatureMissingDate(authorization string) Error {
return newSenderError(
"IncompleteSignature",
fmt.Sprintf("Authorization header requires existence of either a 'X-Amz-Date' or a 'Date' header. (Hashed with SHA-256 and encoded with Base64) Authorization=%s", hashAuthorization(authorization)),
http.StatusBadRequest,
)
}
func IncompleteSignatureInvalidXAmzDate(date string) Error {
return newSenderError(
"IncompleteSignature",
fmt.Sprintf("Date must be in ISO-8601 'basic format'. Got '%s'. See http://en.wikipedia.org/wiki/ISO_8601", date),
http.StatusBadRequest,
)
}
func IncompleteSignatureHeadersNotSigned(headers []string) Error {
err := GetAPIError(ErrIncompleteSignature)
err.Message = fmt.Sprintf("The request signature does not conform to AWS standards. Header(s) not signed: %s.", strings.Join(headers, ", "))
return err
}
func SignatureDoesNotMatchNotYetCurrent(requestTime, serverTime time.Time, allowedSkew time.Duration) Error {
err := GetAPIError(ErrSignatureDoesNotMatch)
err.Message = fmt.Sprintf("Signature not yet current: %s is still later than %s (%s + %d min.)",
requestTime.UTC().Format("20060102T150405Z"),
serverTime.UTC().Add(allowedSkew).Format("20060102T150405Z"),
serverTime.UTC().Format("20060102T150405Z"),
allowedSkew/time.Minute)
return err
}
func SignatureDoesNotMatchExpired(requestTime, serverTime time.Time, allowedSkew time.Duration) Error {
err := GetAPIError(ErrSignatureDoesNotMatch)
err.Message = fmt.Sprintf("Signature expired: %s is now earlier than %s (%s - %d min.)",
requestTime.UTC().Format("20060102T150405Z"),
serverTime.UTC().Add(-allowedSkew).Format("20060102T150405Z"),
serverTime.UTC().Format("20060102T150405Z"),
allowedSkew/time.Minute)
return err
}
func EntityAlreadyExistsUser(userName string) Error {
return newSenderError("EntityAlreadyExists", fmt.Sprintf("User with name %s already exists.", userName), http.StatusConflict)
}
func NoSuchEntityUser(userName string) Error {
return newSenderError("NoSuchEntity", fmt.Sprintf("The user with name %s cannot be found.", userName), http.StatusNotFound)
}
func ValidationError(message string) Error {
return newSenderError("ValidationError", message, http.StatusBadRequest)
}
func InvalidInput(message string) Error {
return newSenderError("InvalidInput", message, http.StatusBadRequest)
}
func InvalidUserName(field string) Error {
return ValidationError(fmt.Sprintf("The specified value for %s is invalid. It must contain only alphanumeric characters and/or the following: +=,.@_-", field))
}
func UserNameTooLong(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 InvalidPath(field string) Error {
return ValidationError(fmt.Sprintf("The specified value for %s is invalid. It must begin and end with / and contain only alphanumeric characters and/or / characters.", field))
}
func PathTooLong(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 InvalidMaxItems(value string) Error {
return ValidationError(fmt.Sprintf("1 validation error detected: Value '%s' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000", value))
}
func TagKeyTooLong(index int) Error {
return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'tags.%d.member.key' failed to satisfy constraint: Member must have length less than or equal to 128", index))
}
func InvalidTagKey(index int) Error {
return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'tags.%d.member.key' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]+", index))
}
func TagValueTooLong(index int) Error {
return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'tags.%d.member.value' failed to satisfy constraint: Member must have length less than or equal to 256", index))
}
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 newSenderError(code, message string, statusCode int) Error {
return Error{
Type: TypeSender,
Code: code,
Message: message,
HTTPStatusCode: statusCode,
}
}
func hashAuthorization(authorization string) string {
hash := sha256.Sum256([]byte(authorization))
return base64.StdEncoding.EncodeToString(hash[:])
}