mirror of
https://github.com/versity/versitygw.git
synced 2026-07-08 00:56:54 +00:00
b590ac9eca
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.
394 lines
13 KiB
Go
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[:])
|
|
}
|