Files
seaweedfs/weed/s3api/s3tables/permissions_test.go
T
Chris Lu 8c55425d55 s3tables: tighten legacy OIDC subject re-anchoring (#11808)
* sts: recognize issuer-scoped parent user ids

ComputeParentUser output has a fixed shape (base64url SHA-256); let
callers distinguish it from bare subject strings.

* s3tables: keep legacy owner re-anchoring off local and scoped account ids

Re-anchoring a stored bare subject under the single trusted issuer must
only apply to strings that could actually be a legacy OIDC subject. A
stored owner naming the reserved or a configured local account, or an
already issuer-scoped id, can otherwise be claimed by any caller whose
token asserts that string as sub.

* s3tables: re-anchor legacy policy principals in map and array forms

A stored bare subject can appear as {"AWS": sub} or inside a principal
list, not only as a top-level string; re-anchoring only the string form
left legacy Deny statements unable to match the caller.

* s3tables: keep built-in legacy exclusions independent of the account checker

A missing local-account predicate must not disable re-anchoring entirely:
the reserved accounts, ARN-shaped values, and scoped-id shapes are never
OIDC subjects regardless, while legacy Deny statements for plain subjects
still need to resolve so a denied caller cannot fall through to an
identity grant.

* s3tables: exercise legacy Deny against an owner grant

The previous case denied the caller regardless of whether the statement
principal matched; make the caller the owner so a missed match shows.
Cover the array Deny form alongside the AWS map.
2026-10-18 02:43:03 -07:00

431 lines
14 KiB
Go

package s3tables
import (
"encoding/json"
"testing"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
)
func TestMatchesActionPattern(t *testing.T) {
tests := []struct {
name string
pattern string
action string
expected bool
}{
// Exact matches
{"exact match", "GetTable", "GetTable", true},
{"no match", "GetTable", "DeleteTable", false},
// Universal wildcard
{"universal wildcard", "*", "anything", true},
// Suffix wildcards
{"suffix wildcard match", "s3tables:*", "s3tables:GetTable", true},
{"suffix wildcard no match", "s3tables:*", "iam:GetUser", false},
// Middle wildcards (new capability from policy_engine)
{"middle wildcard Get*Table", "s3tables:Get*Table", "s3tables:GetTable", true},
{"middle wildcard Get*Table no match GetTableBucket", "s3tables:Get*Table", "s3tables:GetTableBucket", false},
{"middle wildcard Get*Table no match DeleteTable", "s3tables:Get*Table", "s3tables:DeleteTable", false},
{"middle wildcard *Table*", "s3tables:*Table*", "s3tables:GetTableBucket", true},
{"middle wildcard *Table* match CreateTable", "s3tables:*Table*", "s3tables:CreateTable", true},
// Question mark wildcards
{"question mark single char", "GetTable?", "GetTableX", true},
{"question mark no match", "GetTable?", "GetTableXY", false},
// Combined wildcards
{"combined * and ? singular", "s3tables:Get?able*", "s3tables:GetTable", true},
{"combined * and ? plural", "s3tables:Get?able*", "s3tables:GetTables", true},
{"combined no match - ? needs 1 char", "s3tables:Get?able*", "s3tables:Getable", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := matchesActionPattern(tt.pattern, tt.action)
if result != tt.expected {
t.Errorf("matchesActionPattern(%q, %q) = %v, want %v", tt.pattern, tt.action, result, tt.expected)
}
})
}
}
func TestMatchesPrincipal(t *testing.T) {
tests := []struct {
name string
principalSpec interface{}
principal string
expected bool
}{
// String principals
{"exact match", "user123", "user123", true},
{"no match", "user123", "user456", false},
{"universal wildcard", "*", "anyone", true},
// Wildcard principals
{"prefix wildcard", "arn:aws:iam::123456789012:user/*", "arn:aws:iam::123456789012:user/admin", true},
{"prefix wildcard no match", "arn:aws:iam::123456789012:user/*", "arn:aws:iam::987654321098:user/admin", false},
{"middle wildcard", "arn:aws:iam::*:user/admin", "arn:aws:iam::123456789012:user/admin", true},
// Array of principals
{"array match first", []interface{}{"user1", "user2"}, "user1", true},
{"array match second", []interface{}{"user1", "user2"}, "user2", true},
{"array no match", []interface{}{"user1", "user2"}, "user3", false},
{"array wildcard", []interface{}{"user1", "arn:aws:iam::*:user/admin"}, "arn:aws:iam::123:user/admin", true},
// Map-style AWS principals
{"AWS map exact", map[string]interface{}{"AWS": "user123"}, "user123", true},
{"AWS map wildcard", map[string]interface{}{"AWS": "arn:aws:iam::*:user/admin"}, "arn:aws:iam::123:user/admin", true},
{"AWS map array", map[string]interface{}{"AWS": []interface{}{"user1", "user2"}}, "user1", true},
// Nil/empty cases
{"nil principal", nil, "user123", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := matchesPrincipal(tt.principalSpec, tt.principal)
if result != tt.expected {
t.Errorf("matchesPrincipal(%v, %q) = %v, want %v", tt.principalSpec, tt.principal, result, tt.expected)
}
})
}
}
func TestEvaluatePolicyWithConditions(t *testing.T) {
policy := &PolicyDocument{
Statement: []Statement{
{
Effect: "Allow",
Principal: "*",
Action: "s3tables:GetTable",
Condition: map[string]map[string]interface{}{
"StringEquals": {
"s3tables:namespace": "default",
},
"StringLike": {
"s3tables:tableName": "test_*",
},
"NumericGreaterThan": {
"aws:RequestTag/priority": "10",
},
"Bool": {
"aws:ResourceTag/is_public": "true",
},
},
},
},
}
policyBytes, _ := json.Marshal(policy)
policyStr := string(policyBytes)
tests := []struct {
name string
ctx *PolicyContext
expected bool
}{
{
"all conditions match",
&PolicyContext{
Namespace: "default",
TableName: "test_table",
RequestTags: map[string]string{
"priority": "15",
},
ResourceTags: map[string]string{
"is_public": "true",
},
},
true,
},
{
"namespace mismatch",
&PolicyContext{
Namespace: "other",
TableName: "test_table",
RequestTags: map[string]string{
"priority": "15",
},
ResourceTags: map[string]string{
"is_public": "true",
},
},
false,
},
{
"table name mismatch",
&PolicyContext{
Namespace: "default",
TableName: "other_table",
RequestTags: map[string]string{
"priority": "15",
},
ResourceTags: map[string]string{
"is_public": "true",
},
},
false,
},
{
"numeric condition failure",
&PolicyContext{
Namespace: "default",
TableName: "test_table",
RequestTags: map[string]string{
"priority": "5",
},
ResourceTags: map[string]string{
"is_public": "true",
},
},
false,
},
{
"bool condition failure",
&PolicyContext{
Namespace: "default",
TableName: "test_table",
RequestTags: map[string]string{
"priority": "15",
},
ResourceTags: map[string]string{
"is_public": "false",
},
},
false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// principal="user123", owner="owner123"
result := CheckPermissionWithContext("s3tables:GetTable", "user123", "owner123", policyStr, "", tt.ctx)
if result != tt.expected {
t.Errorf("CheckPermissionWithContext() = %v, want %v", result, tt.expected)
}
})
}
}
func TestCheckPermissionWithDefaultAllow(t *testing.T) {
tests := []struct {
name string
defaultAllow bool
policy string
expected bool
}{
{
"default deny (no policy, DefaultAllow=false)",
false,
"",
false,
},
{
"default allow (no policy, DefaultAllow=true)",
true,
"",
true,
},
{
"explicit deny overrides DefaultAllow=true",
true,
`{"Statement":[{"Effect":"Deny","Principal":"*","Action":"s3tables:GetTable"}]}`,
false,
},
{
"explicit allow works with DefaultAllow=false",
false,
`{"Statement":[{"Effect":"Allow","Principal":"*","Action":"s3tables:GetTable"}]}`,
true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := &PolicyContext{
DefaultAllow: tt.defaultAllow,
}
result := CheckPermissionWithContext("s3tables:GetTable", "user123", "owner123", tt.policy, "", ctx)
if result != tt.expected {
t.Errorf("CheckPermissionWithContext() = %v, want %v (DefaultAllow=%v, Policy=%s)", result, tt.expected, tt.defaultAllow, tt.policy)
}
})
}
}
func TestCheckTableOrBucketPermissionDenyVeto(t *testing.T) {
tablePolicy := `{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Deny",
"Principal": "*",
"Action": "s3tables:DeleteTable",
"Resource": "*"
}]
}`
bucketAllow := `{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3tables:*",
"Resource": "*"
}]
}`
tableARN := "arn:aws:s3tables:us-east-1:acct:bucket/b/table/ns/t"
bucketARN := "arn:aws:s3tables:us-east-1:acct:bucket/b"
ctx := &PolicyContext{TableBucketName: "b", Namespace: "ns", TableName: "t"}
// A Deny on the table policy vetoes the bucket Allow.
if CheckTableOrBucketPermission("DeleteTable", "user123", "owner", tablePolicy, tableARN, "owner", bucketAllow, bucketARN, ctx) {
t.Error("table Deny should veto bucket Allow")
}
// The same Deny in the bucket policy vetoes a table Allow.
if CheckTableOrBucketPermission("DeleteTable", "user123", "owner", bucketAllow, tableARN, "owner", tablePolicy, bucketARN, ctx) {
t.Error("bucket Deny should veto table Allow")
}
// A table Deny also vetoes ownership of the table.
if CheckTableOrBucketPermission("DeleteTable", "owner", "owner", tablePolicy, tableARN, "owner", "", bucketARN, ctx) {
t.Error("table Deny should veto the owner grant")
}
// No Deny anywhere: either policy may allow.
if !CheckTableOrBucketPermission("DeleteTable", "user123", "other-owner", "", tableARN, "owner", bucketAllow, bucketARN, ctx) {
t.Error("bucket Allow should grant when no policy denies")
}
}
func TestCheckTableOrBucketPermissionOwnerlessFailsClosed(t *testing.T) {
ctx := &PolicyContext{
IdentityActions: []string{"s3tables:*"},
DefaultAllow: true,
}
allowPolicy := `{"Statement":[{"Effect":"Allow","Principal":"*","Action":"s3tables:*","Resource":"*"}]}`
if CheckTableOrBucketPermission("GetTable", "user1",
"", "", "arn:aws:s3tables:t",
"", "", "arn:aws:s3tables:b", ctx) {
t.Error("identity actions and default allow must not reach an ownerless resource")
}
// With no identity or default grants in play, an ownerless resource
// side contributes nothing even when its own policy allows.
plainCtx := &PolicyContext{}
if CheckTableOrBucketPermission("GetTable", "user1",
"", allowPolicy, "arn:aws:s3tables:t",
"owner", "", "arn:aws:s3tables:b", plainCtx) {
t.Error("an ownerless resource side must not grant even via an explicit allow")
}
if !CheckTableOrBucketPermission("GetTable", "user1",
"", "", "arn:aws:s3tables:t",
"owner", allowPolicy, "arn:aws:s3tables:b", plainCtx) {
t.Error("the bucket side can still grant when its owner is set")
}
}
// Owners written before issuer scoping hold a bare subject. Re-anchoring it
// is only unambiguous when exactly one OIDC issuer is configured; under two
// or more issuers a bare subject could belong to either, so it must not
// match anyone.
func TestCheckPermissionLegacyOwnerScopedToIssuer(t *testing.T) {
owner := "alice-sub"
principal := sts.ComputeParentUser(owner, "corp-idp")
noLocal := &PolicyContext{
TrustedIssuers: []string{"corp-idp"},
IsLocalAccountID: func(string) bool { return false },
}
if !CheckPermissionWithContext("GetTable", principal, owner, "", "arn", noLocal) {
t.Error("single-issuer deployments must keep recognizing the legacy owner")
}
if CheckPermissionWithContext("GetTable", principal, owner, "", "arn", &PolicyContext{
TrustedIssuers: []string{"corp-idp", "evil-idp"},
IsLocalAccountID: func(string) bool { return false },
}) {
t.Error("a bare subject is ambiguous across multiple issuers and must not match")
}
if CheckPermissionWithContext("GetTable", sts.ComputeParentUser(owner, "evil-idp"), owner, "", "arn", &PolicyContext{
TrustedIssuers: []string{"corp-idp", "evil-idp"},
IsLocalAccountID: func(string) bool { return false },
}) {
t.Error("a same-sub caller from another issuer must not claim the legacy owner")
}
if CheckPermissionWithContext("GetTable", principal, owner, "", "arn", &PolicyContext{
IsLocalAccountID: func(string) bool { return false },
}) {
t.Error("legacy owner matching must not engage without a trusted issuer")
}
// With no local-account predicate only the unconditional exclusions apply;
// a plain legacy subject still re-anchors.
if !CheckPermissionWithContext("GetTable", principal, owner, "", "arn", &PolicyContext{
TrustedIssuers: []string{"corp-idp"},
}) {
t.Error("a missing local-account check keeps plain legacy subjects working")
}
}
// A stored owner that names a reserved or configured local account was never
// an OIDC subject, and an already issuer-scoped id is not a legacy subject
// either; re-anchoring them would let a caller pick sub=<stored owner> and
// claim the resource.
func TestCheckPermissionLegacyOwnerExcludesLocalAndScoped(t *testing.T) {
ctx := &PolicyContext{
TrustedIssuers: []string{"corp-idp"},
IsLocalAccountID: func(id string) bool { return id == "admin" || id == "owner2" },
}
for _, owner := range []string{"admin", "owner2"} {
principal := sts.ComputeParentUser(owner, "corp-idp")
if CheckPermissionWithContext("GetTable", principal, owner, "", "arn", ctx) {
t.Errorf("sub=%q must not re-anchor onto the local account owner", owner)
}
}
scoped := sts.ComputeParentUser("victim-sub", "corp-idp")
if CheckPermissionWithContext("GetTable", sts.ComputeParentUser(scoped, "corp-idp"), scoped, "", "arn", ctx) {
t.Error("sub equal to a stored scoped id must not re-anchor")
}
// An ARN-shaped subject claims nothing; ARNs name IAM principals, not OIDC
// subjects, and this shape is what let a forged sub reach the admin account.
arn := "arn:aws:iam::123456789012:user/admin"
if CheckPermissionWithContext("GetTable", sts.ComputeParentUser(arn, "corp-idp"), arn, "", "arn", ctx) {
t.Error("an ARN-shaped stored owner must not re-anchor")
}
}
// Legacy policies can carry the bare subject in the AWS map or array forms,
// not only as a top-level string; re-anchoring must reach all of them or a
// pre-scoping Deny silently stops matching the caller.
func TestCheckPermissionLegacyPolicyPrincipalShapes(t *testing.T) {
ctx := &PolicyContext{
TrustedIssuers: []string{"corp-idp"},
IsLocalAccountID: func(string) bool { return false },
}
principal := sts.ComputeParentUser("alice-sub", "corp-idp")
other := "other-owner"
// The caller owns the resource, so only a matched Deny can veto it.
for _, policy := range []string{
`{"Statement":[{"Effect":"Deny","Principal":{"AWS":"alice-sub"},"Action":"s3tables:GetTable"}]}`,
`{"Statement":[{"Effect":"Deny","Principal":["alice-sub"],"Action":"s3tables:GetTable"}]}`,
} {
if CheckPermissionWithContext("GetTable", principal, principal, policy, "arn", ctx) {
t.Errorf("legacy Deny must veto the scoped caller's owner grant: %s", policy)
}
}
allow := `{"Statement":[{"Effect":"Allow","Principal":["alice-sub"],"Action":"s3tables:GetTable"}]}`
if !CheckPermissionWithContext("GetTable", principal, other, allow, "arn", ctx) {
t.Error("legacy Allow with array principal must still match the scoped caller")
}
}