iam: scope CreateServiceAccount's ParentUser by policy resource (#11736)

* iam: scope CreateServiceAccount's ParentUser by policy resource

AuthorizeIamAction evaluated the iam:CreateServiceAccount grant against a
hardcoded arn:aws:iam:::* resource, so the policy's Resource never had a
say; #11218 then bound the target to the caller outright. Evaluate
self-target actions against the target identity's ARN instead: a wildcard
resource keeps the deliberate any-target grant, a scoped resource confines
issuance to the named parent identity, anything else is denied.

buildResourceARN gains the ARN pass-through buildS3ResourceArn already has,
so iam: ARNs reach the policy engine intact on the attached-policies route.

Closes #11735

* ci: retrigger after unrelated weed/storage metrics flake

* iam: fold IAM-ARN matching into awsIAMMatch, drop debug test

- remove zz_debug_test.go (local debugging leftover)
- fold awsIAMMatchResource into awsIAMMatch: the arn:aws:iam:: value
  prefix selects case-sensitive matching, so no separate name is needed
- trim comments

---------

Co-authored-by: batonogov <batonogov@users.noreply.github.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-18 11:48:01 -07:00
1 parent 4a57432488
commit eea3f232da
5 files changed
+140 -50

No files matched your search

+12
View File
@@ -1240,6 +1240,18 @@ func awsIAMMatch(pattern, value string, evalCtx *EvaluationContext) bool {
return true // Universal wildcard return true // Universal wildcard
} }
// IAM identity ARNs match case-sensitively: identity lookup is
// case-sensitive, so a case-variant names a different identity.
if strings.HasPrefix(value, "arn:aws:iam::") {
if expandedPattern == value {
return true
}
if strings.Contains(expandedPattern, "*") || strings.Contains(expandedPattern, "?") {
return wildcard.MatchesWildcard(expandedPattern, value)
}
return false
}
// Step 3: Case-insensitive exact match // Step 3: Case-insensitive exact match
if strings.EqualFold(expandedPattern, value) { if strings.EqualFold(expandedPattern, value) {
return true return true
+5
View File
@@ -895,6 +895,11 @@ func isPublicReadGrants(grants []*s3.Grant) bool {
// buildResourceARN builds a resource ARN from bucket and object // buildResourceARN builds a resource ARN from bucket and object
// Used by the policy engine wrapper // Used by the policy engine wrapper
func buildResourceARN(bucket, object string) string { func buildResourceARN(bucket, object string) string {
// An already-ARN resource (IAM management actions authorize against
// arn:aws:iam::...) passes through; no bucket name can contain ':'.
if strings.HasPrefix(bucket, "arn:") {
return bucket
}
if object == "" || object == "/" { if object == "" || object == "/" {
return fmt.Sprintf("arn:aws:s3:::%s", bucket) return fmt.Sprintf("arn:aws:s3:::%s", bucket)
} }
+11 -9
View File
@@ -2528,9 +2528,10 @@ func iamRequiresAdminForOthers(action string) bool {
return iamSelfServiceActions[action] return iamSelfServiceActions[action]
} }
// iamSelfTargetActions require an explicit iam:<Action> grant, but a non-admin // iamSelfTargetActions are authorized against the target identity's ARN so a
// grant holder may only target their own identity. The target parameter is // policy can scope the grant (arn:aws:iam:::user/team-parent). The target
// action-specific (UserName for most, ParentUser for CreateServiceAccount). // parameter is action-specific (UserName for most, ParentUser for
// CreateServiceAccount).
var iamSelfTargetActions = map[string]bool{ var iamSelfTargetActions = map[string]bool{
"CreateServiceAccount": true, "CreateServiceAccount": true,
} }
@@ -2573,13 +2574,14 @@ func (iam *IdentityAccessManagement) AuthorizeIamAction(r *http.Request, identit
if identity.isAdmin() { if identity.isAdmin() {
return s3err.ErrNone return s3err.ErrNone
} }
if errCode := iam.VerifyActionPermission(r, identity, Action("iam:"+action), "arn:aws:iam:::*", ""); errCode != s3err.ErrNone { // Self-target actions authorize against the target identity's ARN. The
return errCode // account segment stays empty so existing arn:aws:iam:::* wildcard
// grants keep matching.
resource := "arn:aws:iam:::*"
if iamRequiresSelfTarget(action) && targetUserName != "" {
resource = fmt.Sprintf("arn:aws:iam:::user/%s", targetUserName)
} }
if iamRequiresSelfTarget(action) && targetUserName != "" && targetUserName != identity.Name { return iam.VerifyActionPermission(r, identity, Action("iam:"+action), resource, "")
return s3err.ErrAccessDenied
}
return s3err.ErrNone
} }
// AuthIamManagement authenticates an IAM management request and authorizes the // AuthIamManagement authenticates an IAM management request and authorizes the
+37 -6
View File
@@ -15,6 +15,11 @@ const (
dataPlanePolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:*","Resource":"*"}]}` dataPlanePolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:*","Resource":"*"}]}`
iamAdminPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"}]}` iamAdminPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"}]}`
iamCreateSvcAcctPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["iam:CreateServiceAccount"],"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 { func newIamAuthzTestIam(t *testing.T) *IdentityAccessManagement {
@@ -95,22 +100,48 @@ func TestAuthorizeIamActionDeniesAnonymous(t *testing.T) {
iam.AuthorizeIamAction(iamPostRequest(""), anonymous, "CreateUser", "victim")) iam.AuthorizeIamAction(iamPostRequest(""), anonymous, "CreateUser", "victim"))
} }
// CreateServiceAccount takes its target from the ParentUser parameter, not // CreateServiceAccount takes its target from the ParentUser parameter. A
// UserName. A non-admin holding only iam:CreateServiceAccount must not be able // non-admin holding iam:CreateServiceAccount targets the identities its
// to mint a service account for another identity (privilege escalation to that // policy resource covers: a wildcard resource allows any target, a scoped
// identity's permissions). // resource confines the grant to the named parent identity.
func TestAuthorizeIamActionCreateServiceAccountBindsTargetToCaller(t *testing.T) { func TestAuthorizeIamActionCreateServiceAccountScopedByPolicyResource(t *testing.T) {
iam := newIamAuthzTestIam(t) iam := newIamAuthzTestIam(t)
require.NoError(t, iam.PutPolicy("CreateSvcAcctPolicy", iamCreateSvcAcctPolicy)) 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"}} 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}} admin := &Identity{Name: "admin", Actions: []Action{s3_constants.ACTION_ADMIN}}
// Wildcard resource: any target, including the caller itself.
assert.Equal(t, s3err.ErrNone, assert.Equal(t, s3err.ErrNone,
iam.AuthorizeIamAction(iamPostRequest(""), dev, "CreateServiceAccount", "dev")) iam.AuthorizeIamAction(iamPostRequest(""), dev, "CreateServiceAccount", "dev"))
assert.Equal(t, s3err.ErrNone, assert.Equal(t, s3err.ErrNone,
iam.AuthorizeIamAction(iamPostRequest(""), dev, "CreateServiceAccount", "")) iam.AuthorizeIamAction(iamPostRequest(""), dev, "CreateServiceAccount", ""))
assert.Equal(t, s3err.ErrAccessDenied, assert.Equal(t, s3err.ErrNone,
iam.AuthorizeIamAction(iamPostRequest(""), dev, "CreateServiceAccount", "admin")) 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, assert.Equal(t, s3err.ErrNone,
iam.AuthorizeIamAction(iamPostRequest(""), admin, "CreateServiceAccount", "victim")) iam.AuthorizeIamAction(iamPostRequest(""), admin, "CreateServiceAccount", "victim"))
} }
+75 -35
View File
@@ -1,6 +1,7 @@
package s3api package s3api
import ( import (
"context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -12,9 +13,14 @@ import (
v4 "github.com/aws/aws-sdk-go/aws/signer/v4" v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/credential" "github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/util" "github.com/seaweedfs/seaweedfs/weed/util"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// routingTestAccessKey/routingTestSecretKey are the credentials seeded into // routingTestAccessKey/routingTestSecretKey are the credentials seeded into
@@ -205,50 +211,73 @@ func TestRouting_AuthenticatedIAM(t *testing.T) {
assert.Contains(t, []int{http.StatusBadRequest, http.StatusForbidden}, rr.Code, "Should route to IAM handler (400/403 due to invalid signature)") assert.Contains(t, []int{http.StatusBadRequest, http.StatusForbidden}, rr.Code, "Should route to IAM handler (400/403 due to invalid signature)")
} }
// setupRoutingTestServerWithCreator seeds a non-admin identity (creator) that // setupRoutingTestServerWithCreator seeds two non-admin identities holding
// holds only the iam:CreateServiceAccount action, plus a victim identity, so a // iam:CreateServiceAccount — creator with the coarse (unscoped) action,
// SigV4-signed CreateServiceAccount request can exercise UnifiedPostHandler's // keymaker with a policy scoped to the team-parent identity ARN — plus a
// authorization against the ParentUser target. // victim identity, so SigV4-signed CreateServiceAccount requests can exercise
// UnifiedPostHandler's authorization against the ParentUser target.
func setupRoutingTestServerWithCreator(t *testing.T) *S3ApiServer { func setupRoutingTestServerWithCreator(t *testing.T) *S3ApiServer {
s3a := setupRoutingTestServer(t) s3a := setupRoutingTestServer(t)
const creatorAK, creatorSK = "creator-ak", "creator-sk" const creatorAK, creatorSK = "creator-ak", "creator-sk"
creator := &Identity{ const keymakerAK, keymakerSK = "keymaker-ak", "keymaker-sk"
Name: "creator",
Actions: []Action{Action("iam:CreateServiceAccount")}, // Policy-bearing identities route through the IAM integration.
IsStatic: true, mgr := integration.NewIAMManager()
Credentials: []*Credential{{ require.NoError(t, mgr.Initialize(&integration.IAMConfig{
AccessKey: creatorAK, STS: &sts.STSConfig{
SecretKey: creatorSK, TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
}}, MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
Issuer: "test",
SigningKey: []byte("test-signing-key-32-characters!!"),
AccountId: "111122223333",
},
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
Roles: &integration.RoleStoreConfig{StoreType: "memory"},
}, func() string { return "localhost:8888" }))
s3a.iam.SetIAMIntegration(NewS3IAMIntegration(mgr, ""))
const zonePolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["iam:CreateServiceAccount"],"Resource":["arn:aws:iam:::user/team-parent"]}]}`
require.NoError(t, s3a.iam.PutPolicy("CreateSvcAcctZonePolicy", zonePolicy))
// Store and memory must agree: SigV4 auth reads memory, the handler
// re-reads the store, and a successful creation reloads memory from it.
idents := []*iam_pb.Identity{
{Name: "creator", Actions: []string{"iam:CreateServiceAccount"},
Credentials: []*iam_pb.Credential{{AccessKey: creatorAK, SecretKey: creatorSK}}},
{Name: "keymaker", PolicyNames: []string{"CreateSvcAcctZonePolicy"},
Credentials: []*iam_pb.Credential{{AccessKey: keymakerAK, SecretKey: keymakerSK}}},
{Name: "victim"},
{Name: "team-parent"},
}
require.NoError(t, s3a.credentialManager.SaveConfiguration(context.Background(), &iam_pb.S3ApiConfiguration{
Identities: idents,
Policies: []*iam_pb.Policy{{Name: "CreateSvcAcctZonePolicy", Content: zonePolicy}},
}))
for _, ident := range idents {
require.NoError(t, s3a.iam.UpsertIdentity(ident))
} }
victim := &Identity{Name: "victim", IsStatic: true}
s3a.iam.m.Lock()
s3a.iam.identities = append(s3a.iam.identities, creator, victim)
s3a.iam.accessKeyIdent[creatorAK] = creator
s3a.iam.nameToIdentity["creator"] = creator
s3a.iam.nameToIdentity["victim"] = victim
s3a.iam.m.Unlock()
s3a.cb = NewCircuitBreaker(s3a.option) s3a.cb = NewCircuitBreaker(s3a.option)
return s3a return s3a
} }
// TestRouting_CreateServiceAccountBindsParentUser verifies that on the S3-port // TestRouting_CreateServiceAccountParentUserScopedByPolicy verifies that on
// IAM route a non-admin holding iam:CreateServiceAccount cannot mint a service // the S3-port IAM route a non-admin holding iam:CreateServiceAccount targets
// account for another identity (ParentUser=victim), and can for itself. // the identities its grant covers: the coarse action is unscoped (like a
func TestRouting_CreateServiceAccountBindsParentUser(t *testing.T) { // wildcard resource), a policy scoped to one parent identity's ARN confines
// the grant to that parent.
func TestRouting_CreateServiceAccountParentUserScopedByPolicy(t *testing.T) {
router := mux.NewRouter() router := mux.NewRouter()
s3a := setupRoutingTestServerWithCreator(t) s3a := setupRoutingTestServerWithCreator(t)
s3a.registerRouter(router) s3a.registerRouter(router)
signCreator := func(t *testing.T, req *http.Request, body string) { sign := func(t *testing.T, req *http.Request, body, ak, sk string) {
t.Helper() t.Helper()
creds := credentials.NewStaticCredentials("creator-ak", "creator-sk", "") creds := credentials.NewStaticCredentials(ak, sk, "")
if _, err := v4.NewSigner(creds).Sign(req, strings.NewReader(body), "iam", "us-east-1", time.Now()); err != nil { if _, err := v4.NewSigner(creds).Sign(req, strings.NewReader(body), "iam", "us-east-1", time.Now()); err != nil {
t.Fatalf("sign request: %v", err) t.Fatalf("sign request: %v", err)
} }
} }
makeReq := func(parentUser string) *http.Request { makeReq := func(parentUser, ak, sk string) *http.Request {
data := url.Values{} data := url.Values{}
data.Set("Action", "CreateServiceAccount") data.Set("Action", "CreateServiceAccount")
data.Set("Version", "2010-05-08") data.Set("Version", "2010-05-08")
@@ -256,19 +285,30 @@ func TestRouting_CreateServiceAccountBindsParentUser(t *testing.T) {
body := data.Encode() body := data.Encode()
req, _ := http.NewRequest("POST", "http://localhost/", strings.NewReader(body)) req, _ := http.NewRequest("POST", "http://localhost/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
signCreator(t, req, body) sign(t, req, body, ak, sk)
return req return req
} }
// Targeting another identity must be denied at authorization. serve := func(req *http.Request) *httptest.ResponseRecorder {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
router.ServeHTTP(rr, makeReq("victim")) router.ServeHTTP(rr, req)
assert.Equal(t, http.StatusForbidden, rr.Code, "cross-identity ParentUser must be denied; got body=%s", rr.Body.String()) return rr
}
// Targeting self must pass authorization (handler may still error, but not 403). // Coarse (unscoped) action: any target passes authorization and the
rr2 := httptest.NewRecorder() // service account is actually created.
router.ServeHTTP(rr2, makeReq("creator")) rr := serve(makeReq("victim", "creator-ak", "creator-sk"))
assert.NotEqual(t, http.StatusForbidden, rr2.Code, "self ParentUser must pass authorization; got body=%s", rr2.Body.String()) assert.Equal(t, http.StatusOK, rr.Code, "coarse grant is unscoped; got body=%s", rr.Body.String())
// Scoped policy: the named parent passes (200), any other identity is denied.
rr2 := serve(makeReq("team-parent", "keymaker-ak", "keymaker-sk"))
assert.Equal(t, http.StatusOK, rr2.Code, "scoped grant must cover its named parent; got body=%s", rr2.Body.String())
rr3 := serve(makeReq("victim", "keymaker-ak", "keymaker-sk"))
assert.Equal(t, http.StatusForbidden, rr3.Code, "target outside the scoped grant must be denied; got body=%s", rr3.Body.String())
rr4 := serve(makeReq("keymaker", "keymaker-ak", "keymaker-sk"))
assert.Equal(t, http.StatusForbidden, rr4.Code, "even self is denied when the scoped grant does not name it; got body=%s", rr4.Body.String())
} }
// TestRouting_IAMMatcherLogic verifies the iamMatcher correctly distinguishes auth types // TestRouting_IAMMatcherLogic verifies the iamMatcher correctly distinguishes auth types