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