mirror of
https://github.com/pgsty/minio.git
synced 2026-09-17 15:58:26 +03:00
7b4cacc392
Signed-off-by: Feng Ruohang <rh@vonng.com>
394 lines
17 KiB
Go
394 lines
17 KiB
Go
// Copyright (c) 2026 PGSTY
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/minio/madmin-go/v3"
|
|
"github.com/minio/minio/internal/grid"
|
|
xnet "github.com/pgsty/silo-pkg/v3/net"
|
|
"github.com/pgsty/silo-pkg/v3/policy"
|
|
etcd "go.etcd.io/etcd/client/v3"
|
|
"go.etcd.io/etcd/client/v3/namespace"
|
|
)
|
|
|
|
func prepareIAMRevisionFixture(t testing.TB, backend ...string) (context.Context, *IAMSys, ObjectLayer) {
|
|
t.Helper()
|
|
resetTestGlobals()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
disks, err := getRandomDisks(1)
|
|
mustIAM(t, err)
|
|
obj, _, err := initObjectLayer(ctx, mustGetPoolEndpoints(0, disks...))
|
|
mustIAM(t, err)
|
|
initAllSubsystems(ctx)
|
|
// Deliberately omit the periodic refresh goroutine. Fault injection can
|
|
// replace this fixture's storage interface without racing initialization.
|
|
var client *etcd.Client
|
|
if len(backend) != 0 && backend[0] == "etcd" {
|
|
endpoint := os.Getenv("SILO_TEST_IAM_REVOCATION_ETCD")
|
|
if endpoint == "" {
|
|
cancel()
|
|
obj.Shutdown(context.Background())
|
|
os.RemoveAll(disks[0])
|
|
t.Skip("set SILO_TEST_IAM_REVOCATION_ETCD to a disposable etcd endpoint")
|
|
}
|
|
client, err = etcd.New(etcd.Config{Endpoints: strings.Split(endpoint, ","), DialTimeout: 5 * time.Second})
|
|
mustIAM(t, err)
|
|
prefix := fmt.Sprintf("/silo-boundary-test/%d/", time.Now().UnixNano())
|
|
client.KV = namespace.NewKV(client.KV, prefix)
|
|
client.Watcher = namespace.NewWatcher(client.Watcher, prefix)
|
|
t.Cleanup(func() { client.Delete(context.Background(), "", etcd.WithPrefix()); client.Close() })
|
|
}
|
|
globalIAMSys.initStore(obj, client)
|
|
mustIAM(t, globalIAMSys.Load(ctx, true))
|
|
t.Cleanup(func() { cancel(); obj.Shutdown(context.Background()); os.RemoveAll(disks[0]); resetTestGlobals() })
|
|
return ctx, globalIAMSys, obj
|
|
}
|
|
|
|
func mustIAM(t testing.TB, err error) {
|
|
t.Helper()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
var errIAMInjectedWrite = errors.New("injected IAM persistence failure")
|
|
|
|
type iamFailingCleanupStore struct {
|
|
IAMStorageAPI
|
|
parentPath string
|
|
beforeCommit bool
|
|
}
|
|
|
|
func (s *iamFailingCleanupStore) saveIAMConfig(ctx context.Context, item any, path string, opts ...options) error {
|
|
if s.beforeCommit || path != s.parentPath {
|
|
return errIAMInjectedWrite
|
|
}
|
|
return s.IAMStorageAPI.saveIAMConfig(ctx, item, path, opts...)
|
|
}
|
|
|
|
func TestIAMRevocationCommitBoundary(t *testing.T) {
|
|
for _, before := range []bool{true, false} {
|
|
name := "after_identity_commit"
|
|
if before {
|
|
name = "before_identity_commit"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
ctx, sys, obj := prepareIAMRevisionFixture(t)
|
|
const user = "commit-boundary-user"
|
|
origin := UTCNow().Add(-time.Hour)
|
|
req := madmin.AddOrUpdateUserReq{SecretKey: "valid-test-password", Status: madmin.AccountEnabled}
|
|
_, err := sys.CreateUser(withIAMReplicationTime(ctx, origin), user, req)
|
|
mustIAM(t, err)
|
|
_, err = sys.PolicyDBSet(withIAMReplicationTime(ctx, origin.Add(time.Minute)), user, "readwrite", regUser, false)
|
|
mustIAM(t, err)
|
|
_, err = sys.AddUsersToGroup(withIAMReplicationTime(ctx, origin.Add(time.Minute)), "commit-group", []string{user})
|
|
mustIAM(t, err)
|
|
_, err = sys.PolicyDBSet(ctx, "commit-group", "readwrite", regUser, true)
|
|
mustIAM(t, err)
|
|
child, _, err := sys.NewServiceAccount(withIAMReplicationTime(ctx, origin), user, nil, newServiceAccountOpts{accessKey: "commit-child", secretKey: "valid-child-password"})
|
|
mustIAM(t, err)
|
|
args := policy.Args{AccountName: user, Action: policy.GetObjectAction, BucketName: "bucket", ObjectName: "object"}
|
|
if !sys.IsAllowed(args) {
|
|
t.Fatal("fixture has no grant")
|
|
}
|
|
siblingStore := &IAMStoreSys{IAMStorageAPI: newIAMObjectStore(obj, MinIOUsersSysType)}
|
|
mustIAM(t, siblingStore.LoadIAMCache(ctx, true))
|
|
sibling := &IAMSys{store: siblingStore, usersSysType: MinIOUsersSysType}
|
|
tg, err := grid.SetupTestGrid(2)
|
|
mustIAM(t, err)
|
|
defer tg.Cleanup()
|
|
var notifications atomic.Int32
|
|
mustIAM(t, deleteUserRPC.Register(tg.Managers[1], func(r *grid.MSS) (grid.NoPayload, *grid.RemoteErr) {
|
|
notifications.Add(1)
|
|
if err := sibling.LoadUserAfterDelete(ctx, r.Get(peerRESTUser)); err != nil {
|
|
return grid.NoPayload{}, grid.NewRemoteErr(err)
|
|
}
|
|
return grid.NoPayload{}, nil
|
|
}))
|
|
host, err := xnet.ParseHost(strings.TrimPrefix(tg.Hosts[1], "http://"))
|
|
mustIAM(t, err)
|
|
globalNotificationSys = &NotificationSys{peerClients: []*peerRESTClient{{host: host, gridConn: func() *grid.Connection { return tg.Managers[0].Connection(tg.Hosts[1]) }}}}
|
|
original := sys.store.IAMStorageAPI
|
|
sys.store.IAMStorageAPI = &iamFailingCleanupStore{IAMStorageAPI: original, parentPath: getUserIdentityPath(user, regUser), beforeCommit: before}
|
|
boundary := origin.Add(2 * time.Minute)
|
|
err = sys.DeleteUser(withIAMReplicationTime(ctx, boundary), user, true)
|
|
if !errors.Is(err, errIAMInjectedWrite) {
|
|
t.Fatalf("expected write failure, got %v", err)
|
|
}
|
|
sys.store.IAMStorageAPI = original
|
|
r, err := loadIAMRevision(ctx, original, getUserIdentityPath(user, regUser))
|
|
mustIAM(t, err)
|
|
if before {
|
|
if r.Deleted || !sys.IsAllowed(args) || !sibling.IsAllowed(args) || notifications.Load() != 0 {
|
|
t.Fatal("failure before commit changed the identity or grant")
|
|
}
|
|
return
|
|
}
|
|
if !r.Deleted || !r.RevokedBefore.Equal(boundary) {
|
|
t.Fatal("cleanup failure lost durable revocation")
|
|
}
|
|
if sys.IsAllowed(args) || sibling.IsAllowed(args) || notifications.Load() != 1 {
|
|
t.Fatal("cleanup failure retained old permission")
|
|
}
|
|
// Subsequent fixture writes need no additional RPC handlers.
|
|
globalNotificationSys = &NotificationSys{}
|
|
// Recreate after the partial cleanup. The old mapping, group member
|
|
// and child still exist in storage; none may authorize this identity.
|
|
_, err = sys.CreateUser(withIAMReplicationTime(ctx, origin.Add(3*time.Minute)), user, req)
|
|
mustIAM(t, err)
|
|
reloaded := &IAMStoreSys{IAMStorageAPI: newIAMObjectStore(obj, MinIOUsersSysType)}
|
|
mustIAM(t, reloaded.LoadIAMCache(ctx, true))
|
|
fresh := &IAMSys{store: reloaded, usersSysType: MinIOUsersSysType}
|
|
if fresh.IsAllowed(args) {
|
|
t.Fatal("cold reload restored partially cleaned-up grants")
|
|
}
|
|
if _, ok := reloaded.GetUser(child.AccessKey); ok {
|
|
t.Fatal("cold reload restored the old child")
|
|
}
|
|
gd, err := reloaded.GetGroupDescription("commit-group")
|
|
mustIAM(t, err)
|
|
if len(gd.Members) != 0 {
|
|
t.Fatalf("listing exposed a revoked group relation: %v", gd.Members)
|
|
}
|
|
_, err = sys.AddUsersToGroup(ctx, "commit-group", []string{user})
|
|
mustIAM(t, err)
|
|
if !sys.IsAllowed(args) {
|
|
t.Fatal("explicit new group grant was not accepted")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIAMGroupGrantVersionsSurviveSnapshotsAndRecreation(t *testing.T) {
|
|
ctx, sys, _ := prepareIAMRevisionFixture(t)
|
|
origin := UTCNow().Add(-time.Hour)
|
|
req := madmin.AddOrUpdateUserReq{SecretKey: "valid-test-password", Status: madmin.AccountEnabled}
|
|
for _, user := range []string{"grant-alice", "grant-bob"} {
|
|
_, err := sys.CreateUser(withIAMReplicationTime(ctx, origin), user, req)
|
|
mustIAM(t, err)
|
|
}
|
|
grant := origin.Add(time.Minute)
|
|
_, err := sys.AddUsersToGroup(withIAMReplicationTime(ctx, grant), "grant-group", []string{"grant-alice"})
|
|
mustIAM(t, err)
|
|
_, err = sys.PolicyDBSet(ctx, "grant-group", "readwrite", regUser, true)
|
|
mustIAM(t, err)
|
|
boundary := origin.Add(2 * time.Minute)
|
|
mustIAM(t, sys.DeleteUser(withIAMReplicationTime(ctx, boundary), "grant-alice", false))
|
|
_, err = sys.CreateUser(withIAMReplicationTime(ctx, origin.Add(3*time.Minute)), "grant-alice", req)
|
|
mustIAM(t, err)
|
|
_, err = sys.AddUsersToGroup(withIAMReplicationTime(ctx, origin.Add(4*time.Minute)), "grant-group", []string{"grant-bob"})
|
|
mustIAM(t, err)
|
|
_, err = sys.SetGroupStatus(withIAMReplicationTime(ctx, origin.Add(5*time.Minute)), "grant-group", true)
|
|
mustIAM(t, err)
|
|
var gi GroupInfo
|
|
mustIAM(t, sys.store.loadIAMConfig(ctx, &gi, getGroupInfoPath("grant-group")))
|
|
if !gi.MemberGrants["grant-alice"].Equal(grant) {
|
|
t.Fatal("unrelated group edits refreshed an old grant")
|
|
}
|
|
args := policy.Args{AccountName: "grant-alice", Action: policy.GetObjectAction, BucketName: "bucket", ObjectName: "object"}
|
|
for _, stale := range []time.Time{grant, boundary, {}} {
|
|
item := iamReplicationItem{SRIAMItem: madmin.SRIAMItem{Type: madmin.SRIAMItemGroupInfo, UpdatedAt: origin.Add(6 * time.Minute), GroupInfo: &madmin.SRGroupInfo{UpdateReq: madmin.GroupAddRemove{Group: "grant-group", Members: []string{"grant-alice", "grant-bob"}}}}, GroupSnapshot: true, GroupGrants: map[string]time.Time{"grant-alice": stale, "grant-bob": origin.Add(4 * time.Minute)}}
|
|
mustIAM(t, applyIAMReplicationItem(ctx, item))
|
|
mustIAM(t, sys.store.LoadIAMCache(ctx, false))
|
|
if sys.IsAllowed(args) {
|
|
t.Fatalf("snapshot restored revoked grant %s", stale)
|
|
}
|
|
gd, err := sys.GetGroupDescription("grant-group")
|
|
mustIAM(t, err)
|
|
if len(gd.Members) != 1 || gd.Members[0] != "grant-bob" {
|
|
t.Fatalf("inconsistent effective members: %v", gd.Members)
|
|
}
|
|
}
|
|
// Only an explicit post-revocation grant restores access.
|
|
freshAt, err := sys.AddUsersToGroup(ctx, "grant-group", []string{"grant-alice"})
|
|
mustIAM(t, err)
|
|
if !sys.IsAllowed(args) {
|
|
t.Fatal("explicit regrant rejected")
|
|
}
|
|
mustIAM(t, sys.store.LoadIAMCache(ctx, false))
|
|
mustIAM(t, sys.store.loadIAMConfig(ctx, &gi, getGroupInfoPath("grant-group")))
|
|
if !gi.MemberGrants["grant-alice"].Equal(freshAt) {
|
|
t.Fatal("new grant version was not persisted")
|
|
}
|
|
if !gi.MemberGrants["grant-bob"].Equal(origin.Add(4 * time.Minute)) {
|
|
t.Fatal("regranting Alice changed Bob's grant")
|
|
}
|
|
}
|
|
|
|
func TestIAMGroupRevocationCommitAndRecreation(t *testing.T) {
|
|
for _, backend := range []string{"object", "etcd"} {
|
|
t.Run(backend, func(t *testing.T) { testIAMGroupRevocationCommitAndRecreation(t, backend) })
|
|
}
|
|
}
|
|
|
|
func testIAMGroupRevocationCommitAndRecreation(t *testing.T, backend string) {
|
|
ctx, sys, obj := prepareIAMRevisionFixture(t, backend)
|
|
origin := UTCNow().Add(-time.Hour)
|
|
user, group := "group-boundary-user", "group-boundary"
|
|
_, err := sys.CreateUser(withIAMReplicationTime(ctx, origin), user, madmin.AddOrUpdateUserReq{SecretKey: "valid-user-password", Status: madmin.AccountEnabled})
|
|
mustIAM(t, err)
|
|
grant, boundary := origin.Add(time.Minute), origin.Add(2*time.Minute)
|
|
_, err = sys.AddUsersToGroup(withIAMReplicationTime(ctx, grant), group, []string{user})
|
|
mustIAM(t, err)
|
|
// A newer mapping must not veto the authoritative group deletion.
|
|
_, err = sys.PolicyDBSet(withIAMReplicationTime(ctx, origin.Add(3*time.Minute)), group, "readwrite", regUser, true)
|
|
mustIAM(t, err)
|
|
_, err = sys.RemoveUsersFromGroup(withIAMReplicationTime(ctx, boundary), group, nil)
|
|
mustIAM(t, err)
|
|
r, err := loadIAMRevision(ctx, sys.store, getGroupInfoPath(group))
|
|
mustIAM(t, err)
|
|
if !r.Deleted || !r.RevokedBefore.Equal(boundary) {
|
|
t.Fatal("newer mapping swallowed group deletion")
|
|
}
|
|
_, err = sys.AddUsersToGroup(withIAMReplicationTime(ctx, origin.Add(4*time.Minute)), group, nil)
|
|
mustIAM(t, err)
|
|
for _, at := range []time.Time{grant, boundary, {}} {
|
|
item := iamReplicationItem{SRIAMItem: madmin.SRIAMItem{Type: madmin.SRIAMItemGroupInfo, UpdatedAt: origin.Add(5 * time.Minute), GroupInfo: &madmin.SRGroupInfo{UpdateReq: madmin.GroupAddRemove{Group: group, Members: []string{user}}}}, GroupSnapshot: true, GroupGrants: map[string]time.Time{user: at}}
|
|
mustIAM(t, applyIAMReplicationItem(ctx, item))
|
|
gd, err := sys.GetGroupDescription(group)
|
|
mustIAM(t, err)
|
|
if len(gd.Members) != 0 {
|
|
t.Fatalf("group recreation restored grant %s", at)
|
|
}
|
|
}
|
|
_, err = sys.AddUsersToGroup(ctx, group, []string{user})
|
|
mustIAM(t, err)
|
|
args := policy.Args{AccountName: user, Action: policy.GetObjectAction, BucketName: "bucket", ObjectName: "object"}
|
|
if !sys.IsAllowed(args) {
|
|
t.Fatal("explicit group regrant was rejected")
|
|
}
|
|
// The newer live snapshot may arrive before an older group deletion.
|
|
lateBoundary := origin.Add(6 * time.Minute)
|
|
_, err = sys.RemoveUsersFromGroup(withIAMReplicationTime(ctx, lateBoundary), group, nil)
|
|
mustIAM(t, err)
|
|
r, err = loadIAMRevision(ctx, sys.store, getGroupInfoPath(group))
|
|
mustIAM(t, err)
|
|
if r.Deleted || !r.RevokedBefore.Equal(lateBoundary) {
|
|
t.Fatal("late deletion lost the live group's revocation boundary")
|
|
}
|
|
// The old mapping is now revoked; a new explicit mapping restores access.
|
|
if sys.IsAllowed(args) {
|
|
t.Fatal("late group boundary retained an old mapping")
|
|
}
|
|
_, err = sys.PolicyDBSet(ctx, group, "readwrite", regUser, true)
|
|
mustIAM(t, err)
|
|
store := &IAMStoreSys{IAMStorageAPI: newIAMObjectStore(obj, MinIOUsersSysType)}
|
|
if es, ok := sys.store.IAMStorageAPI.(*IAMEtcdStore); ok {
|
|
store.IAMStorageAPI = newIAMEtcdStore(es.client, MinIOUsersSysType)
|
|
}
|
|
mustIAM(t, store.LoadIAMCache(ctx, true))
|
|
fresh := &IAMSys{store: store, usersSysType: MinIOUsersSysType}
|
|
if !fresh.IsAllowed(args) {
|
|
t.Fatal("reload lost explicit grants after a retained group boundary")
|
|
}
|
|
item, err := globalSiteReplicationSys.replicationItem(ctx, madmin.SRIAMItem{Type: madmin.SRIAMItemGroupInfo, GroupInfo: &madmin.SRGroupInfo{UpdateReq: madmin.GroupAddRemove{Group: group}}, UpdatedAt: r.timestamp()})
|
|
mustIAM(t, err)
|
|
if !item.RevokedBefore.Equal(lateBoundary) || !item.GroupGrants[user].After(lateBoundary) {
|
|
t.Fatal("group snapshot lost revision metadata")
|
|
}
|
|
}
|
|
|
|
// A committed revision is observable before all cached dependents have been
|
|
// cleaned up. Every authorization read must apply that boundary in this window.
|
|
func TestIAMCachedMappingHonorsCommittedRevision(t *testing.T) {
|
|
ctx, sys, _ := prepareIAMRevisionFixture(t)
|
|
origin := UTCNow().Add(-time.Hour)
|
|
parent := "cached-external-parent"
|
|
_, err := sys.PolicyDBSet(withIAMReplicationTime(ctx, origin), parent, "readwrite", stsUser, false)
|
|
mustIAM(t, err)
|
|
policies, err := sys.PolicyDBGet(parent)
|
|
mustIAM(t, err)
|
|
if len(policies) == 0 {
|
|
t.Fatal("fixture has no STS-parent mapping")
|
|
}
|
|
mustIAM(t, sys.store.saveIAMConfig(ctx, &MappedPolicy{Version: 1, Deleted: true, UpdatedAt: origin.Add(time.Minute)}, getMappedPolicyPath(parent, stsUser, false)))
|
|
policies, err = sys.PolicyDBGet(parent)
|
|
mustIAM(t, err)
|
|
if len(policies) != 0 {
|
|
t.Fatal("cached STS mapping ignored its own namespace tombstone")
|
|
}
|
|
|
|
user, group := "cached-group-user", "cached-group"
|
|
_, err = sys.CreateUser(withIAMReplicationTime(ctx, origin), user, madmin.AddOrUpdateUserReq{SecretKey: "valid-user-password", Status: madmin.AccountEnabled})
|
|
mustIAM(t, err)
|
|
grant := origin.Add(5 * time.Minute)
|
|
_, err = sys.AddUsersToGroup(withIAMReplicationTime(ctx, grant), group, []string{user})
|
|
mustIAM(t, err)
|
|
_, err = sys.PolicyDBSet(withIAMReplicationTime(ctx, origin), group, "readwrite", regUser, true)
|
|
mustIAM(t, err)
|
|
args := policy.Args{AccountName: user, Action: policy.GetObjectAction, BucketName: "bucket", ObjectName: "object"}
|
|
if !sys.IsAllowed(args) {
|
|
t.Fatal("fixture has no group grant")
|
|
}
|
|
// A late deletion preserves the newer member grant but revokes the older
|
|
// policy mapping. Simulate the interval before mapping cleanup completes.
|
|
gi := GroupInfo{Version: 1, Status: statusEnabled, Members: []string{user}, MemberGrants: map[string]time.Time{user: grant}, UpdatedAt: grant, RevokedBefore: origin.Add(2 * time.Minute)}
|
|
mustIAM(t, sys.store.saveIAMConfig(ctx, &gi, getGroupInfoPath(group)))
|
|
if sys.IsAllowed(args) {
|
|
t.Fatal("cached group mapping ignored the committed group boundary")
|
|
}
|
|
gd, err := sys.GetGroupDescription(group)
|
|
mustIAM(t, err)
|
|
if gd.Policy != "" {
|
|
t.Fatal("group listing exposed a revoked mapping")
|
|
}
|
|
}
|
|
|
|
type (
|
|
iamExpiryLockFailure struct {
|
|
ObjectLayer
|
|
path string
|
|
}
|
|
iamFailedExpiryLock struct{ RWLocker }
|
|
)
|
|
|
|
func (o *iamExpiryLockFailure) NewNSLock(bucket string, objects ...string) RWLocker {
|
|
lock := o.ObjectLayer.NewNSLock(bucket, objects...)
|
|
if bucket == minioMetaBucket && len(objects) == 1 && objects[0] == o.path+".revision-lock" {
|
|
return &iamFailedExpiryLock{RWLocker: lock}
|
|
}
|
|
return lock
|
|
}
|
|
|
|
func (l *iamFailedExpiryLock) GetLock(context.Context, *dynamicTimeout) (LockContext, error) {
|
|
return LockContext{}, errIAMInjectedWrite
|
|
}
|
|
|
|
func TestIAMExpiredCredentialCleanupDoesNotBlockLoading(t *testing.T) {
|
|
ctx, sys, obj := prepareIAMRevisionFixture(t)
|
|
_, err := sys.CreateUser(ctx, "healthy-user", madmin.AddOrUpdateUserReq{SecretKey: "healthy-user-password", Status: madmin.AccountEnabled})
|
|
mustIAM(t, err)
|
|
_, err = sys.PolicyDBSet(ctx, "healthy-user", "readwrite", regUser, false)
|
|
mustIAM(t, err)
|
|
c, _, err := sys.NewServiceAccount(ctx, "healthy-user", nil, newServiceAccountOpts{accessKey: "expired-service", secretKey: "expired-service-password"})
|
|
mustIAM(t, err)
|
|
c.Expiration = UTCNow().Add(-time.Hour)
|
|
path := getUserIdentityPath(c.AccessKey, svcUser)
|
|
mustIAM(t, sys.store.saveIAMConfig(ctx, &UserIdentity{Version: 1, Credentials: c, UpdatedAt: UTCNow()}, path))
|
|
// A cold loader sees the existing version but cannot acquire the cleanup
|
|
// write lock. Healthy users must still load; the expired one stays denied.
|
|
fresh := &IAMStoreSys{IAMStorageAPI: newIAMObjectStore(&iamExpiryLockFailure{ObjectLayer: obj, path: path}, MinIOUsersSysType)}
|
|
mustIAM(t, fresh.LoadIAMCache(ctx, true))
|
|
if _, ok := fresh.GetUser("healthy-user"); !ok {
|
|
t.Fatal("cleanup failure prevented healthy IAM state from loading")
|
|
}
|
|
if _, ok := fresh.GetUser(c.AccessKey); ok {
|
|
t.Fatal("cleanup failure admitted an expired service account")
|
|
}
|
|
r, err := loadIAMRevision(ctx, fresh, path)
|
|
mustIAM(t, err)
|
|
if r.Deleted || !r.Credentials.IsExpired() {
|
|
t.Fatal("failed cleanup lost the existing expired revision")
|
|
}
|
|
}
|