From a70a9d4d84a9f167afa57424aea714a4120dadfa Mon Sep 17 00:00:00 2001 From: Harshavardhana Date: Mon, 28 Sep 2026 09:44:03 -0700 Subject: [PATCH 1/4] policy: add the aws:SourceIdentity and sts:SourceIdentity condition keys AIStor records an STS session's source identity, the person or application the session acts for, and carries it unchanged into every credential derived from it. These keys let a policy test it: aws:SourceIdentity on any S3, admin or STS request, sts:SourceIdentity on the session an STS call will mint. --- policy/condition/keyname.go | 14 +++++++ policy/source_identity_test.go | 72 ++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 policy/source_identity_test.go diff --git a/policy/condition/keyname.go b/policy/condition/keyname.go index 024bf70..0e30516 100644 --- a/policy/condition/keyname.go +++ b/policy/condition/keyname.go @@ -240,6 +240,10 @@ const ( // AWSGroups - groups for any authenticating Access Key. AWSGroups KeyName = "aws:groups" + // AWSSourceIdentity - the source identity of the session that signed the + // request, set when the session was assumed and fixed for its lifetime. + AWSSourceIdentity KeyName = "aws:SourceIdentity" + // S3SignatureVersion - identifies the version of AWS Signature that you want to support for authenticated requests. S3SignatureVersion KeyName = "s3:signatureversion" @@ -308,6 +312,10 @@ const ( // STSDurationSeconds - Duration seconds condition for STS policy STSDurationSeconds KeyName = "sts:DurationSeconds" + // STSSourceIdentity - the source identity the session being assumed will + // carry: the one requested, or the one inherited from the caller's session. + STSSourceIdentity KeyName = "sts:SourceIdentity" + // SVCDurationSeconds - Duration seconds condition for Admin policy SVCDurationSeconds KeyName = "svc:DurationSeconds" @@ -389,6 +397,7 @@ var AllSupportedKeys = []KeyName{ AWSUserID, AWSUsername, AWSGroups, + AWSSourceIdentity, LDAPUser, LDAPUsername, LDAPGroups, @@ -418,6 +427,7 @@ var AllSupportedKeys = []KeyName{ JWTScope, JWTClientID, STSDurationSeconds, + STSSourceIdentity, SVCDurationSeconds, AdminPolicyName, } @@ -440,6 +450,7 @@ var CommonKeys = append([]KeyName{ AWSUserID, AWSUsername, AWSGroups, + AWSSourceIdentity, LDAPUser, LDAPUsername, LDAPGroups, @@ -468,6 +479,7 @@ var CommonAdminKeys = append([]KeyName{ AWSUserID, AWSUsername, AWSGroups, + AWSSourceIdentity, LDAPUser, LDAPUsername, LDAPGroups, @@ -491,6 +503,8 @@ var AllSupportedSTSKeys = []KeyName{ AWSPrincipalType, AWSSecureTransport, STSDurationSeconds, + STSSourceIdentity, + AWSSourceIdentity, LDAPUser, AWSUserID, AWSGroups, diff --git a/policy/source_identity_test.go b/policy/source_identity_test.go new file mode 100644 index 0000000..9db85d7 --- /dev/null +++ b/policy/source_identity_test.go @@ -0,0 +1,72 @@ +// Copyright (c) 2015-2026 MinIO, Inc. +// +// This file is part of MinIO Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package policy + +import ( + "strings" + "testing" +) + +func TestSourceIdentityConditions(t *testing.T) { + doc := `{ + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": ["s3:GetObject"], "Resource": ["arn:aws:s3:::data/*"], + "Condition": {"StringLike": {"aws:SourceIdentity": ["alice*"]}}}, + {"Effect": "Allow", "Action": ["admin:ServerInfo"], + "Condition": {"StringEquals": {"aws:SourceIdentity": ["alice"]}}}, + {"Effect": "Allow", "Action": ["sts:AssumeRole"], + "Condition": {"StringEquals": {"sts:SourceIdentity": ["alice"]}}} + ] +}` + p, err := ParseConfig(strings.NewReader(doc)) + if err != nil { + t.Fatalf("a policy naming the source identity keys must parse: %v", err) + } + + cases := []struct { + action Action + sourceIdentity []string + allowed bool + }{ + {GetObjectAction, []string{"alice@example.com"}, true}, + {GetObjectAction, []string{"bob"}, false}, + {GetObjectAction, nil, false}, + {Action(ServerInfoAdminAction), []string{"alice"}, true}, + {Action(ServerInfoAdminAction), []string{"bob"}, false}, + {Action(AssumeRoleAction), []string{"alice"}, true}, + {Action(AssumeRoleAction), []string{"bob"}, false}, + {Action(AssumeRoleAction), nil, false}, + } + for _, tc := range cases { + values := map[string][]string{} + if tc.sourceIdentity != nil { + values["SourceIdentity"] = tc.sourceIdentity + } + got := p.IsAllowed(Args{ + AccountName: "app", + Action: tc.action, + BucketName: "data", + ObjectName: "report.csv", + ConditionValues: values, + }) + if got != tc.allowed { + t.Errorf("%s with source identity %v: allowed=%v, want %v", tc.action, tc.sourceIdentity, got, tc.allowed) + } + } +} From 3b043748389ff3a6d4529c82f579abdcc5ef088e Mon Sep 17 00:00:00 2001 From: Harshavardhana Date: Mon, 28 Sep 2026 10:53:34 -0700 Subject: [PATCH 2/4] test: cover Deny on the source identity condition keys A Deny keyed on aws:SourceIdentity overrides a broader Allow, and a StringNotEquals Deny on sts:SourceIdentity refuses a request that carries none, which is how AIStor evaluates sts:AssumeRole. --- policy/source_identity_test.go | 52 ++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/policy/source_identity_test.go b/policy/source_identity_test.go index 9db85d7..42f1aad 100644 --- a/policy/source_identity_test.go +++ b/policy/source_identity_test.go @@ -22,6 +22,9 @@ import ( "testing" ) +// Policies can grant S3, admin and STS actions on a source identity: +// aws:SourceIdentity for the signing session's, sts:SourceIdentity for the +// one an STS call's new session will carry. func TestSourceIdentityConditions(t *testing.T) { doc := `{ "Version": "2012-10-17", @@ -70,3 +73,52 @@ func TestSourceIdentityConditions(t *testing.T) { } } } + +// A Deny keyed on a source identity overrides a broader Allow, and a +// StringNotEquals Deny refuses a request that carries no source identity. +func TestSourceIdentityConditionsDeny(t *testing.T) { + doc := `{ + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": ["s3:GetObject"], "Resource": ["arn:aws:s3:::data/*"]}, + {"Effect": "Allow", "Action": ["sts:AssumeRole"]}, + {"Effect": "Deny", "Action": ["s3:GetObject"], "Resource": ["arn:aws:s3:::data/*"], + "Condition": {"StringEquals": {"aws:SourceIdentity": ["mallory"]}}}, + {"Effect": "Deny", "Action": ["sts:AssumeRole"], + "Condition": {"StringNotEquals": {"sts:SourceIdentity": ["alice"]}}} + ] +}` + p, err := ParseConfig(strings.NewReader(doc)) + if err != nil { + t.Fatalf("a policy denying on the source identity keys must parse: %v", err) + } + + cases := []struct { + action Action + sourceIdentity []string + allowed bool + }{ + {GetObjectAction, []string{"alice"}, true}, + {GetObjectAction, []string{"mallory"}, false}, + {GetObjectAction, nil, true}, + {Action(AssumeRoleAction), []string{"alice"}, true}, + {Action(AssumeRoleAction), []string{"bob"}, false}, + {Action(AssumeRoleAction), nil, false}, + } + for _, tc := range cases { + values := map[string][]string{} + if tc.sourceIdentity != nil { + values["SourceIdentity"] = tc.sourceIdentity + } + got := p.IsAllowed(Args{ + AccountName: "app", + Action: tc.action, + BucketName: "data", + ObjectName: "report.csv", + ConditionValues: values, + }) + if got != tc.allowed { + t.Errorf("%s with source identity %v: allowed=%v, want %v", tc.action, tc.sourceIdentity, got, tc.allowed) + } + } +} From 00725e712b3b55551445e858c13da5305f2e4f01 Mon Sep 17 00:00:00 2001 From: Harshavardhana Date: Mon, 28 Sep 2026 11:49:23 -0700 Subject: [PATCH 3/4] test: lead the source identity test doc comments with their names --- policy/source_identity_test.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/policy/source_identity_test.go b/policy/source_identity_test.go index 42f1aad..0e39a89 100644 --- a/policy/source_identity_test.go +++ b/policy/source_identity_test.go @@ -22,9 +22,10 @@ import ( "testing" ) -// Policies can grant S3, admin and STS actions on a source identity: -// aws:SourceIdentity for the signing session's, sts:SourceIdentity for the -// one an STS call's new session will carry. +// TestSourceIdentityConditions verifies that policies can grant S3, admin and +// STS actions on a source identity: aws:SourceIdentity for the signing +// session's, sts:SourceIdentity for the one an STS call's new session will +// carry. func TestSourceIdentityConditions(t *testing.T) { doc := `{ "Version": "2012-10-17", @@ -74,8 +75,9 @@ func TestSourceIdentityConditions(t *testing.T) { } } -// A Deny keyed on a source identity overrides a broader Allow, and a -// StringNotEquals Deny refuses a request that carries no source identity. +// TestSourceIdentityConditionsDeny verifies that a Deny keyed on a source +// identity overrides a broader Allow, and that a StringNotEquals Deny refuses a +// request that carries no source identity. func TestSourceIdentityConditionsDeny(t *testing.T) { doc := `{ "Version": "2012-10-17", From 22fa80f38961537c0cbe0da32d5d2d2944ad0e14 Mon Sep 17 00:00:00 2001 From: Harshavardhana Date: Mon, 28 Sep 2026 12:47:12 -0700 Subject: [PATCH 4/4] policy: keep sts:SourceIdentity apart from aws:SourceIdentity Both keys trimmed to SourceIdentity, so they read one value. Within an STS call they differ: aws:SourceIdentity is the calling session's source identity and sts:SourceIdentity the one the new session will carry. A Deny on callers with no source identity therefore let a first-time setter through. sts:SourceIdentity now reads a value under its whole name. --- policy/condition/keyname.go | 13 ++++++-- policy/condition/keyname_test.go | 4 +-- policy/source_identity_test.go | 55 ++++++++++++++++++++++++++++++-- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/policy/condition/keyname.go b/policy/condition/keyname.go index 0e30516..5beb624 100644 --- a/policy/condition/keyname.go +++ b/policy/condition/keyname.go @@ -47,13 +47,22 @@ var toTrim = map[string]bool{ adminKeyPrefix: true, } +// untrimmedKeys read the request value named by their whole key, prefix +// included, because trimming would collide with a key that carries a +// different value. Within an STS call aws:SourceIdentity is the calling +// session's source identity and sts:SourceIdentity the one the new session +// will carry. +var untrimmedKeys = map[KeyName]bool{ + STSSourceIdentity: true, +} + // Name - returns the key name with its service prefix stripped, so a key reads // the request value of the same name. The prefixes that are stripped are the // keys of toTrim; a service missing from there keeps its whole name and reads a -// value nothing populates. +// value nothing populates. A key in untrimmedKeys keeps its whole name too. func (key KeyName) Name() string { idx := strings.IndexByte(string(key), ':') - if idx == -1 || !toTrim[string(key[:idx])] { + if idx == -1 || !toTrim[string(key[:idx])] || untrimmedKeys[key] { return string(key) } return string(key[idx+1:]) diff --git a/policy/condition/keyname_test.go b/policy/condition/keyname_test.go index f819a42..b6a0a0c 100644 --- a/policy/condition/keyname_test.go +++ b/policy/condition/keyname_test.go @@ -37,8 +37,8 @@ func TestServicePrefixedKeysResolveToRequestNames(t *testing.T) { for _, key := range AllSupportedKeys { full := string(key) idx := strings.IndexByte(full, ':') - if idx < 0 { - // Unprefixed keys read their own name. + if idx < 0 || untrimmedKeys[key] { + // Unprefixed and untrimmed keys read their own name. if got := key.Name(); got != full { t.Errorf("%s.Name() = %q, want %q", key, got, full) } diff --git a/policy/source_identity_test.go b/policy/source_identity_test.go index 0e39a89..86303da 100644 --- a/policy/source_identity_test.go +++ b/policy/source_identity_test.go @@ -20,6 +20,8 @@ package policy import ( "strings" "testing" + + "github.com/minio/pkg/v3/policy/condition" ) // TestSourceIdentityConditions verifies that policies can grant S3, admin and @@ -60,7 +62,8 @@ func TestSourceIdentityConditions(t *testing.T) { for _, tc := range cases { values := map[string][]string{} if tc.sourceIdentity != nil { - values["SourceIdentity"] = tc.sourceIdentity + values[condition.AWSSourceIdentity.Name()] = tc.sourceIdentity + values[condition.STSSourceIdentity.Name()] = tc.sourceIdentity } got := p.IsAllowed(Args{ AccountName: "app", @@ -110,7 +113,8 @@ func TestSourceIdentityConditionsDeny(t *testing.T) { for _, tc := range cases { values := map[string][]string{} if tc.sourceIdentity != nil { - values["SourceIdentity"] = tc.sourceIdentity + values[condition.AWSSourceIdentity.Name()] = tc.sourceIdentity + values[condition.STSSourceIdentity.Name()] = tc.sourceIdentity } got := p.IsAllowed(Args{ AccountName: "app", @@ -124,3 +128,50 @@ func TestSourceIdentityConditionsDeny(t *testing.T) { } } } + +// TestSourceIdentityKeysReadSeparateValues verifies that within one STS call +// aws:SourceIdentity reads the calling session's source identity and +// sts:SourceIdentity the one the new session will carry, so a policy can tell a +// session setting its source identity for the first time from one inheriting +// it. +func TestSourceIdentityKeysReadSeparateValues(t *testing.T) { + doc := `{ + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": ["sts:AssumeRole"], + "Condition": {"StringEquals": {"sts:SourceIdentity": ["alice"]}}}, + {"Effect": "Deny", "Action": ["sts:AssumeRole"], + "Condition": {"Null": {"aws:SourceIdentity": ["true"]}}} + ] +}` + p, err := ParseConfig(strings.NewReader(doc)) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + caller, session string + allowed bool + }{ + {name: "caller carries alice, session inherits it", caller: "alice", session: "alice", allowed: true}, + {name: "caller carries none, session sets alice", session: "alice"}, + {name: "caller carries bob, session inherits it", caller: "bob", session: "bob"}, + } + for _, tc := range cases { + values := map[string][]string{} + if tc.caller != "" { + values[condition.AWSSourceIdentity.Name()] = []string{tc.caller} + } + if tc.session != "" { + values[condition.STSSourceIdentity.Name()] = []string{tc.session} + } + got := p.IsAllowed(Args{AccountName: "app", Action: Action(AssumeRoleAction), ConditionValues: values}) + if got != tc.allowed { + t.Errorf("%s: allowed=%v, want %v", tc.name, got, tc.allowed) + } + } + if condition.AWSSourceIdentity.Name() == condition.STSSourceIdentity.Name() { + t.Errorf("aws:SourceIdentity and sts:SourceIdentity both read %q", condition.AWSSourceIdentity.Name()) + } +}