fix(auth): scope unread tag candidates to effective policies

Select cached user, group, role and session policies without lazy IAM reads. Keep alternative tag values and Deny keys when combining candidates, and bound intermediate combinations as well as authorization attempts.

Cover unrelated cached policies, intersecting tag conditions, temporary credentials, root-derived credentials and cache-only lookup regressions for issue #293.

Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
Feng Ruohang
2026-11-10 23:22:57 +08:00
parent c6949a38c2
commit 65acda0114
2 changed files with 325 additions and 111 deletions
+186 -106
View File
@@ -27,22 +27,24 @@ import (
"github.com/minio/minio-go/v7/pkg/set" "github.com/minio/minio-go/v7/pkg/set"
"github.com/minio/minio-go/v7/pkg/tags" "github.com/minio/minio-go/v7/pkg/tags"
"github.com/minio/minio/internal/arn"
"github.com/minio/minio/internal/auth" "github.com/minio/minio/internal/auth"
"github.com/minio/minio/internal/logger" "github.com/minio/minio/internal/logger"
"github.com/pgsty/silo-pkg/v3/policy" "github.com/pgsty/silo-pkg/v3/policy"
"github.com/pgsty/silo-pkg/v3/policy/condition" "github.com/pgsty/silo-pkg/v3/policy/condition"
"github.com/puzpuzpuz/xsync/v3"
) )
// authorizeRequestWithUnreadTags authorizes action on an object whose stored // authorizeRequestWithUnreadTags decides whether to disclose readErr for an
// tags could not be read because reading the object failed with readErr. // object whose stored tags could not be read. It must not grant object access.
// //
// Without the tags, an Allow statement that tests s3:ExistingObjectTag cannot // Without the tags, an Allow statement that tests s3:ExistingObjectTag cannot
// match, so a caller allowed only by such a statement would get AccessDenied, // match, so a caller allowed only by such a statement would get AccessDenied,
// which clients do not retry, for a transient server error such as lost read // which clients do not retry, for a transient server error such as lost read
// quorum. For a server error the request is therefore also authorized as if // quorum. For a server error, try a bounded set of tags from the caller's
// the object carried tags that the policies ask for: a caller that some tags // policies and return the storage error if normal authorization accepts one.
// let through gets the error, a caller that no tags let through still gets // If no candidate passes, keep AccessDenied. This is not an exhaustive policy
// AccessDenied. For any other error, NoSuchKey among them, the tags stay empty. // solver. For any other error, NoSuchKey among them, the tags stay empty.
func authorizeRequestWithUnreadTags(ctx context.Context, r *http.Request, action policy.Action, readErr error) APIErrorCode { func authorizeRequestWithUnreadTags(ctx context.Context, r *http.Request, action policy.Action, readErr error) APIErrorCode {
s3Err := authorizeRequest(ctx, r, action) s3Err := authorizeRequest(ctx, r, action)
if s3Err == ErrNone || toAPIError(ctx, readErr).HTTPStatusCode < http.StatusInternalServerError { if s3Err == ErrNone || toAPIError(ctx, readErr).HTTPStatusCode < http.StatusInternalServerError {
@@ -76,17 +78,16 @@ func policyExistingObjectTags(ctx context.Context, r *http.Request, action polic
bucket, object := reqInfo.BucketName, reqInfo.ObjectName bucket, object := reqInfo.BucketName, reqInfo.ObjectName
var ( var (
allows [][]map[string]string // per policy set, the tags its Allow statements ask for allows [][]map[string]string // per policy set, the tags its Allow statements ask for
denies []map[string]string // tags that keep a Deny statement from matching denies [][]map[string]string // per Deny statement, alternative tags that keep it from matching
) )
// add files the tags of a statement that applies to action on the // add collects the tags of a statement that applies to action on the
// object, whatever its conditions. // object, whatever its conditions.
add := func(effect policy.Effect, conditions condition.Functions, values map[string][]string, fromSet *[]map[string]string) { add := func(effect policy.Effect, conditions condition.Functions, values map[string][]string, fromSet *[]map[string]string) {
if tagMap, ok := existingObjectTagsFor(effect, conditions, values); ok { candidates := existingObjectTagsCandidatesFor(effect, conditions, values)
if effect == policy.Allow { if effect == policy.Allow {
*fromSet = append(*fromSet, tagMap) *fromSet = append(*fromSet, candidates...)
} else { } else if len(candidates) > 0 {
denies = append(denies, tagMap) denies = append(denies, candidates)
}
} }
} }
switch { switch {
@@ -107,17 +108,7 @@ func policyExistingObjectTags(ctx context.Context, r *http.Request, action polic
allows = append(allows, fromSet) allows = append(allows, fromSet)
case newGlobalAuthZPluginFn() == nil && globalIAMSys.Initialized(): case newGlobalAuthZPluginFn() == nil && globalIAMSys.Initialized():
values := getConditionValues(r, "", reqInfo.Cred) values := getConditionValues(r, "", reqInfo.Cred)
// From the cache only: reading the drives may be what just failed. policySets := globalIAMSys.unreadTagPolicySets(reqInfo.Cred)
docs, _ := globalIAMSys.store.listPolicyDocs(ctx, "")
policySets := [][]policy.Policy{nil}
for _, name := range slices.Sorted(maps.Keys(docs)) {
policySets[0] = append(policySets[0], docs[name].Policy)
}
if sp, ok := reqInfo.Cred.Claims[sessionPolicyNameExtracted].(string); ok {
if p, err := policy.ParseConfig(strings.NewReader(sp)); err == nil {
policySets = append(policySets, []policy.Policy{*p})
}
}
for _, policies := range policySets { for _, policies := range policySets {
var fromSet []map[string]string var fromSet []map[string]string
for _, p := range policies { for _, p := range policies {
@@ -132,72 +123,131 @@ func policyExistingObjectTags(ctx context.Context, r *http.Request, action polic
allows = append(allows, fromSet) allows = append(allows, fromSet)
} }
} }
return combineExistingObjectTags(allows, denies) return combineExistingObjectTags(append(allows, denies...))
} }
// combineExistingObjectTags merges the tags of one Allow statement of each // unreadTagMappings reuses the normal user/group mapping lookup without lazy
// policy set, or of none for a set that may allow without tags, and tries each // storage reads. Missing cache entries contribute no candidates; the regular
// result also with the tags that keep the Deny statements from matching. The // authorization still decides whether any candidate is allowed.
// first tags win a conflict; the authorization of the result settles it. type unreadTagMappings struct {
func combineExistingObjectTags(allows [][]map[string]string, denies []map[string]string) []string { *IAMStoreSys
merge := func(parts ...map[string]string) map[string]string { }
merged := make(map[string]string)
for _, part := range parts { func (unreadTagMappings) loadMappedPolicy(context.Context, string, IAMUserType, bool, *xsync.MapOf[string, MappedPolicy]) error {
for k, v := range part { return errNoSuchPolicy
if _, ok := merged[k]; !ok { }
merged[k] = v
func (unreadTagMappings) loadGroup(context.Context, string, map[string]GroupInfo) error {
return errNoSuchGroup
}
// unreadTagPolicySets selects only this credential's cached policies. Base
// and session policies remain separate because both must allow the request.
// This only supplies candidates, so identity, claims and policy validity are
// left to the normal authorization on each candidate.
func (sys *IAMSys) unreadTagPolicySets(cred auth.Credentials) [][]policy.Policy {
derived := cred.IsTemp() || cred.IsServiceAccount()
name := cred.AccessKey
if derived {
name = cred.ParentUser
}
cache := sys.store.rlock()
defer sys.store.runlock()
var names []string
roleARN := (policy.Args{Claims: cred.Claims}).GetRoleArn()
switch {
case name == globalActiveCred.AccessKey:
case derived && roleARN != "":
parsed, err := arn.Parse(roleARN)
if err != nil {
return nil
}
names = newMappedPolicy(sys.rolesMap[parsed]).toSlice()
default:
loader := unreadTagMappings{sys.store}
var err error
names, _, err = cache.policyDBGet(loader, name, false, false)
if err != nil {
return nil
}
groups, err := cache.policyDBGetGroups(loader, len(names) > 0, cred.Groups...)
if err != nil {
return nil
}
names = append(names, groups...)
if derived && len(names) == 0 {
claims, _ := policy.GetPoliciesFromClaims(cred.Claims, iamPolicyClaimNameOpenID())
names = claims.ToSlice()
}
}
sets := [][]policy.Policy{nil}
slices.Sort(names)
for _, name := range slices.Compact(names) {
if doc, ok := cache.iamPolicyDocsMap[name]; ok {
sets[0] = append(sets[0], doc.Policy)
}
}
if derived && (cred.IsTemp() || cred.Claims[iamPolicyClaimNameSA()] != inheritedPolicyType) {
if sp, ok := cred.Claims[sessionPolicyNameExtracted].(string); ok {
if p, err := policy.ParseConfig(strings.NewReader(sp)); err == nil {
sets = append(sets, []policy.Policy{*p})
}
}
}
return sets
}
// combineExistingObjectTags combines alternatives from each base/session
// Allow set and each tag-dependent Deny. A set may also contribute no tags:
// another statement or another set may already supply what it needs. First
// values win conflicts; the normal authorization decides whether they work.
func combineExistingObjectTags(sets [][]map[string]string) []string {
combos := []map[string]string{{}}
for _, fromSet := range sets {
fromSet = append(uniqueExistingObjectTagMaps(fromSet), nil)
var next []map[string]string
seen := set.NewStringSet()
combinations:
for _, combo := range combos {
for _, addition := range fromSet {
merged := maps.Clone(combo)
for k, v := range addition {
if _, ok := merged[k]; !ok {
merged[k] = v
}
}
t, err := tags.MapToObjectTags(merged)
if err != nil || seen.Contains(t.String()) {
continue
}
seen.Add(t.String())
next = append(next, merged)
if len(next) == maxExistingTagCandidates {
break combinations
} }
} }
} }
return merged combos = next
} }
combos := [][]map[string]string{nil}
for _, fromSet := range allows {
// Many statements ask for the same tags.
seen := set.NewStringSet()
fromSet = slices.DeleteFunc(slices.Clone(fromSet), func(tagMap map[string]string) bool {
t, err := tags.MapToObjectTags(tagMap)
if err != nil || seen.Contains(t.String()) {
return true
}
seen.Add(t.String())
return false
})
next := make([][]map[string]string, 0, len(combos)*(len(fromSet)+1))
for _, combo := range combos {
next = append(next, combo)
for _, tagMap := range fromSet {
next = append(next, append(slices.Clip(combo), tagMap))
}
}
combos = next[:min(len(next), maxExistingTagCandidates)]
}
deny := merge(denies...)
found := set.NewStringSet()
var candidates []string var candidates []string
for _, combo := range combos { for _, combo := range combos {
for _, tagMap := range []map[string]string{merge(combo...), merge(append(slices.Clip(combo), deny)...)} { if len(combo) != 0 { // empty tags were already authorized
t, err := tags.MapToObjectTags(tagMap) t, _ := tags.MapToObjectTags(combo)
if len(tagMap) == 0 || err != nil || found.Contains(t.String()) { candidates = append(candidates, t.String())
continue
}
found.Add(t.String())
if candidates = append(candidates, t.String()); len(candidates) == maxExistingTagCandidates {
return candidates
}
} }
} }
return candidates return candidates
} }
// existingObjectTagsFor returns tags under which the s3:ExistingObjectTag tests // existingObjectTagsCandidatesFor returns tag sets under which the
// of conditions all pass, for an Allow statement, or one of them fails, for a // s3:ExistingObjectTag tests of conditions all pass, for an Allow statement,
// Deny statement; a tag left out is absent. It returns false if the conditions // or one of them fails, for a Deny statement; a tag left out is absent. For an
// test no tag, or if no such tags were found among those tried for each tag: // Allow, values for different tag keys are combined, while a Deny needs only
// none, the values the tests name, with policy variables taken from values and // one failing key. It tries the values named in the conditions, with policy
// wildcards filled, and the fillers "" and "_". The policy engine itself // variables resolved, wildcard fillers, and absence, then lets the policy
// evaluates the tests for each of them. // engine decide whether each candidate really satisfies the conditions.
func existingObjectTagsFor(effect policy.Effect, conditions condition.Functions, values map[string][]string) (map[string]string, bool) { func existingObjectTagsCandidatesFor(effect policy.Effect, conditions condition.Functions, values map[string][]string) []map[string]string {
// Each function tests one key, so the tests of one tag pass or fail // Each function tests one key, so the tests of one tag pass or fail
// whatever the other tags are. // whatever the other tags are.
byKey := make(map[string]condition.Functions) byKey := make(map[string]condition.Functions)
@@ -209,47 +259,74 @@ func existingObjectTagsFor(effect policy.Effect, conditions condition.Functions,
} }
} }
if len(byKey) == 0 { if len(byKey) == 0 {
return nil, false return nil
}
tagMap := make(map[string]string)
for _, key := range slices.Sorted(maps.Keys(byKey)) {
tagKey := strings.TrimPrefix(key, condition.ExistingObjectTag.Name()+"/")
value, present, found := existingObjectTagValue(key, tagKey, byKey[key], values, effect == policy.Allow)
switch {
case effect == policy.Deny && found:
if present {
tagMap[tagKey] = value
}
return tagMap, true
case effect == policy.Allow && !found:
return nil, false
case present:
tagMap[tagKey] = value
}
} }
keys := slices.Sorted(maps.Keys(byKey))
if effect == policy.Deny { if effect == policy.Deny {
return nil, false var candidates []map[string]string
for _, key := range keys {
candidates = append(candidates, existingObjectTagValues(key, strings.TrimPrefix(key, condition.ExistingObjectTag.Name()+"/"), byKey[key], values, false)...)
}
return uniqueExistingObjectTagMaps(candidates)
} }
return tagMap, true
candidates := []map[string]string{{}}
for _, key := range keys {
keyCandidates := existingObjectTagValues(key, strings.TrimPrefix(key, condition.ExistingObjectTag.Name()+"/"), byKey[key], values, true)
if len(keyCandidates) == 0 {
return nil
}
next := make([]map[string]string, 0, min(maxExistingTagCandidates, len(candidates)*len(keyCandidates)))
for _, base := range candidates {
for _, keyCandidate := range keyCandidates {
merged := maps.Clone(base)
maps.Copy(merged, keyCandidate)
next = append(next, merged)
if len(next) == maxExistingTagCandidates {
break
}
}
if len(next) == maxExistingTagCandidates {
break
}
}
candidates = next
}
return uniqueExistingObjectTagMaps(candidates)
} }
// existingObjectTagValue looks for a value of tag tagKey, or its absence, func uniqueExistingObjectTagMaps(candidates []map[string]string) []map[string]string {
// under which tests, the functions that test the condition key key, evaluate seen := set.NewStringSet()
// to want. unique := make([]map[string]string, 0, len(candidates))
func existingObjectTagValue(key, tagKey string, tests condition.Functions, values map[string][]string, want bool) (value string, present, found bool) { for _, candidate := range candidates {
t, err := tags.MapToObjectTags(candidate)
if err != nil || seen.Contains(t.String()) {
continue
}
seen.Add(t.String())
unique = append(unique, candidate)
}
return unique
}
// existingObjectTagValues looks for values of tag tagKey, or its absence,
// under which the functions that test the condition key key evaluate to want.
func existingObjectTagValues(key, tagKey string, tests condition.Functions, values map[string][]string, want bool) []map[string]string {
defer delete(values, key) defer delete(values, key)
delete(values, key) delete(values, key)
var candidates []map[string]string
if tests.Evaluate(values) == want { if tests.Evaluate(values) == want {
return "", false, true candidates = append(candidates, map[string]string{})
} }
// The functions keep their values unexported; JSON exposes them. // The functions keep their values unexported; JSON exposes them.
buf, err := json.Marshal(tests) buf, err := json.Marshal(tests)
if err != nil { if err != nil {
return "", false, false return candidates
} }
var byName map[string]map[string]condition.ValueSet var byName map[string]map[string]condition.ValueSet
if err = json.Unmarshal(buf, &byName); err != nil { if err = json.Unmarshal(buf, &byName); err != nil {
return "", false, false return candidates
} }
var tried []string var tried []string
for name, byKey := range byName { for name, byKey := range byName {
@@ -270,10 +347,13 @@ func existingObjectTagValue(key, tagKey string, tests condition.Functions, value
} }
values[key] = []string{v} values[key] = []string{v}
if tests.Evaluate(values) == want { if tests.Evaluate(values) == want {
return v, true, true candidates = append(candidates, map[string]string{tagKey: v})
if len(candidates) == maxExistingTagCandidates {
break
}
} }
} }
return "", false, false return uniqueExistingObjectTagMaps(candidates)
} }
// substituteConditionVariables resolves the policy variables in v from the // substituteConditionVariables resolves the policy variables in v from the
+139 -5
View File
@@ -20,6 +20,7 @@ package cmd
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
@@ -29,8 +30,10 @@ import (
"net/url" "net/url"
"strings" "strings"
"testing" "testing"
"time"
"github.com/minio/minio-go/v7/pkg/tags" "github.com/minio/minio-go/v7/pkg/tags"
"github.com/minio/minio/internal/arn"
"github.com/minio/minio/internal/auth" "github.com/minio/minio/internal/auth"
xhttp "github.com/minio/minio/internal/http" xhttp "github.com/minio/minio/internal/http"
"github.com/pgsty/silo-pkg/v3/policy" "github.com/pgsty/silo-pkg/v3/policy"
@@ -108,6 +111,45 @@ func TestExistingObjectTagAuthReadQuorumLost(t *testing.T) {
} }
return cred return cred
} }
newTempUser := func(parent auth.Credentials, statements string) auth.Credentials {
session := `{"Version": "2012-10-17", "Statement": [` + statements + `]}`
secret, err := getTokenSigningKey()
if err != nil {
t.Fatal(err)
}
cred, err := auth.GetNewCredentialsWithMetadata(map[string]any{
parentClaim: parent.AccessKey, "exp": UTCNow().Add(time.Hour).Unix(),
policy.SessionPolicyName: base64.StdEncoding.EncodeToString([]byte(session)),
}, secret)
if err != nil {
t.Fatal(err)
}
cred.ParentUser = parent.AccessKey
if _, err := globalIAMSys.SetTempUser(ctx, cred.AccessKey, cred, ""); err != nil {
t.Fatal(err)
}
return cred
}
// An unrelated cached policy must not consume the candidate limit. The
// target user's policy is created below and sorts after this policy.
fillerStatements := make([]string, 0, maxExistingTagCandidates)
for i := range maxExistingTagCandidates {
fillerStatements = append(fillerStatements, statement("Allow", "*",
fmt.Sprintf(`{"StringEquals": {"s3:ExistingObjectTag/fill%02d": "v"}}`, i)))
}
fillerPolicy, err := policy.ParseConfig(strings.NewReader(`{"Version": "2012-10-17", "Statement": [` + strings.Join(fillerStatements, ",") + `]}`))
if err != nil {
t.Fatal(err)
}
fillerPolicyName := "000-unread-tags-filler-" + mustGetUUID()
if _, err = globalIAMSys.SetPolicy(ctx, fillerPolicyName, *fillerPolicy); err != nil {
t.Fatal(err)
}
defer func() {
if err := globalIAMSys.DeletePolicy(ctx, fillerPolicyName, false); err != nil {
t.Logf("delete filler policy: %v", err)
}
}()
type user struct { type user struct {
name string name string
@@ -146,6 +188,35 @@ func TestExistingObjectTagAuthReadQuorumLost(t *testing.T) {
// asks for j=w: only an object carrying both lets it through. // asks for j=w: only an object carrying both lets it through.
allowedByTags("serviceAccount", newServiceAccount(newUser(tagStatement("Allow", "*", "v")), allowedByTags("serviceAccount", newServiceAccount(newUser(tagStatement("Allow", "*", "v")),
statement("Allow", "*", `{"StringEquals": {"s3:ExistingObjectTag/j": "w"}}`))), statement("Allow", "*", `{"StringEquals": {"s3:ExistingObjectTag/j": "w"}}`))),
allowedByTags("temporary", newTempUser(newUser(tagStatement("Allow", "*", "v")),
statement("Allow", "*", `{"StringEquals": {"s3:ExistingObjectTag/j": "w"}}`))),
allowedByTags("rootTemporary", newTempUser(globalActiveCred, tagStatement("Allow", "*", "v"))),
allowedByTags("rootServiceAccount", newServiceAccount(globalActiveCred, tagStatement("Allow", "*", "v"))),
// The effective policy must be selected before applying the candidate
// limit; the unrelated cached policy above has 64 other tag statements.
allowedByTags("unrelatedPolicies", newUser(statement("Allow", "*",
`{"StringEquals": {"s3:ExistingObjectTag/target": "ok"}}`))),
// An alternative allowed value must survive a Deny that excludes the
// first value. With unread tags, k=v is the only valid candidate.
allowedByTags("alternativeAllowedValue", newUser(
statement("Allow", "*", `{"StringEquals": {"s3:ExistingObjectTag/k": ["a", "v"]}}`),
tagStatement("Deny", "*", "a"))),
allowedByTags("alternativeValuesAcrossKeys", newUser(
statement("Allow", "*", `{"StringEquals": {"s3:ExistingObjectTag/k": ["a", "v"], "s3:ExistingObjectTag/j": ["b", "w"]}}`),
tagStatement("Deny", "*", "a"),
statement("Deny", "*", `{"StringEquals": {"s3:ExistingObjectTag/j": "b"}}`))),
// Both Deny statements must be avoided. The first candidate (k="")
// avoids the Null Deny but still matches the second Deny.
allowedByTags("multipleDenies", newUser(statement("Allow", "*", ""),
statement("Deny", "*", `{"Null": {"s3:ExistingObjectTag/k": "true"}}`),
tagStatement("Deny", "*", ""))),
// The Deny can be avoided by k=v OR j=w. Only j=w is compatible
// with this Allow, so stopping at the first Deny key loses it.
{
"alternativeDenyKey", newUser(tagStatement("Allow", "*", "a"),
statement("Deny", "*", `{"StringNotEquals": {"s3:ExistingObjectTag/k": "v", "s3:ExistingObjectTag/j": "w"}}`)),
http.StatusForbidden, http.StatusServiceUnavailable,
},
// Allowed only for k=w: the decision still depends on the tags. // Allowed only for k=w: the decision still depends on the tags.
{"otherValue", newUser(tagStatement("Allow", "*", "w")), http.StatusForbidden, http.StatusServiceUnavailable}, {"otherValue", newUser(tagStatement("Allow", "*", "w")), http.StatusForbidden, http.StatusServiceUnavailable},
@@ -170,7 +241,7 @@ func TestExistingObjectTagAuthReadQuorumLost(t *testing.T) {
const object, missing = "tagged", "missing" const object, missing = "tagged", "missing"
if _, err := z.PutObject(ctx, bucket, object, mustGetPutObjReader(t, bytes.NewReader([]byte("data")), 4, "", ""), if _, err := z.PutObject(ctx, bucket, object, mustGetPutObjReader(t, bytes.NewReader([]byte("data")), 4, "", ""),
ObjectOptions{UserDefined: map[string]string{xhttp.AmzObjectTagging: "k=v&j=w&np=1&ne=1&fa=1&du=1&owner=" + owner.AccessKey}}); err != nil { ObjectOptions{UserDefined: map[string]string{xhttp.AmzObjectTagging: "k=v&j=w&np=1&ne=1&fa=1&du=1&target=ok&owner=" + owner.AccessKey}}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Anonymous reads are allowed by the bucket policy while the object carries k=v. // Anonymous reads are allowed by the bucket policy while the object carries k=v.
@@ -207,7 +278,14 @@ func TestExistingObjectTagAuthReadQuorumLost(t *testing.T) {
if req.body != nil { if req.body != nil {
body = bytes.NewReader(req.body) body = bytes.NewReader(req.body)
} }
signed, err := newTestSignedRequestV4(req.method, req.target, int64(len(req.body)), body, creds.AccessKey, creds.SecretKey, req.header) header := make(map[string]string)
for k, v := range req.header {
header[k] = v
}
if creds.IsTemp() {
header[xhttp.AmzSecurityToken] = creds.SessionToken
}
signed, err := newTestSignedRequestV4(req.method, req.target, int64(len(req.body)), body, creds.AccessKey, creds.SecretKey, header)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -289,10 +367,11 @@ func TestExistingObjectTagsFor(t *testing.T) {
t.Fatalf("%s: %v", tc.conditions, err) t.Fatalf("%s: %v", tc.conditions, err)
} }
} }
tagMap, ok := existingObjectTagsFor(tc.effect, conditions, values) candidates := existingObjectTagsCandidatesFor(tc.effect, conditions, values)
ok := len(candidates) > 0
got := "" got := ""
if len(tagMap) > 0 { if ok {
encoded, err := tags.MapToObjectTags(tagMap) encoded, err := tags.MapToObjectTags(candidates[0])
if err != nil { if err != nil {
t.Fatalf("%s: %v", tc.conditions, err) t.Fatalf("%s: %v", tc.conditions, err)
} }
@@ -303,3 +382,58 @@ func TestExistingObjectTagsFor(t *testing.T) {
} }
} }
} }
// Group-only and external identities need mapping lookups that normally
// fall back to storage. Candidate collection must stay within the cache even
// for those identities, while still finding their group/claim/role policy.
func TestUnreadTagPolicySetsCacheOnly(t *testing.T) {
ctx, sys, _ := prepareIAMRevisionFixture(t)
const policyName = "unread-tag-policy"
batchAuthzPolicy(ctx, t, sys, policyName, `{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"],"Condition":{"StringEquals":{"s3:ExistingObjectTag/k":"v"}}}`)
member := iamAdminUser(ctx, t, sys, "unread-group-member", "")
_, err := sys.AddUsersToGroup(ctx, "unread-group", []string{member.AccessKey})
mustIAM(t, err)
_, err = sys.PolicyDBSet(ctx, "unread-group", policyName, regUser, true)
mustIAM(t, err)
role, err := arn.NewIAMRoleARN("unread-role", "")
mustIAM(t, err)
sys.rolesMap = map[arn.ARN]string{role: policyName}
claimUser := auth.Credentials{
AccessKey: "unread-sts", ParentUser: "external-parent", SessionToken: "token",
Expiration: UTCNow().Add(time.Hour), Claims: map[string]any{iamPolicyClaimNameOpenID(): policyName},
}
roleUser := claimUser
roleUser.Claims = map[string]any{roleArnClaim: role.String()}
original := sys.store.IAMStorageAPI
defer func() { sys.store.IAMStorageAPI = original }()
for _, tc := range []struct {
name string
cred auth.Credentials
}{
{"group", member},
{"claims", claimUser},
{"role", roleUser},
} {
t.Run(tc.name, func(t *testing.T) {
name := tc.cred.AccessKey
if tc.cred.ParentUser != "" {
name = tc.cred.ParentUser
}
probe := &batchBlockedIAMStore{
IAMStorageAPI: original, mapping: name, doc: policyName,
started: make(chan struct{}), release: make(chan struct{}),
}
close(probe.release) // report an accidental read without blocking the test
sys.store.IAMStorageAPI = probe
sets := sys.unreadTagPolicySets(tc.cred)
select {
case <-probe.started:
t.Error("candidate collection attempted a storage read")
default:
}
if len(sets) != 1 || len(sets[0]) != 1 {
t.Fatalf("expected the cached policy through %s, got %v", tc.name, sets)
}
})
}
}