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

+75 -35
View File
@@ -1,6 +1,7 @@
package s3api
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
@@ -12,9 +13,14 @@ import (
v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
"github.com/gorilla/mux"
"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/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// 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)")
}
// setupRoutingTestServerWithCreator seeds a non-admin identity (creator) that
// holds only the iam:CreateServiceAccount action, plus a victim identity, so a
// SigV4-signed CreateServiceAccount request can exercise UnifiedPostHandler's
// authorization against the ParentUser target.
// setupRoutingTestServerWithCreator seeds two non-admin identities holding
// iam:CreateServiceAccount — creator with the coarse (unscoped) action,
// keymaker with a policy scoped to the team-parent identity ARN — plus a
// victim identity, so SigV4-signed CreateServiceAccount requests can exercise
// UnifiedPostHandler's authorization against the ParentUser target.
func setupRoutingTestServerWithCreator(t *testing.T) *S3ApiServer {
s3a := setupRoutingTestServer(t)
const creatorAK, creatorSK = "creator-ak", "creator-sk"
creator := &Identity{
Name: "creator",
Actions: []Action{Action("iam:CreateServiceAccount")},
IsStatic: true,
Credentials: []*Credential{{
AccessKey: creatorAK,
SecretKey: creatorSK,
}},
const keymakerAK, keymakerSK = "keymaker-ak", "keymaker-sk"
// Policy-bearing identities route through the IAM integration.
mgr := integration.NewIAMManager()
require.NoError(t, mgr.Initialize(&integration.IAMConfig{
STS: &sts.STSConfig{
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)
return s3a
}
// TestRouting_CreateServiceAccountBindsParentUser verifies that on the S3-port
// IAM route a non-admin holding iam:CreateServiceAccount cannot mint a service
// account for another identity (ParentUser=victim), and can for itself.
func TestRouting_CreateServiceAccountBindsParentUser(t *testing.T) {
// TestRouting_CreateServiceAccountParentUserScopedByPolicy verifies that on
// the S3-port IAM route a non-admin holding iam:CreateServiceAccount targets
// the identities its grant covers: the coarse action is unscoped (like a
// 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()
s3a := setupRoutingTestServerWithCreator(t)
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()
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 {
t.Fatalf("sign request: %v", err)
}
}
makeReq := func(parentUser string) *http.Request {
makeReq := func(parentUser, ak, sk string) *http.Request {
data := url.Values{}
data.Set("Action", "CreateServiceAccount")
data.Set("Version", "2010-05-08")
@@ -256,19 +285,30 @@ func TestRouting_CreateServiceAccountBindsParentUser(t *testing.T) {
body := data.Encode()
req, _ := http.NewRequest("POST", "http://localhost/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
signCreator(t, req, body)
sign(t, req, body, ak, sk)
return req
}
// Targeting another identity must be denied at authorization.
rr := httptest.NewRecorder()
router.ServeHTTP(rr, makeReq("victim"))
assert.Equal(t, http.StatusForbidden, rr.Code, "cross-identity ParentUser must be denied; got body=%s", rr.Body.String())
serve := func(req *http.Request) *httptest.ResponseRecorder {
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
// Targeting self must pass authorization (handler may still error, but not 403).
rr2 := httptest.NewRecorder()
router.ServeHTTP(rr2, makeReq("creator"))
assert.NotEqual(t, http.StatusForbidden, rr2.Code, "self ParentUser must pass authorization; got body=%s", rr2.Body.String())
// Coarse (unscoped) action: any target passes authorization and the
// service account is actually created.
rr := serve(makeReq("victim", "creator-ak", "creator-sk"))
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