Compare commits

..

1 Commits

Author SHA1 Message Date
Feng Ruohang 36c8d1683a fix(ilm): count a version under old and healed headers as one revision
The version history that an ILM expiry reads accepts a version only
when one revision of it is on more than half of the drives and on its
read quorum, and a revision was the header as the drive stores it plus
a digest of the metadata. A drive holds the header that the release
which wrote the version there stored: before RELEASE.2024-04-28
without erasure coding, and until RELEASE.2022-01-25 with a signature
that counted empty part ETags. A heal writes the header of its own
release and leaves the other drives alone. Reads and heal take both
for one version; the history counted two revisions. With 5 to 11 of 16
drives healed (EC:4) neither had the quorum, objectVersions failed
with a read quorum error, and every expiry that reads the history was
refused on each scanner cycle, with nothing to bring the drives
together: every expiry in a versioned bucket with several pools, and
an expiry of all versions of a key with one pool too.

versionRevision now gives the key of an object version the signature
and the erasure coding as the current release computes them from the
decoded metadata. The key of a version this release wrote carries its
header as stored, so its history decides as before, including the
order of versions of one time, which the signature breaks. The digest
covers what both fields say. The stored flags stay in the key: whether
a transitioned version uses its data directory follows the moment a
drive wrote the entry (fileInfoRevisions).

TestPoolsLifecycleHeaderEras accepts both old headers on every count
of healed drives. TestPoolsLifecycleHealedOldHeaders expires a
noncurrent version with two pools, and all versions with one, after
half of the drives were wiped and healed. Both fail before this
change. Drives that differ only in the data directory flag of a
restored version are still two revisions; that is a separate defect.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-10 13:45:37 +08:00
6 changed files with 198 additions and 262 deletions
-20
View File
@@ -1,25 +1,5 @@
# Changelog
## Unreleased
- An edit of a service account or access key that names no policy keeps its
inline policy. `mcli admin user svcacct disable`, `enable` and `edit` without
`--policy`, the same `idp ldap accesskey` commands, and any other
`update-service-account` request without `newPolicy` removed the inline
policy at the site that took the request and, for an account of another user
than root, at its replication peers, and the account then had all its parent
is allowed. The inline policy is removed
only on request, by an empty policy document such as `{"Statement":[]}`
(`mcli admin user svcacct edit --policy`); a client that left the policy out
to remove it must send that document. A JSON `null` policy counts as none.
In the Console, clearing the policy editor of an access key no longer removes
the policy; enter `{}` to remove it. The fix does not bring back a policy
that was removed, and a site of a replicated deployment that does not run the
fix still removes the policy when such an edit reaches it, so sites can
differ. Upgrade every site, then review on each site the accounts that
`svcacct info` reports with `Policy: implied`, and set the policy again on
those that were meant to be restricted.
## RELEASE.2026-11-09T00-00-00Z
Package version: `20261109000000.0.0`. Publication status and artifacts are
+4 -4
View File
@@ -885,16 +885,16 @@ func (a adminAPIHandlers) UpdateServiceAccount(w http.ResponseWriter, r *http.Re
return
}
// A request without a policy, or with a JSON null for one, keeps the
// policy the account has. An empty policy document is passed on as
// such: it is the request to remove the embedded policy.
var sp *policy.Policy
if len(updateReq.NewPolicy) > 0 && !bytes.Equal(updateReq.NewPolicy, []byte("null")) {
if len(updateReq.NewPolicy) > 0 {
sp, err = policy.ParseConfigStrict(bytes.NewReader(updateReq.NewPolicy))
if err != nil {
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
return
}
if sp.Version == "" && len(sp.Statements) == 0 {
sp = nil
}
}
opts := updateServiceAccountOpts{
secretKey: updateReq.NewSecretKey,
@@ -20,6 +20,7 @@ package cmd
import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
@@ -29,6 +30,7 @@ import (
"testing"
"time"
"github.com/cespare/xxhash/v2"
"github.com/google/uuid"
"github.com/minio/madmin-go/v3"
"github.com/minio/minio/internal/bucket/lifecycle"
@@ -632,6 +634,176 @@ func TestPoolsLifecycleLegacyHistory(t *testing.T) {
}
}
// authoritySignature2021 is the header signature of o, a version whose part
// ETags are all empty, as the releases from RELEASE.2021-11-24 until
// RELEASE.2022-01-25 computed it: with these ETags.
func authoritySignature2021(o xlMetaV2Object) (sig [4]byte) {
o.ErasureIndex = 0
crc := hashDeterministicString(o.MetaUser) ^ hashDeterministicBytes(o.MetaSys)
o.MetaUser, o.MetaSys = nil, nil
bts, _ := o.MarshalMsg(nil)
crc ^= xxhash.Sum64(bts)
binary.LittleEndian.PutUint32(sig[:], uint32(crc^(crc>>32)))
return sig
}
// A drive holds the header that the release which wrote a version there
// stored. Before RELEASE.2024-04-28 it has no erasure coding, and until
// RELEASE.2022-01-25 its signature counted the empty part ETags of versions
// written before RELEASE.2021-11-24. A heal writes the header of its own
// release and leaves the other drives alone, so the drives of an old version
// differ there for good once some were replaced. That is one revision, as
// reads and heal take it, on however many of the drives.
func TestPoolsLifecycleHeaderEras(t *testing.T) {
id := [16]byte{1}
current := func(drive int) xlMetaV2ShallowVersion { return authorityRevision(t, id, 1, drive+1, "") }
// stored returns the version as drive holds it after change, under the
// header that header derives from the one a current release writes.
stored := func(drive int, change func(*xlMetaV2Object), header func(xlMetaV2VersionHeader, xlMetaV2Object) xlMetaV2VersionHeader) xlMetaV2ShallowVersion {
ver := current(drive)
var v xlMetaV2Version
if _, err := v.UnmarshalMsg(ver.meta); err != nil {
t.Fatal(err)
}
change(v.ObjectV2)
meta, err := v.MarshalMsg(nil)
if err != nil {
t.Fatal(err)
}
return xlMetaV2ShallowVersion{header: header(ver.header, *v.ObjectV2), meta: meta}
}
eras := map[string]func(drive int) xlMetaV2ShallowVersion{
"no-erasure-coding": func(drive int) xlMetaV2ShallowVersion {
return stored(drive, func(*xlMetaV2Object) {}, func(h xlMetaV2VersionHeader, _ xlMetaV2Object) xlMetaV2VersionHeader {
h.EcN, h.EcM = 0, 0
return h
})
},
"empty-part-etags-signed": func(drive int) xlMetaV2ShallowVersion {
return stored(drive, func(o *xlMetaV2Object) { o.PartETags = []string{""} }, func(h xlMetaV2VersionHeader, o xlMetaV2Object) xlMetaV2VersionHeader {
h.EcN, h.EcM, h.Signature = 0, 0, authoritySignature2021(o)
return h
})
},
}
metaVs := slices.Repeat([]uint8{xlMetaVersion}, 16)
readQuorum := func(xlMetaV2ShallowVersion, uint8) int { return 12 }
for name, old := range eras {
t.Run(name, func(t *testing.T) {
if old(0).header == current(0).header {
t.Fatal("fixture: the old header is the current one")
}
if versionRevision(old(0), xlMetaVersion) != versionRevision(current(7), xlMetaVersion) {
t.Error("one version under the headers of two releases has two keys")
}
// The header of the key orders the merge: it is the current one.
if versionRevision(old(0), xlMetaVersion).header != current(0).header {
t.Error("the key does not carry the header of the current release")
}
for healed := range 17 {
metas := make([][]xlMetaV2ShallowVersion, 16)
for i := range metas {
if i < healed {
metas[i] = []xlMetaV2ShallowVersion{current(i)}
} else {
metas[i] = []xlMetaV2ShallowVersion{old(i)}
}
}
merged, err := authoritativeHistory(t.Context(), metas, metaVs, xlMetaVersion, 9, readQuorum)
if err != nil || len(merged) != 1 {
t.Errorf("%d healed drives: %d versions, err=%v", healed, len(merged), err)
}
}
})
}
// Control: the erasure coding still tells revisions apart, by the
// metadata. Another one under the same header is another revision.
other := stored(0, func(o *xlMetaV2Object) { o.ErasureM, o.ErasureN = 8, 8 }, func(h xlMetaV2VersionHeader, _ xlMetaV2Object) xlMetaV2VersionHeader { return h })
if versionRevision(other, xlMetaVersion) == versionRevision(current(0), xlMetaVersion) {
t.Error("another erasure coding under one header keeps the revision")
}
}
// The same on drives, through a heal: two versions with headers from before
// RELEASE.2024-04-28 on a set where half of the drives were replaced. GET
// reads them and heal has nothing left to do. Their expiry has to run as
// well: of a noncurrent version with several pools, and of all versions of a
// key with one pool too, which reads the history like the pools do. A
// restore takes the drives for one generation, not for a failed status write.
func TestPoolsLifecycleHealedOldHeaders(t *testing.T) {
for _, single := range []bool{false, true} {
name, key := "noncurrent-version", "ncv-old-headers"
if single {
name, key = "all-versions-one-pool", "eav-old-headers"
}
t.Run(name, func(t *testing.T) {
s := newSplitILM(t, versioning.Enabled, splitILMRules)
ctx := t.Context()
s.write(key, splitAt("old", 5, 0))
s.write(key, splitAt("new", 3, 0))
ids := []string{s.ids[key+" old"], s.ids[key+" new"]}
if single {
prior := s.z.serverPools
t.Cleanup(func() { s.z.serverPools = prior })
s.z.serverPools = s.z.serverPools[:1]
}
set := s.z.serverPools[0].getHashedSet(key)
disks := set.getDisks()
for _, disk := range disks {
rewriteTierTestMeta(t, disk, s.bucket, key, func(xl *xlMetaV2) {
for i := range xl.versions {
// Control: the key of a version this release wrote
// carries its header as stored.
if versionRevision(xl.versions[i], xl.metaV).header != xl.versions[i].header {
t.Fatal("the key of a current version carries another header")
}
xl.versions[i].header.EcN, xl.versions[i].header.EcM = 0, 0
}
})
}
replaced := len(disks) / 2
for _, disk := range disks[:replaced] {
if err := disk.Delete(ctx, s.bucket, key, DeleteOptions{Recursive: true, Immediate: true}); err != nil {
t.Fatal(err)
}
for _, id := range ids {
if _, err := s.z.HealObject(ctx, s.bucket, key, id, madmin.HealOpts{ScanMode: madmin.HealNormalScan}); err != nil {
t.Fatalf("heal %s: %v", id, err)
}
}
}
for i, disk := range disks {
if authorityHeader(t, disk, s.bucket, key, ids[0]).hasEC() != (i < replaced) {
t.Fatalf("fixture: drive %d of %d, %d replaced, holds the other header", i, len(disks), replaced)
}
}
authorityPresent(t, s, 0, key, ids[0])
res, err := s.z.HealObject(ctx, s.bucket, key, ids[0], madmin.HealOpts{DryRun: true, ScanMode: madmin.HealNormalScan})
if err != nil || slices.ContainsFunc(res.Before.Drives, func(d madmin.HealDriveInfo) bool { return d.State != madmin.DriveStateOk }) {
t.Fatalf("fixture: heal has work left: %v", err)
}
if gen, err := set.readRestoreGeneration(ctx, s.bucket, key, ids[0]); err != nil || gen.split {
t.Errorf("restore generation: split=%v, err=%v", gen.split, err)
}
if single {
_, err = s.z.DeleteObject(ctx, s.bucket, key, authorityDeleteAll)
} else {
err = authorityExpireVersion(t, s, key, ids[0])
}
if err != nil {
t.Fatalf("expiry: %v", err)
}
if _, err := s.z.GetObjectInfo(ctx, s.bucket, key, ObjectOptions{VersionID: ids[0]}); !isErrVersionNotFound(err) && !isErrObjectNotFound(err) {
t.Errorf("the expired version: %v", err)
}
if !single {
authorityPresent(t, s, 0, key, ids[1])
}
})
}
}
// authorityDeleteAll is the expiry of all versions of a key as the expiry
// worker sends it: a purge of all versions, approved by the lifecycle.
var authorityDeleteAll = ObjectOptions{DeleteAllVersions: true, Expiration: ExpirationOptions{Expire: true}}
+17 -5
View File
@@ -187,9 +187,17 @@ type revisionKey struct {
// input is canonical: each map, including the part checksums of a legacy
// version, is its own section with its entry count and sorted,
// length-prefixed entries, since msgp encodes maps in iteration order and an
// entry must not move between maps unnoticed. The header of a
// legacy version carries a signature over its per-shard checksums, so the
// key replaces it with one from the digest.
// entry must not move between maps unnoticed.
//
// A drive holds the header that the release which wrote the version there
// stored, and a heal writes the one of its own release: headers from before
// RELEASE.2024-04-28 carry no erasure coding, and the signature counted
// empty part ETags until RELEASE.2022-01-25. Reads and heal take such drives
// for one version (matchesNotStrict), and nothing rewrites them. The key has
// both as the current release computes them from the metadata, so that the
// header of a version it wrote stays as stored: the signature also orders
// versions of one time in the merge. The signature of a legacy version is
// over its per-shard checksums; the key has one from the digest.
func versionRevision(ver xlMetaV2ShallowVersion, metaV uint8) revisionKey {
h := sha256.New()
writeUint := func(n int) {
@@ -267,8 +275,12 @@ func versionRevision(ver xlMetaV2ShallowVersion, metaV uint8) revisionKey {
section("fields", 1)
writeField(bts)
h.Sum(key.digest[:0])
if v.Type == LegacyType {
switch v.Type {
case LegacyType:
copy(key.header.Signature[:], key.digest[:])
case ObjectType:
key.header.Signature = v.ObjectV2.Signature()
key.header.EcN, key.header.EcM = uint8(v.ObjectV2.ErasureN), uint8(v.ObjectV2.ErasureM)
}
return key
}
@@ -350,7 +362,7 @@ func authoritativeHistory(ctx context.Context, metas [][]xlMetaV2ShallowVersion,
// its count, and one that shares the header could stand in for the
// accepted one, so the merge only sees the accepted revision of each
// version and free versions as they are. It sees the headers of the
// keys, which do not differ between the drives of a legacy version.
// keys, which do not differ between the drives of a revision.
filtered := make([][]xlMetaV2ShallowVersion, len(metas))
for i, versions := range metas {
for j, ver := range versions {
+5 -6
View File
@@ -2930,12 +2930,11 @@ func updatedServiceAccountCredentials(ctx context.Context, accessKey string, cr
// at this point.
m.Delete(sessionPolicyNameExtracted)
// A nil sessionPolicy is an edit that does not concern the policy, and
// the embedded one stays. Only a policy that was given, and is empty,
// removes it: the account then has all its parent is allowed, which is
// usually more.
reset := opts.sessionPolicy != nil && opts.sessionPolicy.Version == "" && len(opts.sessionPolicy.Statements) == 0
if _, ok := m.Lookup(policy.SessionPolicyName); ok && reset {
nosp := opts.sessionPolicy == nil || opts.sessionPolicy.Version == "" && len(opts.sessionPolicy.Statements) == 0
// sessionPolicy is nil and there is embedded policy attached we remove
// embedded policy at that point.
if _, ok := m.Lookup(policy.SessionPolicyName); ok && nosp {
m.Delete(policy.SessionPolicyName)
m.Set(iamPolicyClaimNameSA(), inheritedPolicyType)
}
-227
View File
@@ -1,227 +0,0 @@
// Copyright (c) 2026 PGSTY
// SPDX-License-Identifier: AGPL-3.0-or-later
package cmd
import (
"context"
"encoding/json"
"fmt"
"runtime"
"strings"
"testing"
"time"
"github.com/minio/madmin-go/v3"
minio "github.com/minio/minio-go/v7"
"github.com/minio/minio/internal/auth"
"github.com/pgsty/silo-pkg/v3/policy"
)
// An edit of a service account that names no policy keeps the embedded
// policy: the account must not come out of a status change, a key rotation
// or a rename with all its parent is allowed. Only a policy that is given
// and empty removes the embedded one.
const svcAccEditPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::edit/*"]}]}`
func TestIAMServiceAccountEditKeepsPolicy(t *testing.T) {
f := newGroupRefreshFixture(t)
want, err := policy.ParseConfig(strings.NewReader(svcAccEditPolicy))
mustIAM(t, err)
// restricted creates a service account with the embedded policy.
restricted := func(t *testing.T, key, status string) {
t.Helper()
_, _, err := f.sys.NewServiceAccount(f.ctx, f.parent.AccessKey, nil, newServiceAccountOpts{accessKey: key, secretKey: "edit-original-secret", status: status, sessionPolicy: want})
mustIAM(t, err)
}
// embedded reports whether this server and storage have the policy, and
// returns the stored credential.
embedded := func(t *testing.T, key string) (cached, stored bool, cr auth.Credentials) {
t.Helper()
_, got, err := f.sys.GetServiceAccount(f.ctx, key)
mustIAM(t, err)
if got != nil && !got.Equals(*want) {
t.Fatalf("the embedded policy changed: %+v", got)
}
u, ok := f.stored(t, key)
if !ok {
t.Fatal("service account missing in storage")
}
claims, err := getClaimsFromTokenWithSecret(u.Credentials.SessionToken, u.Credentials.SecretKey)
mustIAM(t, err)
_, hasPolicy := claims.Lookup(policy.SessionPolicyName)
return got != nil, hasPolicy && claims.MapClaims[iamPolicyClaimNameSA()] == embeddedPolicyType, u.Credentials
}
// check wants the edit applied, and the policy kept or removed.
check := func(t *testing.T, key string, keep bool, took func(auth.Credentials) bool) {
t.Helper()
cached, stored, cr := embedded(t, key)
if cached != keep || stored != keep {
t.Errorf("embedded policy: cached=%v stored=%v, want %v", cached, stored, keep)
}
if took != nil && !took(cr) {
t.Errorf("the edit did not apply: status=%q name=%q description=%q expiration=%v", cr.Status, cr.Name, cr.Description, cr.Expiration)
}
}
later := UTCNow().Add(time.Hour)
for _, edit := range []struct {
name, start string
opts updateServiceAccountOpts
took func(auth.Credentials) bool
}{
{"disable", auth.AccountOn, updateServiceAccountOpts{status: string(madmin.AccountDisabled)}, func(c auth.Credentials) bool { return c.Status == auth.AccountOff }},
{"enable", auth.AccountOff, updateServiceAccountOpts{status: string(madmin.AccountEnabled)}, func(c auth.Credentials) bool { return c.Status == auth.AccountOn }},
{"status-off", auth.AccountOn, updateServiceAccountOpts{status: auth.AccountOff}, func(c auth.Credentials) bool { return c.Status == auth.AccountOff }},
{"status-on", auth.AccountOff, updateServiceAccountOpts{status: auth.AccountOn}, func(c auth.Credentials) bool { return c.Status == auth.AccountOn }},
{"secret-key", auth.AccountOn, updateServiceAccountOpts{secretKey: "edit-rotated-secret"}, func(c auth.Credentials) bool { return c.SecretKey == "edit-rotated-secret" }},
{"name", auth.AccountOn, updateServiceAccountOpts{name: "renamed"}, func(c auth.Credentials) bool { return c.Name == "renamed" }},
{"description", auth.AccountOn, updateServiceAccountOpts{description: "described"}, func(c auth.Credentials) bool { return c.Description == "described" }},
{"expiration", auth.AccountOn, updateServiceAccountOpts{expiration: &later}, func(c auth.Credentials) bool { return c.Expiration.Equal(later) }},
} {
t.Run(edit.name, func(t *testing.T) {
key := "edit-" + edit.name
restricted(t, key, edit.start)
_, err := f.sys.UpdateServiceAccount(f.ctx, key, edit.opts)
mustIAM(t, err)
check(t, key, true, edit.took)
})
}
t.Run("empty-policy", func(t *testing.T) {
const key = "edit-empty-policy"
restricted(t, key, auth.AccountOn)
_, err := f.sys.UpdateServiceAccount(f.ctx, key, updateServiceAccountOpts{sessionPolicy: &policy.Policy{}})
mustIAM(t, err)
check(t, key, false, nil)
})
// A peer applies the edit as the site that took it did. The edit goes
// through the encoding of the wire, which is where no policy and a null
// one become the same.
for _, edit := range []struct {
name string
update madmin.SRSvcAccUpdate
keep bool
took func(auth.Credentials) bool
}{
{"peer-status", madmin.SRSvcAccUpdate{Status: auth.AccountOff}, true, func(c auth.Credentials) bool { return c.Status == auth.AccountOff }},
{"peer-null", madmin.SRSvcAccUpdate{Description: "described", SessionPolicy: madmin.SRSessionPolicy("null")}, true, func(c auth.Credentials) bool { return c.Description == "described" }},
{"peer-empty", madmin.SRSvcAccUpdate{SessionPolicy: madmin.SRSessionPolicy(`{"Statement":[]}`)}, false, nil},
} {
t.Run(edit.name, func(t *testing.T) {
key := "edit-" + edit.name
restricted(t, key, auth.AccountOn)
edit.update.AccessKey = key
sent, err := json.Marshal(iamReplicationItem{SRIAMItem: madmin.SRIAMItem{
Type: madmin.SRIAMItemSvcAcc,
SvcAccChange: &madmin.SRSvcAccChange{Update: &edit.update},
UpdatedAt: UTCNow(),
}})
mustIAM(t, err)
var got iamReplicationItem
mustIAM(t, json.Unmarshal(sent, &got))
mustIAM(t, applyIAMReplicationItem(f.ctx, got))
check(t, key, edit.keep, edit.took)
})
}
}
// The same over the admin API, with what a client can send for no policy.
func TestServiceAccountUpdateKeepsPolicy(t *testing.T) {
if runtime.GOOS == globalWindowsOSName {
t.Skip("IAM integration harness is disabled on Windows")
}
s := newTestSuiteIAM(TestSuiteCommon{serverType: "ErasureSD", signer: signerV4}, false)
c := &check{t, s.serverType}
s.SetUpSuite(c)
defer s.TearDownSuite(c)
ctx, cancel := context.WithTimeout(context.Background(), testDefaultTimeout)
defer cancel()
bucket := getRandomBucketName()
if err := s.client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil {
c.Fatalf("bucket create error: %v", err)
}
if err := s.adm.AddCannedPolicy(ctx, "keeps-policy-parent", fmt.Appendf(nil, `{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["s3:ListBucket"],"Resource":["arn:aws:s3:::%s"]},
{"Effect":"Allow","Action":["s3:PutObject","s3:GetObject"],"Resource":["arn:aws:s3:::%s/*"]}]}`, bucket, bucket)); err != nil {
c.Fatalf("policy add error: %v", err)
}
parentAK, parentSK := mustGenerateCredentials(c)
if err := s.adm.SetUser(ctx, parentAK, parentSK, madmin.AccountEnabled); err != nil {
c.Fatalf("unable to set user: %v", err)
}
if _, err := s.adm.AttachPolicy(ctx, madmin.PolicyAssociationReq{Policies: []string{"keeps-policy-parent"}, User: parentAK}); err != nil {
c.Fatalf("unable to attach policy: %v", err)
}
// The account may read objects; listing is its parent's to do.
svcAK, svcSK := mustGenerateCredentials(c)
cr, err := s.adm.AddServiceAccount(ctx, madmin.AddServiceAccountReq{
Policy: fmt.Appendf(nil, `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::%s/*"]}]}`, bucket),
TargetUser: parentAK,
AccessKey: svcAK,
SecretKey: svcSK,
})
if err != nil {
c.Fatalf("unable to create svc acc: %v", err)
}
c.mustUpload(ctx, s.client, bucket)
svcClient := s.getUserClient(c, cr.AccessKey, cr.SecretKey, "")
c.mustDownload(ctx, svcClient, bucket)
c.mustNotListObjects(ctx, svcClient, bucket)
info := func() madmin.InfoServiceAccountResp {
info, err := s.adm.InfoServiceAccount(ctx, svcAK)
if err != nil {
c.Fatalf("unable to get svc acc info: %v", err)
}
return info
}
_, rotatedSK := mustGenerateCredentials(c)
later := UTCNow().Add(time.Hour).Truncate(time.Second)
for _, edit := range []struct {
name string
req madmin.UpdateServiceAccountReq
took func(madmin.InfoServiceAccountResp) bool
}{
{"disable", madmin.UpdateServiceAccountReq{NewStatus: "off"}, func(i madmin.InfoServiceAccountResp) bool { return i.AccountStatus == auth.AccountOff }},
{"enable", madmin.UpdateServiceAccountReq{NewStatus: "on"}, func(i madmin.InfoServiceAccountResp) bool { return i.AccountStatus == auth.AccountOn }},
{"description", madmin.UpdateServiceAccountReq{NewDescription: "described"}, func(i madmin.InfoServiceAccountResp) bool { return i.Description == "described" }},
{"name", madmin.UpdateServiceAccountReq{NewName: "renamed"}, func(i madmin.InfoServiceAccountResp) bool { return i.Name == "renamed" }},
{"expiry", madmin.UpdateServiceAccountReq{NewExpiration: &later}, func(i madmin.InfoServiceAccountResp) bool { return i.Expiration != nil && i.Expiration.Equal(later) }},
{"secret-key", madmin.UpdateServiceAccountReq{NewSecretKey: rotatedSK}, func(madmin.InfoServiceAccountResp) bool {
// The new key signs a request the embedded policy allows.
svcClient = s.getUserClient(c, svcAK, rotatedSK, "")
_, err := svcClient.StatObject(ctx, bucket, "some-object", minio.StatObjectOptions{})
return err == nil
}},
{"null-policy", madmin.UpdateServiceAccountReq{NewPolicy: json.RawMessage("null"), NewDescription: "null"}, func(i madmin.InfoServiceAccountResp) bool { return i.Description == "null" }},
} {
if err := s.adm.UpdateServiceAccount(ctx, svcAK, edit.req); err != nil {
c.Fatalf("%s: unable to update svc acc: %v", edit.name, err)
}
got := info()
if got.ImpliedPolicy {
c.Fatalf("%s: the account lost its embedded policy", edit.name)
}
if !edit.took(got) {
c.Fatalf("%s: the edit did not apply: %+v", edit.name, got)
}
}
c.mustNotListObjects(ctx, svcClient, bucket)
// An empty policy document asks for the parent's policies.
if err := s.adm.UpdateServiceAccount(ctx, svcAK, madmin.UpdateServiceAccountReq{NewPolicy: []byte(`{"Statement":[]}`)}); err != nil {
c.Fatalf("unable to remove the policy of svc acc: %v", err)
}
if !info().ImpliedPolicy {
c.Fatalf("an empty policy document must remove the embedded policy")
}
c.mustListObjects(ctx, svcClient, bucket)
}