iam: optionally require an iam: grant for self-service access-key actions (#11937)

* iam: optionally require an iam: grant for self-service access-key actions

AuthorizeIamAction lets every authenticated identity run CreateAccessKey,
DeleteAccessKey, UpdateAccessKey, ListAccessKeys and GetUser on itself with
no grant. On AWS these actions need an IAM policy (the usual one grants them
on arn:aws:iam::*:user/${aws:username}); nothing is allowed implicitly.

Where identities hold data-plane rights only, the implicit carve-out lets a
leaked key mint further keys for its own identity, with no cap, that keep
working after the operator deletes the key that leaked; it can also
deactivate or delete the keys the operator issued.

A new option, off by default, makes these actions need the iam:<Action> grant
like every other IAM action: -s3.iam.selfServiceRequiresGrant on weed
server, filer and mini, -iam.selfServiceRequiresGrant on weed s3. With it
set, the grant is authorized against the target user's ARN, the caller's own
when UserName is empty, so a policy can confine it to the caller's own keys
(Resource arn:aws:iam:::user/${aws:username}), the same resource scoping
CreateServiceAccount already uses. The admin and iam:* grants are unchanged;
with the option off nothing changes.

Tests (s3api_embedded_iam_authz_test.go): with the option set, an identity
holding only s3:* and a bare identity are refused all five actions on an
empty, own and other target (fails without the change); a grant on the
caller's own user ARN, literal or ${aws:username}, allows them on itself and
not on another user; the admin and an iam:* grant keep every action; with the
option off self-service is allowed as before.

* iam: wire selfServiceRequiresGrant on the standalone iam server

Every other command that runs the embedded IAM API accepts the flag; the
deprecated weed iam could not enable the enforcement at all.

* iam: wire selfServiceRequiresGrant on weed server and filer -iam

The IamOptions used by 'weed server -iam' and 'weed filer -iam' never
received the flag, so startIamServer dereferenced a nil pointer at
startup. Share the -s3.iam.selfServiceRequiresGrant pointer with them.

---------

Co-authored-by: Javier de la Torre <jatorre@lakehousebox.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-21 20:06:30 -07:00
1 parent ea859a0844
commit 1aad4cfe70
10 files changed
+129 -19

No files matched your search

+6
View File
@@ -69,6 +69,11 @@ type IdentityAccessManagement struct {
// IAM Integration for advanced features
iamIntegration IAMIntegration
// selfServiceRequiresGrant makes the self-service IAM actions (an
// identity's own access keys, GetUser on itself) need an iam:<Action>
// grant like every other IAM action. Off by default.
selfServiceRequiresGrant bool
// Serializes resyncs of the policy set into the advanced IAM manager so that
// concurrent policy updates can't apply stale views out of order. See
// resyncIAMManagerPolicies.
@@ -337,6 +342,7 @@ func NewIdentityAccessManagementWithStore(option *S3ApiServerOption, filerClient
hashCounters: make(map[string]*int32),
filerClient: filerClient,
}
iam.selfServiceRequiresGrant = option.IamSelfServiceRequiresGrant
// Always initialize credential manager with fallback to defaults
credentialManager, err := credential.NewCredentialManagerWithDefaults(credential.CredentialStoreTypeName(explicitStore))
+15 -5
View File
@@ -2609,8 +2609,8 @@ func (e *EmbeddedIamApi) handleImplicitUsername(r *http.Request, values url.Valu
}
}
// iamSelfServiceActions are actions that users can perform on their own resources without admin rights.
// According to AWS IAM, users can manage their own access keys without requiring full admin permissions.
// iamSelfServiceActions are actions that users can perform on their own resources without admin rights,
// unless selfServiceRequiresGrant is set, in which case they need an iam:<Action> grant like any other.
var iamSelfServiceActions = map[string]bool{
"CreateAccessKey": true,
"DeleteAccessKey": true,
@@ -2656,15 +2656,18 @@ func iamTargetUserName(action string, r *http.Request) string {
// data-plane policy, which says nothing about administering the credential
// store.
//
// Users may run self-service actions against their own identity without any
// IAM grant, matching AWS.
// By default users may run self-service actions against their own identity
// without any IAM grant. With selfServiceRequiresGrant set they need the
// iam:<Action> grant as AWS requires, authorized against the target user's
// ARN (the caller's own when UserName is empty), so a policy can confine it
// to the caller's own keys.
func (iam *IdentityAccessManagement) AuthorizeIamAction(r *http.Request, identity *Identity, action, targetUserName string) s3err.ErrorCode {
// The anonymous identity has no user of its own, so every self-service
// action it names would run against someone else's.
if identity == nil || identity.Name == s3_constants.AccountAnonymousId {
return s3err.ErrAccessDenied
}
if iamRequiresAdminForOthers(action) && (targetUserName == "" || targetUserName == identity.Name) {
if !iam.selfServiceRequiresGrant && iamRequiresAdminForOthers(action) && (targetUserName == "" || targetUserName == identity.Name) {
return s3err.ErrNone
}
if identity.isAdmin() {
@@ -2677,6 +2680,13 @@ func (iam *IdentityAccessManagement) AuthorizeIamAction(r *http.Request, identit
if iamRequiresSelfTarget(action) && targetUserName != "" {
resource = fmt.Sprintf("arn:aws:iam:::user/%s", targetUserName)
}
if iam.selfServiceRequiresGrant && iamRequiresAdminForOthers(action) {
target := targetUserName
if target == "" {
target = identity.Name
}
resource = fmt.Sprintf("arn:aws:iam:::user/%s", target)
}
return iam.VerifyActionPermission(r, identity, Action("iam:"+action), resource, "")
}
@@ -145,3 +145,75 @@ func TestAuthorizeIamActionCreateServiceAccountScopedByPolicyResource(t *testing
assert.Equal(t, s3err.ErrNone,
iam.AuthorizeIamAction(iamPostRequest(""), admin, "CreateServiceAccount", "victim"))
}
var iamSelfServiceActionNames = []string{"CreateAccessKey", "DeleteAccessKey", "UpdateAccessKey", "ListAccessKeys", "GetUser"}
func ownKeysPolicy(resource string) string {
return `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["iam:CreateAccessKey","iam:DeleteAccessKey","iam:UpdateAccessKey","iam:ListAccessKeys","iam:GetUser"],"Resource":["` + resource + `"]}]}`
}
// With selfServiceRequiresGrant, an identity holding only data-plane rights may
// no longer manage its own access keys: a leaked key cannot mint further keys
// that outlive its own revocation, nor deactivate the keys it was issued.
func TestAuthorizeIamActionSelfServiceRequiresGrantWhenConfigured(t *testing.T) {
iam := newIamAuthzTestIam(t)
iam.selfServiceRequiresGrant = true
powerUser := &Identity{Name: "power_user", PolicyNames: []string{"PowerUserPolicy"}}
bare := &Identity{Name: "bare_user"}
for _, action := range iamSelfServiceActionNames {
for _, identity := range []*Identity{powerUser, bare} {
for _, target := range []string{"", identity.Name, "victim"} {
assert.Equal(t, s3err.ErrAccessDenied, iam.AuthorizeIamAction(iamPostRequest(""), identity, action, target),
"%s by %s on %q", action, identity.Name, target)
}
}
}
}
// The grant is authorized against the target user's ARN, the caller's own when
// UserName is empty, so a policy can confine it to the caller's own keys.
func TestAuthorizeIamActionSelfServiceGrantIsScopedByResource(t *testing.T) {
for _, resource := range []string{"arn:aws:iam:::user/key_owner", "arn:aws:iam:::user/${aws:username}"} {
t.Run(resource, func(t *testing.T) {
iam := newIamAuthzTestIam(t)
iam.selfServiceRequiresGrant = true
require.NoError(t, iam.PutPolicy("OwnKeysPolicy", ownKeysPolicy(resource)))
owner := &Identity{Name: "key_owner", PolicyNames: []string{"OwnKeysPolicy"}}
for _, action := range iamSelfServiceActionNames {
assert.Equal(t, s3err.ErrNone, iam.AuthorizeIamAction(iamPostRequest(""), owner, action, ""), "%s on itself, implicit", action)
assert.Equal(t, s3err.ErrNone, iam.AuthorizeIamAction(iamPostRequest(""), owner, action, "key_owner"), "%s on itself", action)
assert.Equal(t, s3err.ErrAccessDenied, iam.AuthorizeIamAction(iamPostRequest(""), owner, action, "victim"), "%s on another user", action)
}
assert.Equal(t, s3err.ErrAccessDenied, iam.AuthorizeIamAction(iamPostRequest(""), owner, "CreateUser", "victim"))
})
}
}
// The setting changes nothing for the admin, nor for an iam:* grant.
func TestAuthorizeIamActionSelfServiceRequiresGrantKeepsAdmin(t *testing.T) {
iam := newIamAuthzTestIam(t)
iam.selfServiceRequiresGrant = true
admin := &Identity{Name: "admin", Actions: []Action{s3_constants.ACTION_ADMIN}}
operator := &Identity{Name: "iam_operator", PolicyNames: []string{"IamAdminPolicy"}}
for _, action := range iamSelfServiceActionNames {
for _, identity := range []*Identity{admin, operator} {
for _, target := range []string{"", identity.Name, "victim"} {
assert.Equal(t, s3err.ErrNone, iam.AuthorizeIamAction(iamPostRequest(""), identity, action, target),
"%s by %s on %q", action, identity.Name, target)
}
}
}
}
// Off by default: self-service stays allowed without a grant.
func TestAuthorizeIamActionSelfServiceDefaultUnchanged(t *testing.T) {
iam := newIamAuthzTestIam(t)
powerUser := &Identity{Name: "power_user", PolicyNames: []string{"PowerUserPolicy"}}
for _, action := range iamSelfServiceActionNames {
assert.Equal(t, s3err.ErrNone, iam.AuthorizeIamAction(iamPostRequest(""), powerUser, action, ""), action)
assert.Equal(t, s3err.ErrNone, iam.AuthorizeIamAction(iamPostRequest(""), powerUser, action, "power_user"), action)
}
}
+5
View File
@@ -81,6 +81,11 @@ type S3ApiServerOption struct {
// AllowBackslashInKey keeps '\' a literal object-key character instead of
// normalizing it to '/', matching AWS S3 where the two are distinct keys.
AllowBackslashInKey bool
// IamSelfServiceRequiresGrant makes managing one's own access keys
// (CreateAccessKey, DeleteAccessKey, UpdateAccessKey, ListAccessKeys,
// GetUser) need an explicit iam: grant instead of being allowed to every
// authenticated identity.
IamSelfServiceRequiresGrant bool
}
// s3ChunkCacheChunkSizeMB is the assumed chunk size (in MiB) used to convert