mirror of
https://github.com/pgsty/minio.git
synced 2026-09-15 23:14:04 +03:00
709d50a916
Retain source-ordered tombstones and parent grant boundaries across both IAM backends, cache reloads, and deliberate identity recreation. Reconcile deletions through a versioned, bounded replication protocol with restart-aware acknowledgements. Cover inherited group grants, STS retention, same-key service recreation, absolute expiration, and failures after the durable commit. Document coordinated upgrades and the remaining consistency boundaries. Signed-off-by: Feng Ruohang <rh@vonng.com>
750 lines
26 KiB
Go
750 lines
26 KiB
Go
// Copyright (c) 2026 PGSTY
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/minio/madmin-go/v3"
|
|
"github.com/minio/minio/internal/auth"
|
|
etcd "go.etcd.io/etcd/client/v3"
|
|
"go.etcd.io/etcd/client/v3/concurrency"
|
|
)
|
|
|
|
var errIAMStaleUpdate = errors.New("IAM update predates a stored revision or revocation")
|
|
|
|
// The parent is still live; callers must not broadcast a user deletion when
|
|
// only its revocation boundary was retained.
|
|
var errIAMRevocationRetained = errors.New("IAM revocation recorded without deleting the record")
|
|
|
|
// A revocation advances the boundary even when a newer identity already
|
|
// exists. Keep this operation distinct from replacing/deleting that identity.
|
|
type iamUserRevocation struct {
|
|
UserIdentity
|
|
retained bool
|
|
}
|
|
|
|
type iamGroupRevocation struct {
|
|
GroupInfo
|
|
retained bool
|
|
requireEmpty bool
|
|
}
|
|
|
|
// Natural expiration is distinct from revoking a live credential. An expired
|
|
// immutable STS token can be removed; a reusable service-account key retains
|
|
// its revision so an older non-expiring credential cannot return.
|
|
type iamExpireIdentity struct{}
|
|
|
|
// The authoritative revocation is durable even if dependent cleanup fails.
|
|
// Callers must publish it to sibling caches before returning the error.
|
|
type iamCommittedCleanupError struct {
|
|
err error
|
|
retained bool
|
|
}
|
|
|
|
func (e *iamCommittedCleanupError) Error() string {
|
|
return "IAM revocation committed; cleanup failed: " + e.err.Error()
|
|
}
|
|
func (e *iamCommittedCleanupError) Unwrap() error { return e.err }
|
|
|
|
type iamReplicationTimeKey struct{}
|
|
|
|
func withIAMReplicationTime(ctx context.Context, at time.Time) context.Context {
|
|
return context.WithValue(ctx, iamReplicationTimeKey{}, at)
|
|
}
|
|
|
|
func iamReplicationTime(ctx context.Context) (time.Time, bool) {
|
|
at, ok := ctx.Value(iamReplicationTimeKey{}).(time.Time)
|
|
return at, ok
|
|
}
|
|
|
|
func iamReplicationError(err error) error {
|
|
if errors.Is(err, errIAMStaleUpdate) {
|
|
// Retrying an obsolete event cannot change the result.
|
|
return nil
|
|
}
|
|
return wrapSRErr(err)
|
|
}
|
|
|
|
// Deletions occupy the original IAM config path. They contain no secret or
|
|
// grant and are hidden by the normal loaders, but remain available to heal
|
|
// and to timestamp comparisons after a restart. Do not age them out: a peer
|
|
// can be offline indefinitely.
|
|
type iamRevision struct {
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
UpdateDate time.Time `json:"UpdateDate"`
|
|
Deleted bool `json:"deleted"`
|
|
RevokedBefore time.Time `json:"revokedBefore"`
|
|
ExpiresAt time.Time `json:"expiresAt,omitempty"`
|
|
Credentials auth.Credentials `json:"credentials"`
|
|
}
|
|
|
|
func (r iamRevision) timestamp() time.Time {
|
|
if r.UpdateDate.After(r.UpdatedAt) {
|
|
return r.UpdateDate
|
|
}
|
|
return r.UpdatedAt
|
|
}
|
|
|
|
func loadIAMRevision(ctx context.Context, store IAMStorageAPI, path string) (iamRevision, error) {
|
|
var r iamRevision
|
|
err := store.loadIAMConfig(ctx, &r, path)
|
|
if errors.Is(err, errConfigNotFound) {
|
|
err = nil
|
|
}
|
|
return r, err
|
|
}
|
|
|
|
func (store *IAMStoreSys) checkIAMRevision(ctx context.Context, path string, deleting bool) error {
|
|
at, replicated := iamReplicationTime(ctx)
|
|
if !replicated {
|
|
return nil
|
|
}
|
|
return store.withIAMStorage(ctx, func(ctx context.Context) error {
|
|
r, err := loadIAMRevision(ctx, store.IAMStorageAPI, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if r.timestamp().After(at) || (r.Deleted && !deleting && !at.After(r.timestamp())) {
|
|
return errIAMStaleUpdate
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// This signed claim records the parent's revocation boundary at issuance.
|
|
// Unlike UpdatedAt, it cannot advance when an offline site edits an old child.
|
|
// It travels in the existing service-account Claims and STS SessionToken fields.
|
|
const iamParentRevocationClaim = "siloParentRevocation"
|
|
|
|
func setIAMParentRevocationClaim(ctx context.Context, store IAMStorageAPI, parent string, claims map[string]any) error {
|
|
delete(claims, iamParentRevocationClaim)
|
|
if parent == "" || parent == globalActiveCred.AccessKey {
|
|
return nil
|
|
}
|
|
r, err := loadIAMRevision(ctx, store, getUserIdentityPath(parent, regUser))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if r.Deleted {
|
|
return errIAMStaleUpdate
|
|
}
|
|
if !r.RevokedBefore.IsZero() {
|
|
claims[iamParentRevocationClaim] = r.RevokedBefore.Format(time.RFC3339Nano)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func iamCredentialSurvivesRevocation(cred auth.Credentials, at time.Time) bool {
|
|
if at.IsZero() {
|
|
return true
|
|
}
|
|
s, _ := cred.Claims[iamParentRevocationClaim].(string)
|
|
issuedAfter, err := time.Parse(time.RFC3339Nano, s)
|
|
return err == nil && !issuedAfter.Before(at)
|
|
}
|
|
|
|
// Parent revocations delete old children even if an offline peer has edited
|
|
// them later. Preserve children that prove issuance after this revocation.
|
|
func iamChildDeletionContext(ctx context.Context, child UserIdentity) (context.Context, bool) {
|
|
if at, replicated := iamReplicationTime(ctx); replicated {
|
|
if !at.IsZero() && iamCredentialSurvivesRevocation(child.Credentials, at) {
|
|
return ctx, false
|
|
}
|
|
if child.UpdatedAt.After(at) {
|
|
ctx = withIAMReplicationTime(ctx, child.UpdatedAt)
|
|
}
|
|
}
|
|
return ctx, true
|
|
}
|
|
|
|
// A delayed service account or STS event must not outlive deletion of its
|
|
// built-in parent. The caller must populate Claims from the verified token.
|
|
func checkIAMParentRevision(ctx context.Context, store IAMStorageAPI, cred auth.Credentials) error {
|
|
parent := cred.ParentUser
|
|
if parent == "" || parent == globalActiveCred.AccessKey {
|
|
return nil
|
|
}
|
|
r, err := loadIAMRevision(ctx, store, getUserIdentityPath(parent, regUser))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if r.Deleted || !iamCredentialSurvivesRevocation(cred, r.RevokedBefore) {
|
|
return errIAMStaleUpdate
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Called with the IAM writer mutex and cache lock held. Persistence only
|
|
// touches the caller's record, not the cache. Keep writers serialized while
|
|
// allowing cached authentication reads throughout storage and lock waits.
|
|
func (store *IAMStoreSys) withIAMStorage(ctx context.Context, fn func(context.Context) error) error {
|
|
store.IAMStorageAPI.unlock()
|
|
defer store.IAMStorageAPI.lock()
|
|
ctx, cancel := context.WithTimeout(ctx, defaultContextTimeout)
|
|
defer cancel()
|
|
return fn(ctx)
|
|
}
|
|
|
|
func (store *IAMStoreSys) saveIAMRevision(ctx context.Context, path string, item any, opts ...options) error {
|
|
return store.withIAMStorage(ctx, func(ctx context.Context) error {
|
|
return saveIAMRevision(ctx, store.IAMStorageAPI, path, item, opts...)
|
|
})
|
|
}
|
|
|
|
func (store *IAMStoreSys) checkIAMParentRevision(ctx context.Context, cred auth.Credentials) error {
|
|
return store.withIAMStorage(ctx, func(ctx context.Context) error {
|
|
return checkIAMParentRevision(ctx, store.IAMStorageAPI, cred)
|
|
})
|
|
}
|
|
|
|
// Update the caller's record with the persisted revision before it is cached.
|
|
func saveIAMRevision(ctx context.Context, store IAMStorageAPI, path string, item any, opts ...options) error {
|
|
ctx, cancel := context.WithTimeout(ctx, defaultContextTimeout)
|
|
defer cancel()
|
|
|
|
// Serialize compare-and-write across nodes, as well as goroutines. Use a
|
|
// separate lock name so saving the config does not reacquire this lock.
|
|
switch s := store.(type) {
|
|
case *IAMObjectStore:
|
|
lock := s.objAPI.NewNSLock(minioMetaBucket, path+".revision-lock")
|
|
lc, err := lock.GetLock(ctx, globalOperationTimeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer lock.Unlock(lc)
|
|
ctx = lc.Context()
|
|
case *IAMEtcdStore:
|
|
// Mutex.Lock also uses Client.Ctx() for cleanup after cancellation.
|
|
// Borrow the existing services with the operation's bounded context;
|
|
// never close this facade, which does not own those services.
|
|
client := etcd.NewCtxClient(ctx, etcd.WithZapLogger(s.client.GetLogger()))
|
|
client.KV, client.Lease, client.Watcher = s.client.KV, s.client.Lease, s.client.Watcher
|
|
session, err := concurrency.NewSession(client, concurrency.WithContext(ctx))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
session.Orphan()
|
|
// A canceled operation must still release its lease when etcd is
|
|
// reachable. If it is unavailable, stop waiting and let it expire.
|
|
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), defaultContextTimeout)
|
|
defer cancel()
|
|
_, _ = s.client.Revoke(cleanupCtx, session.Lease())
|
|
}()
|
|
lock := concurrency.NewMutex(session, fmt.Sprintf("%s/iam-revision-locks/%x", minioConfigPrefix, sha256.Sum256([]byte(path))))
|
|
if err = lock.Lock(ctx); err != nil {
|
|
return err
|
|
}
|
|
// Revoking the session lease releases the lock, including on cancellation.
|
|
}
|
|
previous, err := loadIAMRevision(ctx, store, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, expiring := item.(*iamExpireIdentity); expiring {
|
|
sts := strings.HasPrefix(path, iamConfigSTSPrefix)
|
|
if previous.Deleted {
|
|
if sts && !previous.ExpiresAt.IsZero() && UTCNow().After(previous.ExpiresAt) {
|
|
return expireIAMSTSConfig(ctx, store, path)
|
|
}
|
|
return nil
|
|
}
|
|
if previous.timestamp().IsZero() || !previous.Credentials.IsExpired() {
|
|
return nil
|
|
}
|
|
if sts {
|
|
return expireIAMSTSConfig(ctx, store, path)
|
|
}
|
|
item = &UserIdentity{Version: 1, Deleted: true}
|
|
ctx = withIAMReplicationTime(ctx, previous.timestamp())
|
|
}
|
|
var revocation *iamUserRevocation
|
|
if op, ok := item.(*iamUserRevocation); ok {
|
|
revocation = op
|
|
op.UserIdentity = UserIdentity{Version: 1, Deleted: true}
|
|
if origin, replicated := iamReplicationTime(ctx); replicated && previous.timestamp().After(origin) {
|
|
if previous.Deleted || !origin.After(previous.RevokedBefore) {
|
|
return errIAMStaleUpdate
|
|
}
|
|
op.retained = true
|
|
op.UserIdentity = UserIdentity{Version: 1, Credentials: previous.Credentials, UpdatedAt: previous.timestamp(), RevokedBefore: origin}
|
|
ctx = withIAMReplicationTime(ctx, previous.timestamp())
|
|
}
|
|
item = &op.UserIdentity
|
|
}
|
|
var groupRevocation *iamGroupRevocation
|
|
if op, ok := item.(*iamGroupRevocation); ok {
|
|
groupRevocation = op
|
|
var group GroupInfo
|
|
if err := store.loadIAMConfig(ctx, &group, path); err != nil && !errors.Is(err, errConfigNotFound) {
|
|
return err
|
|
}
|
|
if op.requireEmpty && !group.Deleted {
|
|
for _, member := range group.Members {
|
|
r := store.revisionIndex().get(getUserIdentityPath(member, regUser))
|
|
at := group.MemberGrants[member]
|
|
if !r.Deleted && (r.RevokedBefore.IsZero() || at.After(r.RevokedBefore)) && (group.RevokedBefore.IsZero() || at.After(group.RevokedBefore)) {
|
|
return errGroupNotEmpty
|
|
}
|
|
}
|
|
}
|
|
op.GroupInfo = GroupInfo{Version: 1, Deleted: true}
|
|
if origin, replicated := iamReplicationTime(ctx); replicated && previous.timestamp().After(origin) {
|
|
if previous.Deleted || !origin.After(previous.RevokedBefore) {
|
|
return errIAMStaleUpdate
|
|
}
|
|
op.retained = true
|
|
op.GroupInfo = group
|
|
op.RevokedBefore = origin
|
|
ctx = withIAMReplicationTime(ctx, previous.timestamp())
|
|
}
|
|
item = &op.GroupInfo
|
|
}
|
|
var at *time.Time
|
|
var deleted bool
|
|
switch v := item.(type) {
|
|
case *UserIdentity:
|
|
at, deleted = &v.UpdatedAt, v.Deleted
|
|
if boundary, ok := ctx.Value(iamRecordBoundaryKey{}).(time.Time); ok && boundary.After(v.RevokedBefore) {
|
|
v.RevokedBefore = boundary
|
|
}
|
|
if previous.RevokedBefore.After(v.RevokedBefore) {
|
|
v.RevokedBefore = previous.RevokedBefore
|
|
}
|
|
case *GroupInfo:
|
|
at, deleted = &v.UpdatedAt, v.Deleted
|
|
if boundary, ok := ctx.Value(iamRecordBoundaryKey{}).(time.Time); ok && boundary.After(v.RevokedBefore) {
|
|
v.RevokedBefore = boundary
|
|
}
|
|
if !deleted {
|
|
var group GroupInfo
|
|
if err := store.loadIAMConfig(ctx, &group, path); err != nil && !errors.Is(err, errConfigNotFound) {
|
|
return err
|
|
}
|
|
mergeIAMGroupMutation(ctx, group, v)
|
|
}
|
|
if previous.RevokedBefore.After(v.RevokedBefore) {
|
|
v.RevokedBefore = previous.RevokedBefore
|
|
}
|
|
case *MappedPolicy:
|
|
at, deleted = &v.UpdatedAt, v.Deleted
|
|
case *PolicyDoc:
|
|
at, deleted = &v.UpdateDate, v.Deleted
|
|
default:
|
|
return errInvalidArgument
|
|
}
|
|
if strings.HasPrefix(path, iamConfigSTSPrefix) && previous.Deleted && !deleted {
|
|
// STS access keys identify immutable tokens, not reusable user names.
|
|
return errIAMStaleUpdate
|
|
}
|
|
if origin, replicated := iamReplicationTime(ctx); replicated {
|
|
*at = origin
|
|
if previous.timestamp().After(origin) || (previous.Deleted && !deleted && !origin.After(previous.timestamp())) {
|
|
return errIAMStaleUpdate
|
|
}
|
|
if strings.HasPrefix(path, iamConfigServiceAccountsPrefix) && !deleted && previous.Credentials.AccessKey != "" && previous.timestamp().Equal(origin) {
|
|
// Duplicate service snapshots are acknowledgements, not new creates
|
|
// or edits. Reload the winner without writing, so even a stale
|
|
// sibling cache is refreshed by the retry before acknowledging it.
|
|
return store.loadIAMConfig(ctx, item, path)
|
|
}
|
|
if previous.Deleted && deleted && !origin.After(previous.timestamp()) {
|
|
// An already-applied tombstone needs no further persistent write.
|
|
if v, ok := item.(*UserIdentity); ok {
|
|
v.RevokedBefore = previous.RevokedBefore
|
|
}
|
|
if v, ok := item.(*GroupInfo); ok {
|
|
v.RevokedBefore = previous.RevokedBefore
|
|
}
|
|
return nil
|
|
}
|
|
} else {
|
|
if previous.Deleted && deleted {
|
|
// A peer notification without an originating revision must not
|
|
// advance a tombstone past a subsequent deliberate recreation.
|
|
*at = previous.timestamp()
|
|
if v, ok := item.(*UserIdentity); ok {
|
|
v.RevokedBefore = previous.RevokedBefore
|
|
}
|
|
if v, ok := item.(*GroupInfo); ok {
|
|
v.RevokedBefore = previous.RevokedBefore
|
|
}
|
|
return nil
|
|
}
|
|
if at.IsZero() {
|
|
*at = UTCNow()
|
|
}
|
|
if !at.After(previous.timestamp()) {
|
|
*at = previous.timestamp().Add(time.Nanosecond)
|
|
}
|
|
}
|
|
if v, ok := item.(*UserIdentity); ok {
|
|
if deleted {
|
|
// Retain only the parent name for root-account exclusion during heal.
|
|
v.Credentials = auth.Credentials{ParentUser: previous.Credentials.ParentUser}
|
|
v.RevokedBefore = *at
|
|
if strings.HasPrefix(path, iamConfigSTSPrefix) && !previous.Credentials.Expiration.IsZero() && !previous.Credentials.Expiration.Equal(timeSentinel) {
|
|
// The signed STS token cannot authorize beyond this time, even
|
|
// if an offline site replays it with a newer event timestamp.
|
|
v.ExpiresAt = previous.Credentials.Expiration.Add(globalMaxSkewTime)
|
|
opts = []options{{ttl: max(1, int64(time.Until(v.ExpiresAt).Seconds())+1)}}
|
|
}
|
|
} else {
|
|
if v.Credentials.SessionToken != "" && v.Credentials.Claims == nil {
|
|
claims, err := extractJWTClaims(*v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
v.Credentials.Claims = claims.Map()
|
|
}
|
|
if err = checkIAMParentRevision(ctx, store, v.Credentials); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if v, ok := item.(*GroupInfo); ok && deleted {
|
|
v.RevokedBefore = *at
|
|
v.Members, v.MemberGrants = nil, nil
|
|
}
|
|
if _, ok := item.(*MappedPolicy); ok && !deleted {
|
|
if parentPath := iamMappingParentPath(path); parentPath != "" {
|
|
parent, err := loadIAMRevision(ctx, store, parentPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if parent.Deleted {
|
|
return errIAMStaleUpdate
|
|
}
|
|
if !parent.RevokedBefore.IsZero() && !at.After(parent.RevokedBefore) {
|
|
if _, replicated := iamReplicationTime(ctx); replicated {
|
|
return errIAMStaleUpdate
|
|
}
|
|
*at = parent.RevokedBefore.Add(time.Nanosecond)
|
|
}
|
|
}
|
|
}
|
|
if err := store.saveIAMConfig(ctx, item, path, opts...); err != nil {
|
|
return err
|
|
}
|
|
if revocation != nil && revocation.retained {
|
|
return errIAMRevocationRetained
|
|
}
|
|
if groupRevocation != nil && groupRevocation.retained {
|
|
return errIAMRevocationRetained
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (iamOS *IAMObjectStore) listIAMConfigPaths(ctx context.Context) ([]string, error) {
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
var paths []string
|
|
for item := range listIAMConfigItems(ctx, iamOS.objAPI, iamConfigPrefix+"/") {
|
|
if item.Err != nil {
|
|
return nil, item.Err
|
|
}
|
|
paths = append(paths, iamConfigPrefix+"/"+item.Item)
|
|
}
|
|
return paths, nil
|
|
}
|
|
|
|
func (ies *IAMEtcdStore) listIAMConfigPaths(ctx context.Context) ([]string, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, defaultContextTimeout)
|
|
defer cancel()
|
|
r, err := ies.client.Get(ctx, iamConfigPrefix+"/", etcd.WithPrefix(), etcd.WithKeysOnly())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
paths := make([]string, 0, len(r.Kvs))
|
|
for _, kv := range r.Kvs {
|
|
paths = append(paths, string(kv.Key))
|
|
}
|
|
return paths, nil
|
|
}
|
|
|
|
func iamDeletionItem(path string, r iamRevision) (item madmin.SRIAMItem, ok bool) {
|
|
if (strings.HasPrefix(path, iamConfigUsersPrefix) || strings.HasPrefix(path, iamConfigGroupsPrefix)) && !r.RevokedBefore.IsZero() {
|
|
// Recreating a parent does not cancel its older revocation of derived
|
|
// credentials. Replay this boundary even after the parent is live again.
|
|
r.Deleted = true
|
|
r.UpdatedAt, r.UpdateDate = r.RevokedBefore, time.Time{}
|
|
}
|
|
if !r.Deleted {
|
|
return item, false
|
|
}
|
|
item.UpdatedAt = r.timestamp()
|
|
switch {
|
|
case strings.HasPrefix(path, iamConfigUsersPrefix):
|
|
name := strings.TrimSuffix(strings.TrimPrefix(path, iamConfigUsersPrefix), "/"+iamIdentityFile)
|
|
item.Type = madmin.SRIAMItemIAMUser
|
|
item.IAMUser = &madmin.SRIAMUser{AccessKey: name, IsDeleteReq: true}
|
|
case strings.HasPrefix(path, iamConfigServiceAccountsPrefix):
|
|
name := strings.TrimSuffix(strings.TrimPrefix(path, iamConfigServiceAccountsPrefix), "/"+iamIdentityFile)
|
|
if name == siteReplicatorSvcAcc || r.Credentials.ParentUser == globalActiveCred.AccessKey {
|
|
return item, false
|
|
}
|
|
item.Type = madmin.SRIAMItemSvcAcc
|
|
item.SvcAccChange = &madmin.SRSvcAccChange{Delete: &madmin.SRSvcAccDelete{AccessKey: name}}
|
|
case strings.HasPrefix(path, iamConfigGroupsPrefix):
|
|
name := strings.TrimSuffix(strings.TrimPrefix(path, iamConfigGroupsPrefix), "/"+iamGroupMembersFile)
|
|
item.Type = madmin.SRIAMItemGroupInfo
|
|
item.GroupInfo = &madmin.SRGroupInfo{UpdateReq: madmin.GroupAddRemove{Group: name, IsRemove: true}}
|
|
case strings.HasPrefix(path, iamConfigPoliciesPrefix):
|
|
item.Type = madmin.SRIAMItemPolicy
|
|
item.Name = strings.TrimSuffix(strings.TrimPrefix(path, iamConfigPoliciesPrefix), "/"+iamPolicyFile)
|
|
case strings.HasPrefix(path, iamConfigPolicyDBPrefix):
|
|
prefix, name, found := strings.Cut(strings.TrimPrefix(path, iamConfigPolicyDBPrefix), "/")
|
|
if !found {
|
|
return item, false
|
|
}
|
|
typ := regUser
|
|
switch prefix {
|
|
case "sts-users":
|
|
typ = stsUser
|
|
case "service-accounts":
|
|
typ = svcUser
|
|
}
|
|
item.Type = madmin.SRIAMItemPolicyMapping
|
|
item.PolicyMapping = &madmin.SRPolicyMapping{UserOrGroup: strings.TrimSuffix(name, ".json"), UserType: int(typ), IsGroup: prefix == "groups"}
|
|
default:
|
|
// Expired STS credentials are not replayed. Parent revocations and
|
|
// their retained timestamp reject delayed copies of derived tokens.
|
|
return item, false
|
|
}
|
|
return item, true
|
|
}
|
|
|
|
func iamDeletionPath(item madmin.SRIAMItem) string {
|
|
switch item.Type {
|
|
case madmin.SRIAMItemIAMUser:
|
|
if item.IAMUser != nil && item.IAMUser.IsDeleteReq {
|
|
return getUserIdentityPath(item.IAMUser.AccessKey, regUser)
|
|
}
|
|
case madmin.SRIAMItemSvcAcc:
|
|
if item.SvcAccChange != nil && item.SvcAccChange.Delete != nil {
|
|
return getUserIdentityPath(item.SvcAccChange.Delete.AccessKey, svcUser)
|
|
}
|
|
case madmin.SRIAMItemGroupInfo:
|
|
if item.GroupInfo != nil && item.GroupInfo.UpdateReq.IsRemove && len(item.GroupInfo.UpdateReq.Members) == 0 {
|
|
return getGroupInfoPath(item.GroupInfo.UpdateReq.Group)
|
|
}
|
|
case madmin.SRIAMItemPolicy:
|
|
if len(item.Policy) == 0 {
|
|
return getPolicyDocPath(item.Name)
|
|
}
|
|
case madmin.SRIAMItemPolicyMapping:
|
|
if p := item.PolicyMapping; p != nil && p.Policy == "" {
|
|
return getMappedPolicyPath(p.UserOrGroup, IAMUserType(p.UserType), p.IsGroup)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (c *SiteReplicationSys) healIAMDeletions(ctx context.Context) (err error) {
|
|
started := time.Now()
|
|
defer func() {
|
|
c.iamRevisionMetrics.healDurationMillis.Store(time.Since(started).Milliseconds())
|
|
if err != nil {
|
|
c.iamRevisionMetrics.healFailures.Add(1)
|
|
} else {
|
|
c.iamRevisionMetrics.healLastSuccess.Store(time.Now().Unix())
|
|
}
|
|
}()
|
|
c.iamHealMu.Lock()
|
|
defer c.iamHealMu.Unlock()
|
|
c.RLock()
|
|
defer c.RUnlock()
|
|
if !c.enabled {
|
|
return nil
|
|
}
|
|
snapshot := globalIAMSys.store.revisionIndex().snapshot()
|
|
paths := make([]string, 0, len(snapshot))
|
|
for path := range snapshot {
|
|
paths = append(paths, path)
|
|
}
|
|
sort.Strings(paths)
|
|
byType := make(map[string][]iamReplicationItem)
|
|
for _, path := range paths {
|
|
r := snapshot[path]
|
|
item, ok := iamDeletionItem(path, r)
|
|
if !ok {
|
|
continue
|
|
}
|
|
out := iamReplicationItem{SRIAMItem: item}
|
|
if item.Type == madmin.SRIAMItemIAMUser && !r.Deleted {
|
|
out.Type, out.IAMUser = iamUserBoundaryType, nil
|
|
out.UserRevocation = &iamUserBoundary{User: item.IAMUser.AccessKey, Before: r.RevokedBefore}
|
|
}
|
|
if item.Type == madmin.SRIAMItemGroupInfo && !r.Deleted {
|
|
out.Type, out.GroupInfo = iamGroupBoundaryType, nil
|
|
out.GroupRevocation = &iamGroupBoundary{Group: item.GroupInfo.UpdateReq.Group, Before: r.RevokedBefore}
|
|
}
|
|
byType[item.Type] = append(byType[item.Type], out)
|
|
}
|
|
var items []iamReplicationItem
|
|
for _, typ := range []string{madmin.SRIAMItemPolicyMapping, madmin.SRIAMItemIAMUser, madmin.SRIAMItemSvcAcc, madmin.SRIAMItemGroupInfo, madmin.SRIAMItemPolicy} {
|
|
items = append(items, byType[typ]...)
|
|
}
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
if c.iamRevisionProgress == nil {
|
|
c.iamRevisionProgress = make(map[string]iamRevisionProgress)
|
|
}
|
|
for id := range c.iamRevisionProgress {
|
|
if _, present := c.state.Peers[id]; !present {
|
|
delete(c.iamRevisionProgress, id)
|
|
}
|
|
}
|
|
var progressMu sync.Mutex
|
|
cerr := c.concDo(nil, func(id string, p madmin.PeerInfo) error {
|
|
// Bound each pass, but retain acknowledgements independently of the
|
|
// pass deadline or unrelated changes at either site.
|
|
peerCtx, cancel := context.WithTimeout(ctx, defaultContextTimeout)
|
|
defer cancel()
|
|
client, err := c.getAdminClient(peerCtx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
remote, err := executeIAMRevisionRequest(peerCtx, client, http.MethodGet, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
progressMu.Lock()
|
|
progress := c.iamRevisionProgress[id]
|
|
progressMu.Unlock()
|
|
progress.observePeer(remote)
|
|
defer func() {
|
|
progressMu.Lock()
|
|
c.iamRevisionProgress[id] = progress
|
|
progressMu.Unlock()
|
|
}()
|
|
var pending []iamReplicationItem
|
|
for _, item := range items {
|
|
path, version := iamReplicationMarker(item)
|
|
if progress.Acknowledged[path] != version {
|
|
pending = append(pending, item)
|
|
}
|
|
}
|
|
// Acknowledgements are only a replay optimization, never GC proof.
|
|
for path := range progress.Acknowledged {
|
|
if _, retained := snapshot[path]; !retained {
|
|
delete(progress.Acknowledged, path)
|
|
}
|
|
}
|
|
var failures []error
|
|
for next := 0; next < len(pending); {
|
|
end := min(next+maxIAMRevisionBatch, len(pending))
|
|
batch := pending[next:end]
|
|
remote, err = executeIAMRevisionRequest(peerCtx, client, http.MethodPut, &iamRevisionBatch{Version: iamRevisionProtocol, Items: batch})
|
|
if err != nil {
|
|
var batchErr *iamRevisionBatchError
|
|
if !errors.As(err, &batchErr) {
|
|
return errors.Join(append(failures, err)...)
|
|
}
|
|
failures = append(failures, err)
|
|
} else {
|
|
progress.observePeer(remote)
|
|
for _, item := range batch {
|
|
path, version := iamReplicationMarker(item)
|
|
progress.Acknowledged[path] = version
|
|
}
|
|
}
|
|
next = end
|
|
}
|
|
return errors.Join(failures...)
|
|
}, "IAM revision convergence")
|
|
return errors.Unwrap(cerr)
|
|
}
|
|
|
|
func iamReplicationMarker(item iamReplicationItem) (path, version string) {
|
|
path = iamDeletionPath(item.SRIAMItem)
|
|
if item.UserRevocation != nil {
|
|
path = getUserIdentityPath(item.UserRevocation.User, regUser)
|
|
}
|
|
if item.GroupRevocation != nil {
|
|
path = getGroupInfoPath(item.GroupRevocation.Group)
|
|
}
|
|
return path, item.Type + ":" + item.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
|
|
func (store *IAMStoreSys) savePolicyDoc(ctx context.Context, policyName string, p *PolicyDoc) error {
|
|
return store.saveIAMRevision(ctx, getPolicyDocPath(policyName), p)
|
|
}
|
|
|
|
func (store *IAMStoreSys) saveMappedPolicy(ctx context.Context, name string, userType IAMUserType, isGroup bool, mp *MappedPolicy, opts ...options) error {
|
|
return store.saveIAMRevision(ctx, getMappedPolicyPath(name, userType, isGroup), mp, opts...)
|
|
}
|
|
|
|
func (store *IAMStoreSys) saveUserIdentity(ctx context.Context, name string, userType IAMUserType, u *UserIdentity, opts ...options) error {
|
|
return store.saveIAMRevision(ctx, getUserIdentityPath(name, userType), u, opts...)
|
|
}
|
|
|
|
func (store *IAMStoreSys) saveGroupInfo(ctx context.Context, name string, gi *GroupInfo) error {
|
|
return store.saveIAMRevision(ctx, getGroupInfoPath(name), gi)
|
|
}
|
|
|
|
func (store *IAMStoreSys) deletePolicyDoc(ctx context.Context, name string) error {
|
|
return store.saveIAMRevision(ctx, getPolicyDocPath(name), &PolicyDoc{Version: 1, Deleted: true})
|
|
}
|
|
|
|
func (store *IAMStoreSys) deleteMappedPolicy(ctx context.Context, name string, userType IAMUserType, isGroup bool) error {
|
|
return store.saveIAMRevision(ctx, getMappedPolicyPath(name, userType, isGroup), &MappedPolicy{Version: 1, Deleted: true})
|
|
}
|
|
|
|
func (store *IAMStoreSys) deleteUserIdentity(ctx context.Context, name string, userType IAMUserType) error {
|
|
return store.saveIAMRevision(ctx, getUserIdentityPath(name, userType), &UserIdentity{Version: 1, Deleted: true})
|
|
}
|
|
|
|
// Called under the identity's distributed revision lock, after verifying that
|
|
// its immutable STS token (or early-revocation retention) has expired. Only the
|
|
// old token-key mapping is removed; the reusable parent mapping is unaffected.
|
|
func expireIAMSTSConfig(ctx context.Context, store IAMStorageAPI, path string) error {
|
|
key := strings.TrimSuffix(strings.TrimPrefix(path, iamConfigSTSPrefix), "/"+iamIdentityFile)
|
|
if err := store.deleteIAMConfig(ctx, getMappedPolicyPath(key, stsUser, false)); err != nil && !errors.Is(err, errConfigNotFound) {
|
|
return err
|
|
}
|
|
return store.deleteIAMConfig(ctx, path)
|
|
}
|
|
|
|
type (
|
|
iamExpirationCleanupKey struct{}
|
|
iamExpirationCleanupState struct{ failed atomic.Bool }
|
|
)
|
|
|
|
func withIAMExpirationCleanup(ctx context.Context) context.Context {
|
|
if _, ok := ctx.Value(iamExpirationCleanupKey{}).(*iamExpirationCleanupState); ok {
|
|
return ctx
|
|
}
|
|
return context.WithValue(ctx, iamExpirationCleanupKey{}, &iamExpirationCleanupState{})
|
|
}
|
|
|
|
func bestEffortIAMExpiration(ctx context.Context, store IAMStorageAPI, path string) {
|
|
state, _ := ctx.Value(iamExpirationCleanupKey{}).(*iamExpirationCleanupState)
|
|
if ctx.Err() != nil || (state != nil && state.failed.Load()) {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, time.Second)
|
|
defer cancel()
|
|
// Failure leaves the expired record and its existing version intact.
|
|
// Stop optional reclamation for this load, while still loading healthy
|
|
// users. Healthy cleanup has no per-scan quota that could build a backlog.
|
|
if err := saveIAMRevision(ctx, store, path, &iamExpireIdentity{}); err != nil {
|
|
if state != nil {
|
|
state.failed.Store(true)
|
|
}
|
|
iamLogIf(ctx, err)
|
|
}
|
|
}
|