mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-19 20:44:28 +02:00
* 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.
431 lines
14 KiB
Go
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")
|
|
}
|
|
}
|