Files
seaweedfs/weed/s3api/s3api_embedded_iam_authz_test.go
T
1aad4cfe70 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>
2026-10-21 20:06:30 -07:00

220 lines
11 KiB
Go

package s3api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
dataPlanePolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:*","Resource":"*"}]}`
iamAdminPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"}]}`
iamCreateSvcAcctPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["iam:CreateServiceAccount"],"Resource":["*"]}]}`
// The wildcard ARN shape this authorization always used; it must keep
// matching any parent so existing grants survive.
iamCreateSvcAcctWildcardArnPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["iam:CreateServiceAccount"],"Resource":["arn:aws:iam:::*"]}]}`
// Scoped to one parent identity.
iamCreateSvcAcctZonePolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["iam:CreateServiceAccount"],"Resource":["arn:aws:iam:::user/team-parent"]}]}`
)
func newIamAuthzTestIam(t *testing.T) *IdentityAccessManagement {
t.Helper()
iam := &IdentityAccessManagement{}
require.NoError(t, iam.PutPolicy("PowerUserPolicy", dataPlanePolicy))
require.NoError(t, iam.PutPolicy("IamAdminPolicy", iamAdminPolicy))
return iam
}
func iamPostRequest(rawQuery string) *http.Request {
url := "http://s3.example.com/"
if rawQuery != "" {
url += "?" + rawQuery
}
return httptest.NewRequest(http.MethodPost, url, nil)
}
// A policy granting the S3 data plane must not reach IAM management, including
// when the request carries an S3 query parameter — the action resolver used to
// read the request shape even for an iam: action.
func TestAuthorizeIamActionDeniesDataPlanePolicy(t *testing.T) {
iam := newIamAuthzTestIam(t)
identity := &Identity{Name: "power_user", PolicyNames: []string{"PowerUserPolicy"}}
for _, rawQuery := range []string{"", "delete", "acl", "tagging", "policy", "versions"} {
t.Run("query="+rawQuery, func(t *testing.T) {
assert.Equal(t, s3err.ErrAccessDenied,
iam.AuthorizeIamAction(iamPostRequest(rawQuery), identity, "CreateUser", "victim"))
assert.Equal(t, s3err.ErrAccessDenied,
iam.AuthorizeIamAction(iamPostRequest(rawQuery), identity, "CreateAccessKey", "victim"))
})
}
}
func TestAuthorizeIamActionAllowsIamPolicy(t *testing.T) {
iam := newIamAuthzTestIam(t)
identity := &Identity{Name: "iam_operator", PolicyNames: []string{"IamAdminPolicy"}}
for _, rawQuery := range []string{"", "delete"} {
t.Run("query="+rawQuery, func(t *testing.T) {
assert.Equal(t, s3err.ErrNone,
iam.AuthorizeIamAction(iamPostRequest(rawQuery), identity, "CreateUser", "victim"))
})
}
}
func TestAuthorizeIamActionSelfServiceAndAdmin(t *testing.T) {
iam := newIamAuthzTestIam(t)
powerUser := &Identity{Name: "power_user", PolicyNames: []string{"PowerUserPolicy"}}
admin := &Identity{Name: "admin", Actions: []Action{s3_constants.ACTION_ADMIN}}
assert.Equal(t, s3err.ErrNone, iam.AuthorizeIamAction(iamPostRequest(""), powerUser, "CreateAccessKey", ""))
assert.Equal(t, s3err.ErrNone, iam.AuthorizeIamAction(iamPostRequest(""), powerUser, "CreateAccessKey", "power_user"))
assert.Equal(t, s3err.ErrNone, iam.AuthorizeIamAction(iamPostRequest(""), admin, "CreateUser", "victim"))
assert.Equal(t, s3err.ErrAccessDenied, iam.AuthorizeIamAction(iamPostRequest(""), nil, "CreateUser", "victim"))
}
// An identity with no grant at all is the negative control: the route itself
// was never open, so a denial here has to come from the policy check.
func TestAuthorizeIamActionDeniesUngrantedIdentity(t *testing.T) {
iam := newIamAuthzTestIam(t)
identity := &Identity{Name: "nobody"}
assert.Equal(t, s3err.ErrAccessDenied,
iam.AuthorizeIamAction(iamPostRequest(""), identity, "CreateUser", "victim"))
}
// A configured anonymous identity must not slip in through the self-service
// carve-out, which would otherwise hand it the caller-implied user name.
func TestAuthorizeIamActionDeniesAnonymous(t *testing.T) {
iam := newIamAuthzTestIam(t)
anonymous := &Identity{Name: s3_constants.AccountAnonymousId, PolicyNames: []string{"IamAdminPolicy"}}
assert.Equal(t, s3err.ErrAccessDenied,
iam.AuthorizeIamAction(iamPostRequest(""), anonymous, "CreateAccessKey", ""))
assert.Equal(t, s3err.ErrAccessDenied,
iam.AuthorizeIamAction(iamPostRequest(""), anonymous, "CreateUser", "victim"))
}
// CreateServiceAccount takes its target from the ParentUser parameter. A
// non-admin holding iam:CreateServiceAccount targets the identities its
// policy resource covers: a wildcard resource allows any target, a scoped
// resource confines the grant to the named parent identity.
func TestAuthorizeIamActionCreateServiceAccountScopedByPolicyResource(t *testing.T) {
iam := newIamAuthzTestIam(t)
require.NoError(t, iam.PutPolicy("CreateSvcAcctPolicy", iamCreateSvcAcctPolicy))
require.NoError(t, iam.PutPolicy("CreateSvcAcctWildcardArnPolicy", iamCreateSvcAcctWildcardArnPolicy))
require.NoError(t, iam.PutPolicy("CreateSvcAcctZonePolicy", iamCreateSvcAcctZonePolicy))
dev := &Identity{Name: "dev", PolicyNames: []string{"CreateSvcAcctPolicy"}}
wildcardArn := &Identity{Name: "wildcard-arn", PolicyNames: []string{"CreateSvcAcctWildcardArnPolicy"}}
keymaker := &Identity{Name: "keymaker", PolicyNames: []string{"CreateSvcAcctZonePolicy"}}
admin := &Identity{Name: "admin", Actions: []Action{s3_constants.ACTION_ADMIN}}
// Wildcard resource: any target, including the caller itself.
assert.Equal(t, s3err.ErrNone,
iam.AuthorizeIamAction(iamPostRequest(""), dev, "CreateServiceAccount", "dev"))
assert.Equal(t, s3err.ErrNone,
iam.AuthorizeIamAction(iamPostRequest(""), dev, "CreateServiceAccount", ""))
assert.Equal(t, s3err.ErrNone,
iam.AuthorizeIamAction(iamPostRequest(""), dev, "CreateServiceAccount", "admin"))
// Wildcard ARN: any target.
assert.Equal(t, s3err.ErrNone,
iam.AuthorizeIamAction(iamPostRequest(""), wildcardArn, "CreateServiceAccount", "team-parent"))
assert.Equal(t, s3err.ErrNone,
iam.AuthorizeIamAction(iamPostRequest(""), wildcardArn, "CreateServiceAccount", "admin"))
// Scoped resource: the named parent only. IAM resource matching is
// case-sensitive, so a case variant is a different identity.
assert.Equal(t, s3err.ErrNone,
iam.AuthorizeIamAction(iamPostRequest(""), keymaker, "CreateServiceAccount", "team-parent"))
assert.Equal(t, s3err.ErrAccessDenied,
iam.AuthorizeIamAction(iamPostRequest(""), keymaker, "CreateServiceAccount", "Team-Parent"))
assert.Equal(t, s3err.ErrAccessDenied,
iam.AuthorizeIamAction(iamPostRequest(""), keymaker, "CreateServiceAccount", "admin"))
assert.Equal(t, s3err.ErrAccessDenied,
iam.AuthorizeIamAction(iamPostRequest(""), keymaker, "CreateServiceAccount", "keymaker"))
assert.Equal(t, s3err.ErrAccessDenied,
iam.AuthorizeIamAction(iamPostRequest(""), keymaker, "CreateServiceAccount", ""))
// Admins remain unrestricted.
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)
}
}