mirror of
https://github.com/pgsty/minio.git
synced 2026-11-10 17:45:32 +03:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c11bfda8f4 |
@@ -1,5 +1,25 @@
|
||||
# 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
|
||||
|
||||
@@ -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 {
|
||||
if len(updateReq.NewPolicy) > 0 && !bytes.Equal(updateReq.NewPolicy, []byte("null")) {
|
||||
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,7 +20,6 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -30,7 +29,6 @@ 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"
|
||||
@@ -634,176 +632,6 @@ 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}}
|
||||
|
||||
@@ -187,17 +187,9 @@ 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.
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
func versionRevision(ver xlMetaV2ShallowVersion, metaV uint8) revisionKey {
|
||||
h := sha256.New()
|
||||
writeUint := func(n int) {
|
||||
@@ -275,12 +267,8 @@ func versionRevision(ver xlMetaV2ShallowVersion, metaV uint8) revisionKey {
|
||||
section("fields", 1)
|
||||
writeField(bts)
|
||||
h.Sum(key.digest[:0])
|
||||
switch v.Type {
|
||||
case LegacyType:
|
||||
if v.Type == 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
|
||||
}
|
||||
@@ -362,7 +350,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 revision.
|
||||
// keys, which do not differ between the drives of a legacy version.
|
||||
filtered := make([][]xlMetaV2ShallowVersion, len(metas))
|
||||
for i, versions := range metas {
|
||||
for j, ver := range versions {
|
||||
|
||||
+6
-5
@@ -2930,11 +2930,12 @@ func updatedServiceAccountCredentials(ctx context.Context, accessKey string, cr
|
||||
// at this point.
|
||||
m.Delete(sessionPolicyNameExtracted)
|
||||
|
||||
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 {
|
||||
// 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 {
|
||||
m.Delete(policy.SessionPolicyName)
|
||||
m.Set(iamPolicyClaimNameSA(), inheritedPolicyType)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user