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>
This commit is contained in:
Javier de la Torre
2026-10-21 20:06:30 -07:00
committed by GitHub
co-authored by Javier de la Torre Chris Lu
parent ea859a0844
commit 1aad4cfe70
10 changed files with 129 additions and 19 deletions
+6
View File
@@ -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")
+11 -8
View File
@@ -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 {
+1
View File
@@ -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)")
+3
View File
@@ -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)
+2
View File
@@ -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-<port>.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")
+8 -6
View File
@@ -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)
+6
View File
@@ -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:<Action>
// 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))
+15 -5
View File
@@ -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:<Action> 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:<Action> 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, "")
}
@@ -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)
}
}
+5
View File
@@ -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