feat(iam): allow caller-supplied AccessKeyId and SecretAccessKey in CreateAccessKey (#9172)

* feat(iam): support caller-supplied AccessKeyId and SecretAccessKey in CreateAccessKey

Both IAM implementations (standalone and embedded) now check for
caller-supplied AccessKeyId and SecretAccessKey form parameters before
generating random credentials. If provided, the caller-supplied values
are used. If empty, random keys are generated as before.

This enables programmatic identity provisioning where the caller needs
to control the S3 credentials.

Backward-compatible: no behavior change for callers that omit these
parameters.

* refactor(iam): extract shared caller-supplied credential validation

Move the AccessKeyId/SecretAccessKey format checks and the in-memory
collision scan into weed/iam so the standalone IAM API, the embedded
IAM in s3api, and the admin dashboard all enforce the same rules.

- ValidateCallerSuppliedAccessKeyId: 4-128 alphanumeric (rejects
  SigV4-breaking characters like '/' and '=').
- ValidateCallerSuppliedSecretAccessKey: 8-128 chars.
- FindAccessKeyOwner: scans identities and service accounts and
  returns the owning entity type + name for debug logging, without
  exposing the owner in caller-facing error messages.

The admin dashboard previously only length-checked caller-supplied
keys; it now enforces the same alphanumeric rule, which matches what
SigV4 actually accepts anyway.

* fix(iam): reject partial caller-supplied AccessKeyId/SecretAccessKey

Previously, if a caller supplied only one of AccessKeyId or
SecretAccessKey, CreateAccessKey logged a warning and auto-generated
the missing half. That silently returns a credential the caller did
not fully choose, which is surprising and easy to miss in a response
they expected to echo back their input.

Return ErrCodeInvalidInputException instead: either both are supplied
or neither is. Updates the mixed-supply tests in weed/iamapi and
weed/s3api to assert the rejection.

* chore(iam): centralize and broaden sensitive form redaction

DoActions and ExecuteAction both had an inline loop that redacted
SecretAccessKey from their debug-level request log. Replace the two
copies with iam.RedactSensitiveFormValues, backed by an explicit
sensitive-keys set.

The set now also covers Password, OldPassword, NewPassword,
PrivateKey, and SessionToken. None of those parameters are used by
today's IAM actions, but naming them here makes the log-safety
guarantee survive future additions such as LoginProfile / STS.

* test(iam): cover the upper length bound for CreateAccessKey

TestCreateAccessKeyBoundary / TestEmbeddedIamCreateAccessKeyBoundary
only exercised the 3/4-char lower edge. Add cases for 128 (accepted)
and 129 (rejected) for AccessKeyId, plus 7 / 128 / 129-char cases
for SecretAccessKey, so both ends of the validator are locked in at
the handler level (the pure validators in weed/iam already cover
this).

* fix(s3api/iam): verify user existence before RNG and collision scan

In the embedded IAM CreateAccessKey, the user lookup ran last: a
request for a non-existent user still walked the whole identity /
service-account list for collisions and, if no caller-supplied keys
were present, generated fresh random credentials with crypto/rand
before the NoSuchEntity error finally surfaced.

Reorder: validate inputs, then find the target identity, then do the
collision scan, then generate keys. A missing user now fails fast and
consumes no entropy, and the handler returns NoSuchEntity instead of
a misleading EntityAlreadyExists when both the user is missing and
the supplied AccessKeyId happens to collide with another identity's
key.

Add TestEmbeddedIamCreateAccessKeyRejectsMissingUser to lock in the
"no mutation on unknown user" guarantee.

The standalone iamapi CreateAccessKey intentionally keeps its
pre-existing "create-or-attach" semantics where a missing user is
implicitly provisioned — that is a behavior change beyond the scope
of this PR.

* test(iam): tighten collision leak assertion and cover 8-char secret

- Rename the collision-owner identity in TestCreateAccessKeyRejectsCollision
  (both iamapi and the embedded s3api test) from "existing" / "ExistingUser"
  to "ownerAlpha". The old assert.NotContains check was effectively a no-op
  because the error message never contained those substrings; a distinctive
  name shared with no part of the expected error body makes the leak guard
  actually meaningful if the wording ever drifts. The embedded test also
  adds a NotContains assertion that was previously missing entirely.
- Add an explicit 8-char SecretAccessKey pass case to both boundary tests
  so the lower edge of the validator is locked in at the handler level
  alongside the 7 / 128 / 129-char cases.

* fix(iamapi): enforce both-or-none before the collision lookup

In the standalone IAM CreateAccessKey, FindAccessKeyOwner ran before
the partial-credential check. If a caller supplied only AccessKeyId
and it happened to collide with an existing key, the response was
EntityAlreadyExists instead of the more fundamental InvalidInput for
omitting SecretAccessKey — wrong error class, and leaked the fact
that the probed key is already in use.

Swap the order: validate both-or-none first, then do the collision
scan. Matches the embedded IAM path and AWS behavior.

Add a case to TestCreateAccessKeyRejectsPartialSupply that combines
partial supply with a collision to lock in the ordering.

* fix(admin): reject partial caller-supplied AccessKey/SecretKey

The admin dashboard path silently generated the missing half when a
caller supplied only one of AccessKey or SecretKey, while the IAM
API and embedded IAM paths now reject this. Align the three: if
exactly one is provided, return ErrInvalidInput.

Also simplifies the generator block — either both are provided or
neither is, so there is no mixed path to handle.

* test(s3api/iam): guard dereferences in caller-supplied-keys test

TestEmbeddedIamCreateAccessKeyWithCallerSuppliedKeys dereferenced
*AccessKeyId/*SecretAccessKey/*UserName and indexed
Identities[0].Credentials[0] without first verifying shape, so any
future regression that returns a partial response or skips the
config mutation would panic mid-assertion instead of failing with
a clear message.

Add require.NotNil on the response pointers and require.Len on
the identities/credentials slices before the asserts.

* test(iamapi): exercise the service-account branch of the collision check

FindAccessKeyOwner scans both Identities[*].Credentials and
ServiceAccounts[*].Credential, but TestCreateAccessKeyRejectsCollision
only covered the identity branch. Split the test into two subtests —
one per branch — so a future refactor that drops the service-account
scan (or mutates the existing credential) trips a failure.

Also asserts the existing service-account credential is not mutated
and no credential is attached to the target identity on rejection.

* test(iam): isolate 129-char secret subcase from prior credential

In both TestCreateAccessKeyBoundary (iamapi) and
TestEmbeddedIamCreateAccessKeyBoundary (s3api), the 129-char
SecretAccessKey subcase reused the "validkey" AccessKeyId that the
preceding 8-char subcase had just persisted into the config. The
test still asserted the right outcome because the handler validates
secret length before running the collision scan — but if the two
checks ever swap, the subcase would pass (or fail) for the wrong
reason.

Reset the in-memory credentials before the 129-char subcase, matching
the pattern already used by the 3/128/129-char AccessKeyId and
7-char secret subcases. No behavior change; purely test isolation.

---------

Co-authored-by: Chris Lu <chris.lu@gmail.com>
This commit is contained in:
Jon E Nesvold
2026-04-22 21:35:55 +02:00
committed by GitHub
parent fff243d463
commit c6302fcb54
9 changed files with 937 additions and 34 deletions
+16 -8
View File
@@ -247,21 +247,29 @@ func (s *AdminServer) CreateAccessKey(username string, req *CreateAccessKeyReque
req = &CreateAccessKeyRequest{}
}
// Validate provided keys
if req.AccessKey != "" && (len(req.AccessKey) < 4 || len(req.AccessKey) > 128) {
return nil, fmt.Errorf("access key must be between 4 and 128 characters: %w", ErrInvalidInput)
// Validate provided keys (shared with the IAM API and embedded IAM paths).
if req.AccessKey != "" {
if err := iam.ValidateCallerSuppliedAccessKeyId(req.AccessKey); err != nil {
return nil, fmt.Errorf("%s: %w", err.Error(), ErrInvalidInput)
}
}
if req.SecretKey != "" && (len(req.SecretKey) < 8 || len(req.SecretKey) > 128) {
return nil, fmt.Errorf("secret key must be between 8 and 128 characters: %w", ErrInvalidInput)
if req.SecretKey != "" {
if err := iam.ValidateCallerSuppliedSecretAccessKey(req.SecretKey); err != nil {
return nil, fmt.Errorf("%s: %w", err.Error(), ErrInvalidInput)
}
}
// Enforce the both-or-none rule to match the IAM API and embedded IAM
// paths — silently generating the missing half lets a caller end up
// with a credential they did not fully choose.
if (req.AccessKey != "") != (req.SecretKey != "") {
return nil, fmt.Errorf("access key and secret key must be supplied together: %w", ErrInvalidInput)
}
// Use provided keys or generate new ones
accessKey := req.AccessKey
secretKey := req.SecretKey
if accessKey == "" {
accessKey = generateAccessKey()
}
secretKey := req.SecretKey
if secretKey == "" {
secretKey = generateSecretKey()
}
+32
View File
@@ -0,0 +1,32 @@
package iam
import "net/url"
// sensitiveFormKeys is the set of IAM request form parameters whose values
// must never be written to logs. Matching is case-sensitive and uses the
// exact AWS IAM parameter name. Extend this when adding IAM actions that
// accept credentials, passwords, session tokens, or private keys.
var sensitiveFormKeys = map[string]struct{}{
"SecretAccessKey": {},
"Password": {},
"NewPassword": {},
"OldPassword": {},
"PrivateKey": {},
"SessionToken": {},
}
// RedactSensitiveFormValues returns a shallow copy of values with every
// sensitive key (see sensitiveFormKeys) replaced by "[REDACTED]". Intended
// for debug-level logging of IAM request forms so secrets do not leak into
// log sinks.
func RedactSensitiveFormValues(values url.Values) url.Values {
safe := make(url.Values, len(values))
for k, v := range values {
if _, sensitive := sensitiveFormKeys[k]; sensitive {
safe[k] = []string{"[REDACTED]"}
} else {
safe[k] = v
}
}
return safe
}
+33
View File
@@ -0,0 +1,33 @@
package iam
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
)
func TestRedactSensitiveFormValues(t *testing.T) {
in := url.Values{
"Action": []string{"CreateAccessKey"},
"UserName": []string{"alice"},
"AccessKeyId": []string{"AKIAALICE1"},
"SecretAccessKey": []string{"very-secret"},
"Password": []string{"hunter2"},
"NewPassword": []string{"new-hunter2"},
"OldPassword": []string{"old-hunter2"},
"PrivateKey": []string{"-----BEGIN-----"},
"SessionToken": []string{"tok"},
}
out := RedactSensitiveFormValues(in)
assert.Equal(t, []string{"CreateAccessKey"}, out["Action"])
assert.Equal(t, []string{"alice"}, out["UserName"])
assert.Equal(t, []string{"AKIAALICE1"}, out["AccessKeyId"])
for _, k := range []string{"SecretAccessKey", "Password", "NewPassword", "OldPassword", "PrivateKey", "SessionToken"} {
assert.Equal(t, []string{"[REDACTED]"}, out[k], "key %q should be redacted", k)
}
// Input is not mutated.
assert.Equal(t, []string{"very-secret"}, in["SecretAccessKey"])
}
+68
View File
@@ -0,0 +1,68 @@
package iam
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
// ValidateCallerSuppliedAccessKeyId checks that a caller-supplied AccessKeyId
// is 4 to 128 ASCII alphanumeric characters. Returns nil if valid.
//
// The alphanumeric restriction avoids characters that would break SigV4
// canonicalization (e.g. '/' and '=' appear as delimiters in Credential
// headers), so this is a stricter superset of the rule AWS enforces.
func ValidateCallerSuppliedAccessKeyId(accessKeyId string) error {
if len(accessKeyId) < 4 || len(accessKeyId) > 128 {
return fmt.Errorf("AccessKeyId must be 4 to 128 alphanumeric characters")
}
for _, r := range accessKeyId {
if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')) {
return fmt.Errorf("AccessKeyId must be 4 to 128 alphanumeric characters")
}
}
return nil
}
// ValidateCallerSuppliedSecretAccessKey checks that a caller-supplied
// SecretAccessKey is 8 to 128 characters. Returns nil if valid.
func ValidateCallerSuppliedSecretAccessKey(secretAccessKey string) error {
if len(secretAccessKey) < 8 || len(secretAccessKey) > 128 {
return fmt.Errorf("SecretAccessKey must be between 8 and 128 characters")
}
return nil
}
// AccessKeyOwner identifies which entity in an S3ApiConfiguration already owns
// a given AccessKeyId. Returned by FindAccessKeyOwner for collision checks on
// caller-supplied credentials.
type AccessKeyOwner struct {
// Type is "user" or "service account".
Type string
// Name is the identity's Name (for users) or the service account's Id.
Name string
}
// FindAccessKeyOwner scans s3cfg for an identity or service account whose
// credentials already contain accessKeyId. Returns nil if the key is free.
//
// Callers should log Name only at debug level — error responses returned to
// the caller should not include owner identity to avoid information leaks.
func FindAccessKeyOwner(s3cfg *iam_pb.S3ApiConfiguration, accessKeyId string) *AccessKeyOwner {
if s3cfg == nil || accessKeyId == "" {
return nil
}
for _, ident := range s3cfg.Identities {
for _, cred := range ident.Credentials {
if cred.AccessKey == accessKeyId {
return &AccessKeyOwner{Type: "user", Name: ident.Name}
}
}
}
for _, sa := range s3cfg.ServiceAccounts {
if sa.Credential != nil && sa.Credential.AccessKey == accessKeyId {
return &AccessKeyOwner{Type: "service account", Name: sa.Id}
}
}
return nil
}
+104
View File
@@ -0,0 +1,104 @@
package iam
import (
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/stretchr/testify/assert"
)
func TestValidateCallerSuppliedAccessKeyId(t *testing.T) {
cases := []struct {
name string
input string
wantErr bool
}{
{"empty", "", true},
{"three chars", "abc", true},
{"four chars ok", "abcd", false},
{"mixed case alnum", "MyAppKey123", false},
{"128 chars ok", strings.Repeat("a", 128), false},
{"129 chars too long", strings.Repeat("a", 129), true},
{"slash rejected", "foo/bar", true},
{"equals rejected", "foo=bar", true},
{"dash rejected", "foo-bar", true},
{"underscore rejected", "foo_bar", true},
{"unicode rejected", "fooö123", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateCallerSuppliedAccessKeyId(tc.input)
if tc.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestValidateCallerSuppliedSecretAccessKey(t *testing.T) {
cases := []struct {
name string
input string
wantErr bool
}{
{"empty", "", true},
{"seven chars", "abcdefg", true},
{"eight chars ok", "abcdefgh", false},
{"128 chars ok", strings.Repeat("a", 128), false},
{"129 chars too long", strings.Repeat("a", 129), true},
{"non-alnum allowed in secret", "sec/ret=1", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateCallerSuppliedSecretAccessKey(tc.input)
if tc.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestFindAccessKeyOwner(t *testing.T) {
s3cfg := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{
Name: "alice",
Credentials: []*iam_pb.Credential{
{AccessKey: "AKIAALICE1", SecretKey: "s"},
},
},
{Name: "bob"},
},
ServiceAccounts: []*iam_pb.ServiceAccount{
{Id: "svc-1", Credential: &iam_pb.Credential{AccessKey: "SVCKEY1"}},
{Id: "svc-2"},
},
}
t.Run("matches user", func(t *testing.T) {
owner := FindAccessKeyOwner(s3cfg, "AKIAALICE1")
assert.NotNil(t, owner)
assert.Equal(t, "user", owner.Type)
assert.Equal(t, "alice", owner.Name)
})
t.Run("matches service account", func(t *testing.T) {
owner := FindAccessKeyOwner(s3cfg, "SVCKEY1")
assert.NotNil(t, owner)
assert.Equal(t, "service account", owner.Type)
assert.Equal(t, "svc-1", owner.Name)
})
t.Run("no match", func(t *testing.T) {
assert.Nil(t, FindAccessKeyOwner(s3cfg, "NOTTAKEN"))
})
t.Run("empty key", func(t *testing.T) {
assert.Nil(t, FindAccessKeyOwner(s3cfg, ""))
})
t.Run("nil config", func(t *testing.T) {
assert.Nil(t, FindAccessKeyOwner(nil, "anything"))
})
}
+32 -7
View File
@@ -929,13 +929,38 @@ func (iama *IamApiServer) CreateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, valu
userName := values.Get("UserName")
status := iam.StatusTypeActive
accessKeyId, err := StringWithCharset(21, charsetUpper)
if err != nil {
return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to generate access key: %w", err)}
accessKeyId := values.Get("AccessKeyId")
secretAccessKey := values.Get("SecretAccessKey")
if accessKeyId != "" {
if err := iamlib.ValidateCallerSuppliedAccessKeyId(accessKeyId); err != nil {
return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: err}
}
}
secretAccessKey, err := StringWithCharset(42, charset)
if err != nil {
return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to generate secret key: %w", err)}
if secretAccessKey != "" {
if err := iamlib.ValidateCallerSuppliedSecretAccessKey(secretAccessKey); err != nil {
return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: err}
}
}
if (accessKeyId != "") != (secretAccessKey != "") {
return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("AccessKeyId and SecretAccessKey must be supplied together")}
}
if owner := iamlib.FindAccessKeyOwner(s3cfg, accessKeyId); owner != nil {
glog.V(4).Infof("CreateAccessKey: supplied AccessKeyId already in use by %s %s", owner.Type, owner.Name)
return resp, &IamError{Code: iam.ErrCodeEntityAlreadyExistsException, Error: fmt.Errorf("AccessKeyId is already in use")}
}
if accessKeyId == "" {
var err error
accessKeyId, err = StringWithCharset(21, charsetUpper)
if err != nil {
return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to generate access key: %w", err)}
}
}
if secretAccessKey == "" {
var err error
secretAccessKey, err = StringWithCharset(42, charset)
if err != nil {
return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to generate secret key: %w", err)}
}
}
resp.CreateAccessKeyResult.AccessKey.AccessKeyId = &accessKeyId
@@ -1085,7 +1110,7 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
return
}
glog.V(4).Infof("DoActions: %+v", values)
glog.V(4).Infof("DoActions: %+v", iamlib.RedactSensitiveFormValues(values))
var response iamlib.RequestIDSetter
changed := true
switch r.Form.Get("Action") {
@@ -3,6 +3,7 @@ package iamapi
import (
"encoding/json"
"net/url"
"strings"
"testing"
"github.com/aws/aws-sdk-go/service/iam"
@@ -629,3 +630,266 @@ func TestListAttachedUserPolicies(t *testing.T) {
assert.NotNil(t, iamErr)
assert.Equal(t, iam.ErrCodeNoSuchEntityException, iamErr.Code)
}
func TestCreateAccessKeyWithCallerSuppliedKeys(t *testing.T) {
iama := newTestIamApiServer(Policies{})
s3cfg := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{Name: "alice"}},
}
values := url.Values{
"UserName": []string{"alice"},
"AccessKeyId": []string{"myappkey"},
"SecretAccessKey": []string{"mysecret1234"},
}
resp, iamErr := iama.CreateAccessKey(s3cfg, values)
assert.Nil(t, iamErr)
assert.Equal(t, "myappkey", *resp.CreateAccessKeyResult.AccessKey.AccessKeyId)
assert.Equal(t, "mysecret1234", *resp.CreateAccessKeyResult.AccessKey.SecretAccessKey)
assert.Equal(t, "alice", *resp.CreateAccessKeyResult.AccessKey.UserName)
assert.Equal(t, "myappkey", s3cfg.Identities[0].Credentials[0].AccessKey)
assert.Equal(t, "mysecret1234", s3cfg.Identities[0].Credentials[0].SecretKey)
}
func TestCreateAccessKeyRandomGeneration(t *testing.T) {
iama := newTestIamApiServer(Policies{})
s3cfg := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{Name: "alice"}},
}
values := url.Values{
"UserName": []string{"alice"},
}
resp, iamErr := iama.CreateAccessKey(s3cfg, values)
assert.Nil(t, iamErr)
assert.NotEmpty(t, *resp.CreateAccessKeyResult.AccessKey.AccessKeyId)
assert.NotEmpty(t, *resp.CreateAccessKeyResult.AccessKey.SecretAccessKey)
assert.Len(t, s3cfg.Identities[0].Credentials, 1)
}
func TestCreateAccessKeyRejectsWeakKeys(t *testing.T) {
iama := newTestIamApiServer(Policies{})
s3cfg := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{Name: "alice"}},
}
// Too short
values := url.Values{
"UserName": []string{"alice"},
"AccessKeyId": []string{"ab"},
"SecretAccessKey": []string{"validsecret1"},
}
_, iamErr := iama.CreateAccessKey(s3cfg, values)
assert.NotNil(t, iamErr)
assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)
// Short secret
values = url.Values{
"UserName": []string{"alice"},
"AccessKeyId": []string{"validkey"},
"SecretAccessKey": []string{"short"},
}
_, iamErr = iama.CreateAccessKey(s3cfg, values)
assert.NotNil(t, iamErr)
assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)
// SigV4 delimiters
values = url.Values{
"UserName": []string{"alice"},
"AccessKeyId": []string{"foo/bar=baz"},
"SecretAccessKey": []string{"validsecret1"},
}
_, iamErr = iama.CreateAccessKey(s3cfg, values)
assert.NotNil(t, iamErr)
assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)
}
func TestCreateAccessKeyRejectsCollision(t *testing.T) {
iama := newTestIamApiServer(Policies{})
// Use a distinctive owner name ("ownerAlpha") that shares no substring
// with the expected error message so the leak assertion is meaningful.
const ownerName = "ownerAlpha"
t.Run("identity credential", func(t *testing.T) {
s3cfg := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{
Name: ownerName,
Credentials: []*iam_pb.Credential{
{AccessKey: "takenkey", SecretKey: "existingsecret"},
},
},
{Name: "newuser"},
},
}
values := url.Values{
"UserName": []string{"newuser"},
"AccessKeyId": []string{"takenkey"},
"SecretAccessKey": []string{"newsecret123"},
}
_, iamErr := iama.CreateAccessKey(s3cfg, values)
assert.NotNil(t, iamErr)
assert.Equal(t, iam.ErrCodeEntityAlreadyExistsException, iamErr.Code)
assert.NotContains(t, iamErr.Error.Error(), ownerName, "should not leak owner name")
assert.Len(t, s3cfg.Identities[1].Credentials, 0)
})
t.Run("service account credential", func(t *testing.T) {
const saId = "svcAlpha"
s3cfg := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{Name: "newuser"},
},
ServiceAccounts: []*iam_pb.ServiceAccount{
{
Id: saId,
Credential: &iam_pb.Credential{AccessKey: "takenkey", SecretKey: "existingsecret"},
},
},
}
values := url.Values{
"UserName": []string{"newuser"},
"AccessKeyId": []string{"takenkey"},
"SecretAccessKey": []string{"newsecret123"},
}
_, iamErr := iama.CreateAccessKey(s3cfg, values)
assert.NotNil(t, iamErr)
assert.Equal(t, iam.ErrCodeEntityAlreadyExistsException, iamErr.Code)
assert.NotContains(t, iamErr.Error.Error(), saId, "should not leak owner id")
// The service account's existing credential must be untouched, and
// no new credential should be attached to the identity.
assert.Equal(t, "takenkey", s3cfg.ServiceAccounts[0].Credential.AccessKey)
assert.Equal(t, "existingsecret", s3cfg.ServiceAccounts[0].Credential.SecretKey)
assert.Len(t, s3cfg.Identities[0].Credentials, 0)
})
}
func TestCreateAccessKeyBoundary(t *testing.T) {
iama := newTestIamApiServer(Policies{})
s3cfg := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{Name: "alice"}},
}
// Exactly 4 chars - should pass
values := url.Values{
"UserName": []string{"alice"},
"AccessKeyId": []string{"abcd"},
"SecretAccessKey": []string{"secretkey123"},
}
resp, iamErr := iama.CreateAccessKey(s3cfg, values)
assert.Nil(t, iamErr)
assert.Equal(t, "abcd", *resp.CreateAccessKeyResult.AccessKey.AccessKeyId)
// Exactly 3 chars - should fail
s3cfg.Identities[0].Credentials = nil
values = url.Values{
"UserName": []string{"alice"},
"AccessKeyId": []string{"abc"},
"SecretAccessKey": []string{"secretkey123"},
}
_, iamErr = iama.CreateAccessKey(s3cfg, values)
assert.NotNil(t, iamErr)
assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)
// Exactly 128 chars - should pass
s3cfg.Identities[0].Credentials = nil
ak128 := strings.Repeat("a", 128)
sk128 := strings.Repeat("s", 128)
values = url.Values{
"UserName": []string{"alice"},
"AccessKeyId": []string{ak128},
"SecretAccessKey": []string{sk128},
}
resp, iamErr = iama.CreateAccessKey(s3cfg, values)
assert.Nil(t, iamErr)
assert.Equal(t, ak128, *resp.CreateAccessKeyResult.AccessKey.AccessKeyId)
assert.Equal(t, sk128, *resp.CreateAccessKeyResult.AccessKey.SecretAccessKey)
// 129 chars AccessKeyId - should fail
s3cfg.Identities[0].Credentials = nil
values = url.Values{
"UserName": []string{"alice"},
"AccessKeyId": []string{strings.Repeat("a", 129)},
"SecretAccessKey": []string{sk128},
}
_, iamErr = iama.CreateAccessKey(s3cfg, values)
assert.NotNil(t, iamErr)
assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)
// 7-char SecretAccessKey - should fail
s3cfg.Identities[0].Credentials = nil
values = url.Values{
"UserName": []string{"alice"},
"AccessKeyId": []string{"validkey"},
"SecretAccessKey": []string{"1234567"},
}
_, iamErr = iama.CreateAccessKey(s3cfg, values)
assert.NotNil(t, iamErr)
assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)
// Exactly 8-char SecretAccessKey - should pass (lower boundary)
s3cfg.Identities[0].Credentials = nil
values = url.Values{
"UserName": []string{"alice"},
"AccessKeyId": []string{"validkey"},
"SecretAccessKey": []string{"12345678"},
}
resp, iamErr = iama.CreateAccessKey(s3cfg, values)
assert.Nil(t, iamErr)
assert.Equal(t, "12345678", *resp.CreateAccessKeyResult.AccessKey.SecretAccessKey)
// 129-char SecretAccessKey - should fail
s3cfg.Identities[0].Credentials = nil
values = url.Values{
"UserName": []string{"alice"},
"AccessKeyId": []string{"validkey"},
"SecretAccessKey": []string{strings.Repeat("s", 129)},
}
_, iamErr = iama.CreateAccessKey(s3cfg, values)
assert.NotNil(t, iamErr)
assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)
}
func TestCreateAccessKeyRejectsPartialSupply(t *testing.T) {
iama := newTestIamApiServer(Policies{})
s3cfg := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{Name: "alice"}},
}
// AccessKeyId supplied, SecretAccessKey omitted
values := url.Values{
"UserName": []string{"alice"},
"AccessKeyId": []string{"myappkey"},
}
_, iamErr := iama.CreateAccessKey(s3cfg, values)
assert.NotNil(t, iamErr)
assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)
assert.Len(t, s3cfg.Identities[0].Credentials, 0)
// SecretAccessKey supplied, AccessKeyId omitted
values = url.Values{
"UserName": []string{"alice"},
"SecretAccessKey": []string{"secretkey123"},
}
_, iamErr = iama.CreateAccessKey(s3cfg, values)
assert.NotNil(t, iamErr)
assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)
assert.Len(t, s3cfg.Identities[0].Credentials, 0)
// Partial supply wins over collision: only AccessKeyId supplied, and
// it matches an existing credential. We must see InvalidInput, not
// EntityAlreadyExists — the both-or-none rule is more fundamental.
s3cfg = &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{
Name: "ownerAlpha",
Credentials: []*iam_pb.Credential{
{AccessKey: "takenkey", SecretKey: "s"},
},
},
{Name: "alice"},
},
}
values = url.Values{
"UserName": []string{"alice"},
"AccessKeyId": []string{"takenkey"},
}
_, iamErr = iama.CreateAccessKey(s3cfg, values)
assert.NotNil(t, iamErr)
assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)
}
+48 -19
View File
@@ -410,32 +410,61 @@ func (e *EmbeddedIamApi) CreateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, value
userName := values.Get("UserName")
status := iam.StatusTypeActive
// Generate AWS-standard access key: AKIA prefix + 16 random uppercase chars = 20 total
randomPart, err := iamStringWithCharset(AccessKeyLength-len(UserAccessKeyPrefix), iamCharsetUpper)
if err != nil {
return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to generate access key: %w", err)}
accessKeyId := values.Get("AccessKeyId")
secretAccessKey := values.Get("SecretAccessKey")
if accessKeyId != "" {
if err := iamlib.ValidateCallerSuppliedAccessKeyId(accessKeyId); err != nil {
return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err}
}
}
accessKeyId := UserAccessKeyPrefix + randomPart
secretAccessKey, err := iamStringWithCharset(SecretKeyLength, iamCharset)
if err != nil {
return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to generate secret key: %w", err)}
if secretAccessKey != "" {
if err := iamlib.ValidateCallerSuppliedSecretAccessKey(secretAccessKey); err != nil {
return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err}
}
}
if (accessKeyId != "") != (secretAccessKey != "") {
return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("AccessKeyId and SecretAccessKey must be supplied together")}
}
// Find the target user before touching the RNG or scanning for collisions,
// so a missing user fails fast without consuming entropy.
var target *iam_pb.Identity
for _, ident := range s3cfg.Identities {
if userName == ident.Name {
target = ident
break
}
}
if target == nil {
return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)}
}
if owner := iamlib.FindAccessKeyOwner(s3cfg, accessKeyId); owner != nil {
glog.V(4).Infof("CreateAccessKey: supplied AccessKeyId already in use by %s %s", owner.Type, owner.Name)
return resp, &iamError{Code: iam.ErrCodeEntityAlreadyExistsException, Error: fmt.Errorf("AccessKeyId is already in use")}
}
if accessKeyId == "" {
randomPart, err := iamStringWithCharset(AccessKeyLength-len(UserAccessKeyPrefix), iamCharsetUpper)
if err != nil {
return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to generate access key: %w", err)}
}
accessKeyId = UserAccessKeyPrefix + randomPart
}
if secretAccessKey == "" {
var err error
secretAccessKey, err = iamStringWithCharset(SecretKeyLength, iamCharset)
if err != nil {
return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to generate secret key: %w", err)}
}
}
resp.CreateAccessKeyResult.AccessKey.AccessKeyId = &accessKeyId
resp.CreateAccessKeyResult.AccessKey.SecretAccessKey = &secretAccessKey
resp.CreateAccessKeyResult.AccessKey.UserName = &userName
resp.CreateAccessKeyResult.AccessKey.Status = &status
for _, ident := range s3cfg.Identities {
if userName == ident.Name {
ident.Credentials = append(ident.Credentials,
&iam_pb.Credential{AccessKey: accessKeyId, SecretKey: secretAccessKey, Status: iamAccessKeyStatusActive})
return resp, nil
}
}
// User not found - return error instead of implicitly creating the user
return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)}
target.Credentials = append(target.Credentials,
&iam_pb.Credential{AccessKey: accessKeyId, SecretKey: secretAccessKey, Status: iamAccessKeyStatusActive})
return resp, nil
}
// DeleteAccessKey deletes an access key for a user.
@@ -2102,7 +2131,7 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s
return nil, &iamError{Code: s3err.GetAPIError(s3err.ErrInternalError).Code, Error: fmt.Errorf("failed to get s3 api configuration: %v", err)}
}
glog.V(4).Infof("IAM ExecuteAction: %+v", values)
glog.V(4).Infof("IAM ExecuteAction: %+v", iamlib.RedactSensitiveFormValues(values))
var response iamlib.RequestIDSetter
changed := true
switch values.Get("Action") {
+340
View File
@@ -911,6 +911,346 @@ func TestEmbeddedIamCreateAccessKey(t *testing.T) {
assert.Len(t, api.mockConfig.Identities[0].Credentials, 1)
}
// TestEmbeddedIamCreateAccessKeyRejectsMissingUser verifies CreateAccessKey
// returns NoSuchEntity for an unknown user without mutating the config.
func TestEmbeddedIamCreateAccessKeyRejectsMissingUser(t *testing.T) {
api := NewEmbeddedIamApiForTest()
api.mockConfig = &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{Name: "ExistingUser"}},
}
form := url.Values{}
form.Set("Action", "CreateAccessKey")
form.Set("UserName", "GhostUser")
req, _ := http.NewRequest("POST", "/", nil)
req.PostForm = form
req.Form = form
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
apiRouter := mux.NewRouter().SkipClean(true)
apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions)
apiRouter.ServeHTTP(rr, req)
assert.NotEqual(t, http.StatusOK, rr.Code)
// No new identity and no credential appended to the existing one.
assert.Len(t, api.mockConfig.Identities, 1)
assert.Len(t, api.mockConfig.Identities[0].Credentials, 0)
}
// TestEmbeddedIamCreateAccessKeyWithCallerSuppliedKeys tests creating an access key with caller-supplied credentials
func TestEmbeddedIamCreateAccessKeyWithCallerSuppliedKeys(t *testing.T) {
api := NewEmbeddedIamApiForTest()
api.mockConfig = &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{Name: "TestUser"},
},
}
form := url.Values{}
form.Set("Action", "CreateAccessKey")
form.Set("UserName", "TestUser")
form.Set("AccessKeyId", "myapp")
form.Set("SecretAccessKey", "mysecret123")
req, _ := http.NewRequest("POST", "/", nil)
req.PostForm = form
req.Form = form
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
apiRouter := mux.NewRouter().SkipClean(true)
apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions)
apiRouter.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
// Verify caller-supplied keys were used, not random ones
var out iamCreateAccessKeyResponse
err := xml.Unmarshal(rr.Body.Bytes(), &out)
require.NoError(t, err, "failed to unmarshal CreateAccessKey response")
require.NotNil(t, out.CreateAccessKeyResult.AccessKey.AccessKeyId)
require.NotNil(t, out.CreateAccessKeyResult.AccessKey.SecretAccessKey)
require.NotNil(t, out.CreateAccessKeyResult.AccessKey.UserName)
assert.Equal(t, "myapp", *out.CreateAccessKeyResult.AccessKey.AccessKeyId)
assert.Equal(t, "mysecret123", *out.CreateAccessKeyResult.AccessKey.SecretAccessKey)
assert.Equal(t, "TestUser", *out.CreateAccessKeyResult.AccessKey.UserName)
// Verify credentials were persisted with caller-supplied keys
require.Len(t, api.mockConfig.Identities, 1)
require.Len(t, api.mockConfig.Identities[0].Credentials, 1)
assert.Equal(t, "myapp", api.mockConfig.Identities[0].Credentials[0].AccessKey)
assert.Equal(t, "mysecret123", api.mockConfig.Identities[0].Credentials[0].SecretKey)
}
// TestEmbeddedIamCreateAccessKeyRejectsWeakKeys tests that weak caller-supplied keys are rejected
func TestEmbeddedIamCreateAccessKeyRejectsWeakKeys(t *testing.T) {
api := NewEmbeddedIamApiForTest()
api.mockConfig = &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{Name: "TestUser"},
},
}
// AccessKeyId too short
form := url.Values{}
form.Set("Action", "CreateAccessKey")
form.Set("UserName", "TestUser")
form.Set("AccessKeyId", "ab")
form.Set("SecretAccessKey", "validsecret123")
req, _ := http.NewRequest("POST", "/", nil)
req.PostForm = form
req.Form = form
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
apiRouter := mux.NewRouter().SkipClean(true)
apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions)
apiRouter.ServeHTTP(rr, req)
assert.NotEqual(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), "AccessKeyId must be 4 to 128 alphanumeric characters")
// SecretAccessKey too short
form.Set("AccessKeyId", "validkey")
form.Set("SecretAccessKey", "short")
req, _ = http.NewRequest("POST", "/", nil)
req.PostForm = form
req.Form = form
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
apiRouter.ServeHTTP(rr, req)
assert.NotEqual(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), "SecretAccessKey must be between 8 and 128 characters")
// AccessKeyId with SigV4 delimiters
form.Set("AccessKeyId", "foo/bar=baz")
form.Set("SecretAccessKey", "validsecret123")
req, _ = http.NewRequest("POST", "/", nil)
req.PostForm = form
req.Form = form
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
apiRouter.ServeHTTP(rr, req)
assert.NotEqual(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), "AccessKeyId must be 4 to 128 alphanumeric characters")
}
// TestEmbeddedIamCreateAccessKeyRejectsCollision tests that duplicate access keys are rejected
func TestEmbeddedIamCreateAccessKeyRejectsCollision(t *testing.T) {
api := NewEmbeddedIamApiForTest()
// Use a distinctive owner name ("ownerAlpha") so the leak assertion
// cannot accidentally match a word embedded in the error body.
const ownerName = "ownerAlpha"
api.mockConfig = &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{
Name: ownerName,
Credentials: []*iam_pb.Credential{
{AccessKey: "takenkey", SecretKey: "existingsecret"},
},
},
{Name: "NewUser"},
},
}
form := url.Values{}
form.Set("Action", "CreateAccessKey")
form.Set("UserName", "NewUser")
form.Set("AccessKeyId", "takenkey")
form.Set("SecretAccessKey", "newsecret123")
req, _ := http.NewRequest("POST", "/", nil)
req.PostForm = form
req.Form = form
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
apiRouter := mux.NewRouter().SkipClean(true)
apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions)
apiRouter.ServeHTTP(rr, req)
assert.NotEqual(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), "already in use")
assert.NotContains(t, rr.Body.String(), ownerName, "should not leak owner name")
// Verify no credentials were added to NewUser
assert.Len(t, api.mockConfig.Identities[1].Credentials, 0)
}
// TestEmbeddedIamCreateAccessKeyRejectsPartialSupply tests that supplying only
// one of AccessKeyId / SecretAccessKey is rejected.
func TestEmbeddedIamCreateAccessKeyRejectsPartialSupply(t *testing.T) {
api := NewEmbeddedIamApiForTest()
api.mockConfig = &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{Name: "TestUser"},
},
}
apiRouter := mux.NewRouter().SkipClean(true)
apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions)
// AccessKeyId supplied, SecretAccessKey omitted
form := url.Values{}
form.Set("Action", "CreateAccessKey")
form.Set("UserName", "TestUser")
form.Set("AccessKeyId", "myappkey")
req, _ := http.NewRequest("POST", "/", nil)
req.PostForm = form
req.Form = form
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
apiRouter.ServeHTTP(rr, req)
assert.NotEqual(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), "AccessKeyId and SecretAccessKey must be supplied together")
assert.Len(t, api.mockConfig.Identities[0].Credentials, 0)
// SecretAccessKey supplied, AccessKeyId omitted
form = url.Values{}
form.Set("Action", "CreateAccessKey")
form.Set("UserName", "TestUser")
form.Set("SecretAccessKey", "validsecret1")
req, _ = http.NewRequest("POST", "/", nil)
req.PostForm = form
req.Form = form
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
apiRouter.ServeHTTP(rr, req)
assert.NotEqual(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), "AccessKeyId and SecretAccessKey must be supplied together")
assert.Len(t, api.mockConfig.Identities[0].Credentials, 0)
}
// TestEmbeddedIamCreateAccessKeyBoundary tests key length boundaries
func TestEmbeddedIamCreateAccessKeyBoundary(t *testing.T) {
api := NewEmbeddedIamApiForTest()
api.mockConfig = &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{Name: "TestUser"},
},
}
apiRouter := mux.NewRouter().SkipClean(true)
apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions)
// Exactly 4 chars — should pass
form := url.Values{}
form.Set("Action", "CreateAccessKey")
form.Set("UserName", "TestUser")
form.Set("AccessKeyId", "abcd")
form.Set("SecretAccessKey", "validsecret1")
req, _ := http.NewRequest("POST", "/", nil)
req.PostForm = form
req.Form = form
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
apiRouter.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
// Exactly 3 chars — should fail
api.mockConfig.Identities[0].Credentials = nil
form.Set("AccessKeyId", "abc")
req, _ = http.NewRequest("POST", "/", nil)
req.PostForm = form
req.Form = form
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
apiRouter.ServeHTTP(rr, req)
assert.NotEqual(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), "alphanumeric")
// Exactly 128 chars — should pass
api.mockConfig.Identities[0].Credentials = nil
ak128 := strings.Repeat("a", 128)
sk128 := strings.Repeat("s", 128)
form.Set("AccessKeyId", ak128)
form.Set("SecretAccessKey", sk128)
req, _ = http.NewRequest("POST", "/", nil)
req.PostForm = form
req.Form = form
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
apiRouter.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
// 129 chars AccessKeyId — should fail
api.mockConfig.Identities[0].Credentials = nil
form.Set("AccessKeyId", strings.Repeat("a", 129))
form.Set("SecretAccessKey", sk128)
req, _ = http.NewRequest("POST", "/", nil)
req.PostForm = form
req.Form = form
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
apiRouter.ServeHTTP(rr, req)
assert.NotEqual(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), "alphanumeric")
// 7-char SecretAccessKey — should fail
api.mockConfig.Identities[0].Credentials = nil
form.Set("AccessKeyId", "validkey")
form.Set("SecretAccessKey", "1234567")
req, _ = http.NewRequest("POST", "/", nil)
req.PostForm = form
req.Form = form
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
apiRouter.ServeHTTP(rr, req)
assert.NotEqual(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), "SecretAccessKey must be between 8 and 128 characters")
// Exactly 8-char SecretAccessKey — should pass (lower boundary)
api.mockConfig.Identities[0].Credentials = nil
form.Set("AccessKeyId", "validkey")
form.Set("SecretAccessKey", "12345678")
req, _ = http.NewRequest("POST", "/", nil)
req.PostForm = form
req.Form = form
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
apiRouter.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
// 129-char SecretAccessKey — should fail
api.mockConfig.Identities[0].Credentials = nil
form.Set("AccessKeyId", "validkey")
form.Set("SecretAccessKey", strings.Repeat("s", 129))
req, _ = http.NewRequest("POST", "/", nil)
req.PostForm = form
req.Form = form
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
apiRouter.ServeHTTP(rr, req)
assert.NotEqual(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), "SecretAccessKey must be between 8 and 128 characters")
}
// TestEmbeddedIamDeleteAccessKey tests deleting an access key via direct form post
func TestEmbeddedIamDeleteAccessKey(t *testing.T) {
api := NewEmbeddedIamApiForTest()