From 1aad4cfe702e8f97703e01e36b2d6c3efc7cfde7 Mon Sep 17 00:00:00 2001 From: Javier de la Torre Date: Wed, 21 Oct 2026 23:06:30 -0400 Subject: [PATCH] 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: 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 Co-authored-by: Chris Lu --- weed/command/filer.go | 6 ++ weed/command/iam.go | 19 +++--- weed/command/mini.go | 1 + weed/command/s3.go | 3 + weed/command/server.go | 2 + weed/iamapi/iamapi_server.go | 14 ++-- weed/s3api/auth_credentials.go | 6 ++ weed/s3api/s3api_embedded_iam.go | 20 ++++-- weed/s3api/s3api_embedded_iam_authz_test.go | 72 +++++++++++++++++++++ weed/s3api/s3api_server.go | 5 ++ 10 files changed, 129 insertions(+), 19 deletions(-) diff --git a/weed/command/filer.go b/weed/command/filer.go index d1385f918a..245182117a 100644 --- a/weed/command/filer.go +++ b/weed/command/filer.go @@ -51,6 +51,10 @@ var ( // S3 gateway, whose remote-mount read paths dial the mounted endpoint directly. const allowUntrustedRemoteEndpointsUsage = "if true, a read of a remote-only entry accepts arbitrary remote S3 endpoints including loopback / link-local hosts. Default rejects internal / metadata endpoints." +// iamSelfServiceRequiresGrantUsage documents the flag shared by every command +// that runs the embedded IAM API. +const iamSelfServiceRequiresGrantUsage = "if true, an identity needs an explicit iam: grant to manage its own access keys (CreateAccessKey, DeleteAccessKey, UpdateAccessKey, ListAccessKeys, GetUser), as on AWS. Default allows it to every authenticated identity." + // allowBackslashInKeyUsage documents the S3 flag keeping '\' a literal // object-key character instead of normalizing it to '/'. Repeated '//' in a // key still collapses: a filer entry name cannot contain an empty segment. @@ -179,6 +183,7 @@ func init() { filerS3Options.readerCacheSizeMB = cmdFiler.Flag.Int64("s3.readerCacheSizeMB", 0, "memory budget in MiB for downloaded and in-flight reader buffers across all S3 GETs (0 means unlimited)") filerS3Options.allowUntrustedRemoteEndpoints = cmdFiler.Flag.Bool("s3.allowUntrustedRemoteEndpoints", false, allowUntrustedRemoteEndpointsUsage) filerS3Options.allowBackslashInKey = cmdFiler.Flag.Bool("s3.allowBackslashInKey", false, allowBackslashInKeyUsage) + filerS3Options.iamSelfServiceRequiresGrant = cmdFiler.Flag.Bool("s3.iam.selfServiceRequiresGrant", false, iamSelfServiceRequiresGrantUsage) // start webdav on filer filerStartWebDav = cmdFiler.Flag.Bool("webdav", false, "whether to start webdav gateway") @@ -197,6 +202,7 @@ func init() { filerStartIam = cmdFiler.Flag.Bool("iam", false, "whether to start IAM service") filerIamOptions.ip = cmdFiler.Flag.String("iam.ip", *f.ip, "iam server http listen ip address") filerIamOptions.port = cmdFiler.Flag.Int("iam.port", 8111, "iam server http listen port") + filerIamOptions.selfServiceRequiresGrant = filerS3Options.iamSelfServiceRequiresGrant filerStartSftp = cmdFiler.Flag.Bool("sftp", false, "whether to start the SFTP server") filerSftpOptions.port = cmdFiler.Flag.Int("sftp.port", 2022, "SFTP server listen port") diff --git a/weed/command/iam.go b/weed/command/iam.go index 77f3a9014a..ce11f56ab2 100644 --- a/weed/command/iam.go +++ b/weed/command/iam.go @@ -28,10 +28,11 @@ var ( ) type IamOptions struct { - filer *string - masters *string - ip *string - port *int + filer *string + masters *string + ip *string + port *int + selfServiceRequiresGrant *bool } func init() { @@ -40,6 +41,7 @@ func init() { iamStandaloneOptions.masters = cmdIam.Flag.String("master", "localhost:9333", "comma-separated master servers") iamStandaloneOptions.ip = cmdIam.Flag.String("ip", util.DetectedHostAddress(), "iam server http listen ip address") iamStandaloneOptions.port = cmdIam.Flag.Int("port", 8111, "iam server http listen port") + iamStandaloneOptions.selfServiceRequiresGrant = cmdIam.Flag.Bool("selfServiceRequiresGrant", false, iamSelfServiceRequiresGrantUsage) } var cmdIam = &Command{ @@ -101,10 +103,11 @@ func (iamopt *IamOptions) startIamServer() bool { masters := pb.ServerAddresses(*iamopt.masters).ToAddressMap() router := mux.NewRouter().SkipClean(true) iamApiServer, iamApiServer_err := iamapi.NewIamApiServer(router, &iamapi.IamServerOption{ - Masters: masters, - Filers: filerAddresses, - Port: *iamopt.port, - GrpcDialOption: grpcDialOption, + Masters: masters, + Filers: filerAddresses, + Port: *iamopt.port, + GrpcDialOption: grpcDialOption, + IamSelfServiceRequiresGrant: *iamopt.selfServiceRequiresGrant, }) glog.V(0).Info("NewIamApiServer created") if iamApiServer_err != nil { diff --git a/weed/command/mini.go b/weed/command/mini.go index 9d4eb630cb..9fd8d0963f 100644 --- a/weed/command/mini.go +++ b/weed/command/mini.go @@ -557,6 +557,7 @@ func initMiniS3Flags() { miniS3Options.readerCacheSizeMB = cmdMini.Flag.Int64("s3.readerCacheSizeMB", 0, "memory budget in MiB for downloaded and in-flight reader buffers across all S3 GETs (0 means unlimited)") miniS3Options.allowUntrustedRemoteEndpoints = cmdMini.Flag.Bool("s3.allowUntrustedRemoteEndpoints", false, allowUntrustedRemoteEndpointsUsage) miniS3Options.allowBackslashInKey = cmdMini.Flag.Bool("s3.allowBackslashInKey", false, allowBackslashInKeyUsage) + miniS3Options.iamSelfServiceRequiresGrant = cmdMini.Flag.Bool("s3.iam.selfServiceRequiresGrant", false, iamSelfServiceRequiresGrantUsage) // In mini mode, S3 uses the shared debug server started at line 681, not its own separate debug server miniS3Options.debug = new(bool) // explicitly false miniS3Options.debugPort = cmdMini.Flag.Int("s3.debug.port", 6060, "http port for debugging (unused in mini mode)") diff --git a/weed/command/s3.go b/weed/command/s3.go index 98b0bbcc3c..425bd47639 100644 --- a/weed/command/s3.go +++ b/weed/command/s3.go @@ -85,6 +85,7 @@ type S3Options struct { allowUntrustedRemoteEndpoints *bool allowBackslashInKey *bool + iamSelfServiceRequiresGrant *bool // shutdownCtx, when non-nil, tells startS3Server to gracefully shut down // its HTTP/gRPC servers once the ctx is cancelled, in addition to on // interrupt. Used by weed mini to orchestrate an ordered shutdown. @@ -142,6 +143,7 @@ func init() { s3StandaloneOptions.readerCacheSizeMB = cmdS3.Flag.Int64("readerCacheSizeMB", 0, "memory budget in MiB for downloaded and in-flight reader buffers across all S3 GETs (0 means unlimited)") s3StandaloneOptions.allowUntrustedRemoteEndpoints = cmdS3.Flag.Bool("allowUntrustedRemoteEndpoints", false, allowUntrustedRemoteEndpointsUsage) s3StandaloneOptions.allowBackslashInKey = cmdS3.Flag.Bool("allowBackslashInKey", false, allowBackslashInKeyUsage) + s3StandaloneOptions.iamSelfServiceRequiresGrant = cmdS3.Flag.Bool("iam.selfServiceRequiresGrant", false, iamSelfServiceRequiresGrantUsage) } var cmdS3 = &Command{ @@ -402,6 +404,7 @@ func (s3opt *S3Options) startS3Server() bool { AllowUntrustedRemoteEndpoints: *s3opt.allowUntrustedRemoteEndpoints, AllowBackslashInKey: *s3opt.allowBackslashInKey, + IamSelfServiceRequiresGrant: *s3opt.iamSelfServiceRequiresGrant, }) if s3ApiServer_err != nil { glog.Fatalf("S3 API Server startup error: %v", s3ApiServer_err) diff --git a/weed/command/server.go b/weed/command/server.go index 3fd367fd7f..a9b27bc064 100644 --- a/weed/command/server.go +++ b/weed/command/server.go @@ -200,6 +200,7 @@ func init() { s3Options.cacheSizeMB = cmdServer.Flag.Int64("s3.cacheCapacityMB", 0, "in-memory chunk cache capacity in MB for S3 GETs shared across requests (0 disables)") s3Options.readerCacheSizeMB = cmdServer.Flag.Int64("s3.readerCacheSizeMB", 0, "memory budget in MiB for downloaded and in-flight reader buffers across all S3 GETs (0 means unlimited)") s3Options.allowUntrustedRemoteEndpoints = cmdServer.Flag.Bool("s3.allowUntrustedRemoteEndpoints", false, allowUntrustedRemoteEndpointsUsage) + s3Options.iamSelfServiceRequiresGrant = cmdServer.Flag.Bool("s3.iam.selfServiceRequiresGrant", false, iamSelfServiceRequiresGrantUsage) sftpOptions.port = cmdServer.Flag.Int("sftp.port", 2022, "SFTP server listen port") sftpOptions.sshPrivateKey = cmdServer.Flag.String("sftp.sshPrivateKey", "", "path to the SSH private key file for host authentication") @@ -215,6 +216,7 @@ func init() { sftpOptions.dataCenter = cmdServer.Flag.String("sftp.dataCenter", "", "prefer to read and write to volumes in this data center") sftpOptions.localSocket = cmdServer.Flag.String("sftp.localSocket", "", "default to /tmp/seaweedfs-sftp-.sock") iamOptions.port = cmdServer.Flag.Int("iam.port", 8111, "iam server http listen port") + iamOptions.selfServiceRequiresGrant = s3Options.iamSelfServiceRequiresGrant webdavOptions.port = cmdServer.Flag.Int("webdav.port", 7333, "webdav server http listen port") webdavOptions.collection = cmdServer.Flag.String("webdav.collection", "", "collection to create the files") diff --git a/weed/iamapi/iamapi_server.go b/weed/iamapi/iamapi_server.go index 26b2d9c606..63f85623f2 100644 --- a/weed/iamapi/iamapi_server.go +++ b/weed/iamapi/iamapi_server.go @@ -42,10 +42,11 @@ type IamS3ApiConfigure struct { } type IamServerOption struct { - Masters map[string]pb.ServerAddress - Filers []pb.ServerAddress - Port int - GrpcDialOption grpc.DialOption + Masters map[string]pb.ServerAddress + Filers []pb.ServerAddress + Port int + GrpcDialOption grpc.DialOption + IamSelfServiceRequiresGrant bool } type IamApiServer struct { @@ -87,8 +88,9 @@ func NewIamApiServerWithStore(router *mux.Router, option *IamServerOption, expli s3ApiConfigure = configure s3Option := s3api.S3ApiServerOption{ - Filers: option.Filers, - GrpcDialOption: option.GrpcDialOption, + Filers: option.Filers, + GrpcDialOption: option.GrpcDialOption, + IamSelfServiceRequiresGrant: option.IamSelfServiceRequiresGrant, } // Initialize FilerClient for IAM - explicit filers only (no discovery as FilerGroup unspecified) diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index 65a64c2eff..46ff18366b 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -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: + // 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)) diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index bce22df8aa..4b837117f4 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -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: 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: 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, "") } diff --git a/weed/s3api/s3api_embedded_iam_authz_test.go b/weed/s3api/s3api_embedded_iam_authz_test.go index 7f4725fab9..81a1dd3e8b 100644 --- a/weed/s3api/s3api_embedded_iam_authz_test.go +++ b/weed/s3api/s3api_embedded_iam_authz_test.go @@ -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) + } +} diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go index 2ccd42d6e4..c485e8814e 100644 --- a/weed/s3api/s3api_server.go +++ b/weed/s3api/s3api_server.go @@ -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