mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
fix: STS DurationSeconds and MFA member validation as AWS answers them (#1137)
This commit is contained in:
@@ -24,6 +24,10 @@ Spinifex implements **127 operations** in the EC2 `2016-11-15` API model.
|
||||
|
||||
Spot Instance Requests are a mock over the on-demand `RunInstances` path. A request synchronously launches real VMs on the operator's own compute and is then reported `active` and `fulfilled`. There is no spot market: no bidding, no price rejection, no interruption and no reclamation, and instances are never taken back.
|
||||
|
||||
### AMI Launch Permissions Are Refused
|
||||
|
||||
An AMI cannot be shared with another account or made public. `ModifyImageAttribute` refuses `LaunchPermission`, `UserIds`, `UserGroups`, `OrganizationArns`, `OrganizationalUnitArns` and `OperationType`, and `ResetImageAttribute` resets only `description`, not `launchPermission`. Each refusal is `InvalidParameterValue`. Terraform's `aws_ami_launch_permission` therefore cannot be created. `description` is the only image attribute that can be modified or reset.
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation |
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
### Spot Instances Are a Mock
|
||||
|
||||
Spot Instance Requests are a mock over the on-demand `RunInstances` path. A request synchronously launches real VMs on the operator's own compute and is then reported `active` and `fulfilled`. There is no spot market: no bidding, no price rejection, no interruption and no reclamation, and instances are never taken back.
|
||||
|
||||
### AMI Launch Permissions Are Refused
|
||||
|
||||
An AMI cannot be shared with another account or made public. `ModifyImageAttribute` refuses `LaunchPermission`, `UserIds`, `UserGroups`, `OrganizationArns`, `OrganizationalUnitArns` and `OperationType`, and `ResetImageAttribute` resets only `description`, not `launchPermission`. Each refusal is `InvalidParameterValue`. Terraform's `aws_ami_launch_permission` therefore cannot be created. `description` is the only image attribute that can be modified or reset.
|
||||
|
||||
@@ -54,7 +54,7 @@ A parameter whose omission would create a false safety, security or availability
|
||||
| `DBSecurityGroups` | EC2-Classic security groups — use `VpcSecurityGroupIds` |
|
||||
| `DBClusterIdentifier`, `DBClusterSnapshotIdentifier` | Clustered engines are not offered |
|
||||
| `EnableCloudwatchLogsExports` | Log export is not implemented |
|
||||
| `EngineVersion` other than the engine's pin, `Engine` on modify | No in-place engine or version change |
|
||||
| `EngineVersion` other than the engine's pin; on modify, any `EngineVersion` and `Engine` | No in-place engine or version change |
|
||||
| `Engine=mysql` (and Aurora engines) | Oracle MySQL is not offered; `mariadb` is a distinct engine, not an alias for it |
|
||||
| `NewDBInstanceIdentifier` | The identifier is the DNS label and the KV key |
|
||||
| `DBPortNumber`, `DBSubnetGroupName` on modify | Both would move the endpoint |
|
||||
@@ -67,6 +67,7 @@ A parameter whose omission would create a false safety, security or availability
|
||||
| `EnableCustomerOwnedIp` | An Outposts feature |
|
||||
| `ForceFailover` (reboot) | No standby to fail over to |
|
||||
| `DBSnapshotIdentifier` (stop) | Snapshot-on-stop is not implemented |
|
||||
| `DBInstanceAutomatedBackupsArn` (DescribeDBInstanceAutomatedBackups) | Cross-region automated backup replication is not offered, so no replicated backup has an ARN |
|
||||
| `dbi-resource-id` filter (DescribeDBSnapshots, DescribeDBInstanceAutomatedBackups) | Snapshots and automated backups do not record their source instance's resource ID |
|
||||
|
||||
### Operations
|
||||
|
||||
@@ -31,7 +31,7 @@ A parameter whose omission would create a false safety, security or availability
|
||||
| `DBSecurityGroups` | EC2-Classic security groups — use `VpcSecurityGroupIds` |
|
||||
| `DBClusterIdentifier`, `DBClusterSnapshotIdentifier` | Clustered engines are not offered |
|
||||
| `EnableCloudwatchLogsExports` | Log export is not implemented |
|
||||
| `EngineVersion` other than the engine's pin, `Engine` on modify | No in-place engine or version change |
|
||||
| `EngineVersion` other than the engine's pin; on modify, any `EngineVersion` and `Engine` | No in-place engine or version change |
|
||||
| `Engine=mysql` (and Aurora engines) | Oracle MySQL is not offered; `mariadb` is a distinct engine, not an alias for it |
|
||||
| `NewDBInstanceIdentifier` | The identifier is the DNS label and the KV key |
|
||||
| `DBPortNumber`, `DBSubnetGroupName` on modify | Both would move the endpoint |
|
||||
@@ -44,4 +44,5 @@ A parameter whose omission would create a false safety, security or availability
|
||||
| `EnableCustomerOwnedIp` | An Outposts feature |
|
||||
| `ForceFailover` (reboot) | No standby to fail over to |
|
||||
| `DBSnapshotIdentifier` (stop) | Snapshot-on-stop is not implemented |
|
||||
| `DBInstanceAutomatedBackupsArn` (DescribeDBInstanceAutomatedBackups) | Cross-region automated backup replication is not offered, so no replicated backup has an ARN |
|
||||
| `dbi-resource-id` filter (DescribeDBSnapshots, DescribeDBInstanceAutomatedBackups) | Snapshots and automated backups do not record their source instance's resource ID |
|
||||
|
||||
@@ -13,10 +13,8 @@ import (
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
@@ -75,10 +73,6 @@ const (
|
||||
// ASCII range. `:` and `/` are excluded so the assumed-role ARN stays unambiguously parseable.
|
||||
var roleSessionNameRegex = regexp.MustCompile(`^[A-Za-z0-9_+=,.@-]{2,64}$`)
|
||||
|
||||
// roleSessionNameCharsetRegex is roleSessionNameRegex without the length bound, so the
|
||||
// charset and length constraints are reported separately, as AWS does.
|
||||
var roleSessionNameCharsetRegex = regexp.MustCompile(`^[A-Za-z0-9_+=,.@-]*$`)
|
||||
|
||||
// AssumeRole mints temporary credentials after evaluating the target role's
|
||||
// trust policy against the caller.
|
||||
func (s *STSServiceImpl) AssumeRole(callerAccountID, callerARN, callerIdentity string, input *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) {
|
||||
@@ -94,28 +88,9 @@ func (s *STSServiceImpl) AssumeRole(callerAccountID, callerARN, callerIdentity s
|
||||
|
||||
// AWS reports every failed constraint at once, durationSeconds first, and before
|
||||
// resolving the role, so a bad value against a missing role is not AccessDenied.
|
||||
var violations []constraintViolation
|
||||
if d := input.DurationSeconds; d != nil {
|
||||
value := strconv.FormatInt(*d, 10)
|
||||
if *d < minDurationSeconds {
|
||||
violations = append(violations, constraintViolation{"durationSeconds", value,
|
||||
fmt.Sprintf("Member must have value greater than or equal to %d", minDurationSeconds)})
|
||||
} else if *d > maxDurationSeconds {
|
||||
violations = append(violations, constraintViolation{"durationSeconds", value,
|
||||
fmt.Sprintf("Member must have value less than or equal to %d", maxDurationSeconds)})
|
||||
}
|
||||
}
|
||||
if !roleSessionNameCharsetRegex.MatchString(sessionName) {
|
||||
violations = append(violations, constraintViolation{"roleSessionName", sessionName,
|
||||
`Member must satisfy regular expression pattern: [\w+=,.@-]*`})
|
||||
}
|
||||
if n := utf8.RuneCountInString(sessionName); n < minRoleSessionNameLength {
|
||||
violations = append(violations, constraintViolation{"roleSessionName", sessionName,
|
||||
fmt.Sprintf("Member must have length greater than or equal to %d", minRoleSessionNameLength)})
|
||||
} else if n > maxRoleSessionNameLength {
|
||||
violations = append(violations, constraintViolation{"roleSessionName", sessionName,
|
||||
fmt.Sprintf("Member must have length less than or equal to %d", maxRoleSessionNameLength)})
|
||||
}
|
||||
violations := durationViolations(input.DurationSeconds, maxDurationSeconds)
|
||||
violations = append(violations, roleSessionNameConstraint.check("roleSessionName", sessionName)...)
|
||||
violations = append(violations, mfaViolations(input.SerialNumber, input.TokenCode)...)
|
||||
if err := validationError(violations); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -128,7 +103,7 @@ func (s *STSServiceImpl) AssumeRole(callerAccountID, callerARN, callerIdentity s
|
||||
return nil, awserrors.Errorf(awserrors.ErrorValidationError,
|
||||
"Session tags are not supported in this release; omit Tags and TransitiveTagKeys")
|
||||
}
|
||||
if aws.StringValue(input.SerialNumber) != "" || aws.StringValue(input.TokenCode) != "" {
|
||||
if input.SerialNumber != nil || input.TokenCode != nil {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
|
||||
"MFA is not supported in this release; omit SerialNumber and TokenCode")
|
||||
}
|
||||
|
||||
@@ -497,8 +497,58 @@ func TestAssumeRole_RejectsMFA(t *testing.T) {
|
||||
requireMFANotSupported(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range mfaConstraintCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
input := basicAssumeRoleInput(*role.Arn, "sess")
|
||||
input.SerialNumber, input.TokenCode = tc.serialNumber, tc.tokenCode
|
||||
_, err := svc.AssumeRole(testCallerAccountID, testCallerARN(), testCallerUserName, input)
|
||||
requireAWSError(t, err, awserrors.ErrorValidationError, tc.wantMessage)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mfaConstraintCases break the model's serialNumber and tokenCode constraints, which
|
||||
// are checked before MFA is refused as unsupported.
|
||||
var mfaConstraintCases = func() []struct {
|
||||
name string
|
||||
serialNumber, tokenCode *string
|
||||
wantMessage string
|
||||
} {
|
||||
const (
|
||||
serialPattern = `Member must satisfy regular expression pattern: [\w+=/:,.@-]*`
|
||||
serialShort = "Member must have length greater than or equal to 9"
|
||||
serialLong = "Member must have length less than or equal to 256"
|
||||
tokenPattern = `Member must satisfy regular expression pattern: [\d]*`
|
||||
tokenShort = "Member must have length greater than or equal to 6"
|
||||
tokenLong = "Member must have length less than or equal to 6"
|
||||
)
|
||||
violation := func(field, value, constraint string) string {
|
||||
return fmt.Sprintf("Value '%s' at '%s' failed to satisfy constraint: %s", value, field, constraint)
|
||||
}
|
||||
one := func(field, value, constraint string) string {
|
||||
return "1 validation error detected: " + violation(field, value, constraint)
|
||||
}
|
||||
long := strings.Repeat("a", 257)
|
||||
return []struct {
|
||||
name string
|
||||
serialNumber, tokenCode *string
|
||||
wantMessage string
|
||||
}{
|
||||
{"serial_empty", aws.String(""), nil, one("serialNumber", "", serialShort)},
|
||||
{"serial_short", aws.String("GAHT1234"), nil, one("serialNumber", "GAHT1234", serialShort)},
|
||||
{"serial_long", aws.String(long), nil, one("serialNumber", long, serialLong)},
|
||||
{"serial_charset", aws.String("GAHT 12345"), nil, one("serialNumber", "GAHT 12345", serialPattern)},
|
||||
{"token_empty", nil, aws.String(""), one("tokenCode", "", tokenShort)},
|
||||
{"token_long", nil, aws.String("1234567"), one("tokenCode", "1234567", tokenLong)},
|
||||
{"token_charset", nil, aws.String("12345a"), one("tokenCode", "12345a", tokenPattern)},
|
||||
{"token_charset_and_short", nil, aws.String("abc"), "2 validation errors detected: " +
|
||||
violation("tokenCode", "abc", tokenPattern) + "; " + violation("tokenCode", "abc", tokenShort)},
|
||||
{"both", aws.String(""), aws.String(""), "2 validation errors detected: " +
|
||||
violation("serialNumber", "", serialShort) + "; " + violation("tokenCode", "", tokenShort)},
|
||||
}
|
||||
}()
|
||||
|
||||
func TestAssumeRole_RejectsInvalidSessionName(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
role := createRoleInAccount(t, svc, testCallerAccountID, "name", trustPolicyAllowingUser(testCallerARN()))
|
||||
|
||||
@@ -29,6 +29,13 @@ func (s *STSServiceImpl) GetSessionToken(callerAccountID, callerUserName, caller
|
||||
input = &sts.GetSessionTokenInput{}
|
||||
}
|
||||
|
||||
// Model constraints are checked before the caller, as AWS does for AssumeRole.
|
||||
violations := durationViolations(input.DurationSeconds, getSessionTokenMaxDuration)
|
||||
violations = append(violations, mfaViolations(input.SerialNumber, input.TokenCode)...)
|
||||
if err := validationError(violations); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if callerPrincipalType != principalTypeUser {
|
||||
slog.Warn("GetSessionToken denied: caller is not a long-lived user principal",
|
||||
"account_id", callerAccountID, "principal_type", callerPrincipalType)
|
||||
@@ -50,14 +57,14 @@ func (s *STSServiceImpl) GetSessionToken(callerAccountID, callerUserName, caller
|
||||
}
|
||||
|
||||
// MFA is out of scope: reject rather than silently ignore, so callers don't believe MFA was enforced.
|
||||
if aws.StringValue(input.SerialNumber) != "" || aws.StringValue(input.TokenCode) != "" {
|
||||
if input.SerialNumber != nil || input.TokenCode != nil {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
|
||||
"MFA is not supported in this release; omit SerialNumber and TokenCode")
|
||||
}
|
||||
|
||||
duration := getSessionTokenDefaultDuration
|
||||
if input.DurationSeconds != nil {
|
||||
duration = clampGetSessionTokenDuration(*input.DurationSeconds)
|
||||
duration = *input.DurationSeconds
|
||||
}
|
||||
|
||||
// Resolved here rather than threaded from the gateway: the gateway's value is
|
||||
@@ -100,18 +107,6 @@ func (s *STSServiceImpl) GetSessionToken(callerAccountID, callerUserName, caller
|
||||
}, nil
|
||||
}
|
||||
|
||||
// clampGetSessionTokenDuration coerces a duration into [900, 129600].
|
||||
// A nil DurationSeconds is handled by the caller; AWS clamps explicit values rather than rejecting them.
|
||||
func clampGetSessionTokenDuration(requested int64) int64 {
|
||||
if requested < minDurationSeconds {
|
||||
return minDurationSeconds
|
||||
}
|
||||
if requested > getSessionTokenMaxDuration {
|
||||
return getSessionTokenMaxDuration
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
// userEnvelope is the session envelope for a GetSessionToken user session:
|
||||
// PrincipalType "user", SessionName = the IAM user name, no assumed-role fields.
|
||||
func userEnvelope(accountID, userName, userID string) sessionEnvelope {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -79,29 +80,47 @@ func TestGetSessionToken_NilInput_DefaultsToTwelveHours(t *testing.T) {
|
||||
assertStoredDuration(t, svc, out, getSessionTokenDefaultDuration)
|
||||
}
|
||||
|
||||
func TestGetSessionToken_DurationClamp(t *testing.T) {
|
||||
func TestGetSessionToken_DurationBounds(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
seedUser(t, svc, testCallerAccountID, testCallerUserName)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
requested int64
|
||||
want int64
|
||||
}{
|
||||
{"below_minimum_clamps_up", minDurationSeconds - 1, minDurationSeconds},
|
||||
{"at_minimum", minDurationSeconds, minDurationSeconds},
|
||||
{"in_range", 7200, 7200},
|
||||
{"at_maximum", getSessionTokenMaxDuration, getSessionTokenMaxDuration},
|
||||
{"above_maximum_clamps_down", getSessionTokenMaxDuration + 1, getSessionTokenMaxDuration},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, seconds := range []int64{minDurationSeconds, 7200, getSessionTokenMaxDuration} {
|
||||
t.Run(fmt.Sprintf("accepts_%d", seconds), func(t *testing.T) {
|
||||
out, err := svc.GetSessionToken(testCallerAccountID, testCallerUserName, principalTypeUser, testCallerAccessKeyID,
|
||||
&sts.GetSessionTokenInput{DurationSeconds: aws.Int64(tc.requested)})
|
||||
&sts.GetSessionTokenInput{DurationSeconds: aws.Int64(seconds)})
|
||||
require.NoError(t, err)
|
||||
assertStoredDuration(t, svc, out, tc.want)
|
||||
assertStoredDuration(t, svc, out, seconds)
|
||||
})
|
||||
}
|
||||
|
||||
refused := []struct {
|
||||
seconds int64
|
||||
constraint string
|
||||
}{
|
||||
{minDurationSeconds - 1, "Member must have value greater than or equal to 900"},
|
||||
{getSessionTokenMaxDuration + 1, "Member must have value less than or equal to 129600"},
|
||||
}
|
||||
for _, tc := range refused {
|
||||
t.Run(fmt.Sprintf("refuses_%d", tc.seconds), func(t *testing.T) {
|
||||
out, err := svc.GetSessionToken(testCallerAccountID, testCallerUserName, principalTypeUser, testCallerAccessKeyID,
|
||||
&sts.GetSessionTokenInput{DurationSeconds: aws.Int64(tc.seconds)})
|
||||
requireAWSError(t, err, awserrors.ErrorValidationError, fmt.Sprintf(
|
||||
"1 validation error detected: Value '%d' at 'durationSeconds' failed to satisfy constraint: %s", tc.seconds, tc.constraint))
|
||||
assert.Nil(t, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A session caller is refused AccessDenied, but only once its input is valid.
|
||||
func TestGetSessionToken_ConstraintsCheckedBeforeCaller(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
|
||||
_, err := svc.GetSessionToken(testCallerAccountID, testCallerUserName, principalTypeUser, "ASIAEXAMPLEAAAAAAAAA",
|
||||
&sts.GetSessionTokenInput{DurationSeconds: aws.Int64(899), TokenCode: aws.String("")})
|
||||
requireAWSError(t, err, awserrors.ErrorValidationError,
|
||||
"2 validation errors detected: Value '899' at 'durationSeconds' failed to satisfy constraint: "+
|
||||
"Member must have value greater than or equal to 900; "+
|
||||
"Value '' at 'tokenCode' failed to satisfy constraint: Member must have length greater than or equal to 6")
|
||||
}
|
||||
|
||||
func TestGetSessionToken_RejectsNonUserAndSessionCallers(t *testing.T) {
|
||||
@@ -149,6 +168,15 @@ func TestGetSessionToken_RejectsMFAParameters(t *testing.T) {
|
||||
assert.Nil(t, out)
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range mfaConstraintCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := svc.GetSessionToken(testCallerAccountID, testCallerUserName, principalTypeUser, testCallerAccessKeyID,
|
||||
&sts.GetSessionTokenInput{SerialNumber: tc.serialNumber, TokenCode: tc.tokenCode})
|
||||
requireAWSError(t, err, awserrors.ErrorValidationError, tc.wantMessage)
|
||||
assert.Nil(t, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSessionToken_RejectsMissingUserName(t *testing.T) {
|
||||
|
||||
@@ -2,7 +2,10 @@ package handlers_sts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
)
|
||||
@@ -14,6 +17,70 @@ type constraintViolation struct {
|
||||
constraint string
|
||||
}
|
||||
|
||||
// stringConstraint is a model string shape: a pattern, as AWS prints it without
|
||||
// anchors, and a length range counted in characters.
|
||||
type stringConstraint struct {
|
||||
pattern *regexp.Regexp
|
||||
patternText string
|
||||
minLen, maxLen int
|
||||
}
|
||||
|
||||
var (
|
||||
// roleSessionNameConstraint is roleSessionNameRegex without the length bound, so
|
||||
// the charset and length constraints are reported separately, as AWS does.
|
||||
roleSessionNameConstraint = stringConstraint{
|
||||
regexp.MustCompile(`^[A-Za-z0-9_+=,.@-]*$`), `[\w+=,.@-]*`, minRoleSessionNameLength, maxRoleSessionNameLength,
|
||||
}
|
||||
serialNumberConstraint = stringConstraint{regexp.MustCompile(`^[A-Za-z0-9_+=/:,.@-]*$`), `[\w+=/:,.@-]*`, 9, 256}
|
||||
tokenCodeConstraint = stringConstraint{regexp.MustCompile(`^[0-9]*$`), `[\d]*`, 6, 6}
|
||||
)
|
||||
|
||||
// check reports value's pattern violation, then its length violation.
|
||||
func (c stringConstraint) check(field, value string) []constraintViolation {
|
||||
var violations []constraintViolation
|
||||
if !c.pattern.MatchString(value) {
|
||||
violations = append(violations, constraintViolation{field, value,
|
||||
"Member must satisfy regular expression pattern: " + c.patternText})
|
||||
}
|
||||
if n := utf8.RuneCountInString(value); n < c.minLen {
|
||||
violations = append(violations, constraintViolation{field, value,
|
||||
fmt.Sprintf("Member must have length greater than or equal to %d", c.minLen)})
|
||||
} else if n > c.maxLen {
|
||||
violations = append(violations, constraintViolation{field, value,
|
||||
fmt.Sprintf("Member must have length less than or equal to %d", c.maxLen)})
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
// durationViolations range-checks a present durationSeconds against [900, maxSeconds].
|
||||
func durationViolations(d *int64, maxSeconds int64) []constraintViolation {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
value := strconv.FormatInt(*d, 10)
|
||||
if *d < minDurationSeconds {
|
||||
return []constraintViolation{{"durationSeconds", value,
|
||||
fmt.Sprintf("Member must have value greater than or equal to %d", minDurationSeconds)}}
|
||||
}
|
||||
if *d > maxSeconds {
|
||||
return []constraintViolation{{"durationSeconds", value,
|
||||
fmt.Sprintf("Member must have value less than or equal to %d", maxSeconds)}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mfaViolations checks the MFA members when present, an empty value included.
|
||||
func mfaViolations(serialNumber, tokenCode *string) []constraintViolation {
|
||||
var violations []constraintViolation
|
||||
if serialNumber != nil {
|
||||
violations = append(violations, serialNumberConstraint.check("serialNumber", *serialNumber)...)
|
||||
}
|
||||
if tokenCode != nil {
|
||||
violations = append(violations, tokenCodeConstraint.check("tokenCode", *tokenCode)...)
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
// validationError renders violations as the single ValidationError AWS returns for
|
||||
// failed model constraints, in the order given, or nil when there are none.
|
||||
func validationError(violations []constraintViolation) error {
|
||||
|
||||
@@ -35,11 +35,11 @@ A rejection that succeeds is always a finding. A refusal counts as a pass only w
|
||||
|
||||
Request findings and undeclared errors block only for services listed under `promotedRequestServices` in `conformance-promoted-services.json`, which starts empty; response promotion does not carry over.
|
||||
|
||||
The 2026-09-29 baseline sent 4047 requests across 371 operations: 980 pass, 506 findings, 96 undeclared errors, 2465 inconclusive. None of it had been checked against AWS, so each finding below is a lead to verify before any fix. Since then, EC2 range-checks `MaxResults` on six `Describe*` calls and refuses a tag resource type the operation cannot tag, as AWS answers both, which clears 12 EC2 findings. IAM tag and untag calls now refuse a tag with no value or outside the model's key and value character sets, count tag lengths in characters rather than bytes, treat keys that differ only in case as one key on users and roles but as two elsewhere, and refuse the reserved `aws:` prefix, each as AWS answered it. IAM also checks tags on every create, `PathPrefix` on every list call (and `ListAttached*Policies` now filters by it), the `Scope` and `PolicyUsageFilter` enums, role and policy `Description` length, role `MaxSessionDuration` against 3600–43200, OIDC client ID and thumbprint lengths, `ListEntitiesForPolicy` `PolicyArn` length and an empty `CreatePolicy` `Path`, and answers name and path length breaks with `ValidationError`, each verified against AWS. Every finding is traced to its cause, and grouped into fix items, in the mulga plan `docs/development/bugs/aws-integration-conformance-findings.md`.
|
||||
The 2026-09-29 baseline sent 4047 requests across 371 operations: 980 pass, 506 findings, 96 undeclared errors, 2465 inconclusive. None of it had been checked against AWS, so each finding below is a lead to verify before any fix. Since then, EC2 range-checks `MaxResults` on six `Describe*` calls and refuses a tag resource type the operation cannot tag, as AWS answers both, which clears 12 EC2 findings. IAM tag and untag calls now refuse a tag with no value or outside the model's key and value character sets, count tag lengths in characters rather than bytes, treat keys that differ only in case as one key on users and roles but as two elsewhere, and refuse the reserved `aws:` prefix, each as AWS answered it. IAM also checks tags on every create, `PathPrefix` on every list call (and `ListAttached*Policies` now filters by it), the `Scope` and `PolicyUsageFilter` enums, role and policy `Description` length, role `MaxSessionDuration` against 3600–43200, OIDC client ID and thumbprint lengths, `ListEntitiesForPolicy` `PolicyArn` length and an empty `CreatePolicy` `Path`, and answers name and path length breaks with `ValidationError`, each verified against AWS. STS `GetSessionToken` refuses an out-of-range `DurationSeconds` rather than clamping it, as AWS answers `AssumeRole`'s, and both calls check `SerialNumber` and `TokenCode` against the model's length and pattern before refusing MFA. Every finding is traced to its cause, and grouped into fix items, in the mulga plan `docs/development/archive/bugs/aws-integration-conformance-findings.md`.
|
||||
|
||||
| Finding | Count | Notes |
|
||||
|---|---:|---|
|
||||
| A request breaking a model constraint is accepted | 420 | ECS 116, IAM 112, ACM 56, ELBv2 48, ECR 47, EC2 22, RDS 7, STS 6, EKS 6. Examples: ACM and ELBv2 accept a tag without `Key`, as IAM did; EC2 network `Describe*` accepted out-of-range `MaxResults`; ECS `CreateCapacityProvider` accepts every enum and range break inside `autoScalingGroupProvider` and `managedInstancesProvider`; ACM `RequestCertificate` accepts every break inside `DomainValidationOptions`; STS `GetSessionToken` accepts out-of-range `DurationSeconds` and a `SerialNumber` and `TokenCode` below their minimum length. |
|
||||
| A request breaking a model constraint is accepted | 420 | ECS 116, IAM 112, ACM 56, ELBv2 48, ECR 47, EC2 22, RDS 7, STS 6, EKS 6. Examples: ACM and ELBv2 accept a tag without `Key`, as IAM did; EC2 network `Describe*` accepted out-of-range `MaxResults`; ECS `CreateCapacityProvider` accepts every enum and range break inside `autoScalingGroupProvider` and `managedInstancesProvider`; ACM `RequestCertificate` accepts every break inside `DomainValidationOptions`; STS `GetSessionToken` accepted out-of-range `DurationSeconds` and a `SerialNumber` and `TokenCode` below their minimum length, and still accepts an out-of-range `MinimumSessionTokenSize`. |
|
||||
| A model-valid request is refused as invalid | 74 | RDS 42, ECS 10, STS 10, EC2 8, EKS 2, IAM 2. Mostly deliberate limitations that say so: RDS `CreateDBInstance`, `ModifyDBInstance` and `RestoreDBInstanceFromDBSnapshot` members, STS session policies, tags and MFA, ECS `availabilityZoneRebalancing`. Others return a generic message whose cause is unresolved (ECS task and task-definition operations, EC2 `Modify*Attribute`), or add a member next to a seeded one that AWS may refuse in combination, such as EC2 `CreateCapacityReservation` `AvailabilityZoneId` beside `AvailabilityZone`. |
|
||||
| A refusal uses an undeclared error code | 96 | ECR (90) and EKS (2) answer with `InvalidParameterValueException`; both models declare `InvalidParameterException`. IAM (4) answered `CreateGroup` and `Put*Policy` name-length breaks with `InvalidInput`, which those operations do not declare; they now answer `ValidationError`, as AWS does. |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user