Files
seaweedfs/weed/s3api/s3tables/permissions_test.go
T
Chris LuandDevin 98fe70a247 s3vectors: keep bucket policies in vector form and evaluate them in the gateway (#12214)
* s3vectors: translate vector bucket policies to the catalog form

The document is stored verbatim on the shared table bucket and evaluated
against s3tables actions and ARNs, so a policy written for the vector API
was a dead rule. Actions now map to the catalog operation the request
authorizes against, and an arn:aws:s3vectors: resource becomes the
bucket/.../table/vector/... ARN the index's catalog entry answers to.

* s3tables: expose PrincipalFor and PolicyContext for façade evaluation

A façade authorizing against its own action namespace needs the same
identity resolution and evaluation context the catalog uses internally.

* s3vectors: keep bucket policies in vector form and evaluate them in the gateway

The earlier approach translated the document to catalog actions and ARNs,
which lost the caller's original policy for GetVectorBucketPolicy and could
not cover data-plane calls that never touch the catalog. The document now
stays in s3vectors form under a dedicated bucket attribute and every
handler evaluates it through authorizeVector against the action and ARN
the request names; a dedicated key also keeps a vector grant from ever
authorizing a catalog call.

* s3vectors: close policy-mutation races and keep vector grants visible

- Authorize Put/DeleteVectorBucketPolicy inside the policy slot's
  compare-and-swap, so a grant revoked between reads cannot authorize
  the pending write.
- Assert the policy attributes stay absent in the update precondition,
  so a concurrent policy or tag write trips the retry instead of being
  silently reverted.
- Migrate a raw vector document out of the shared policy slot on
  replace or delete, keeping its catalog rules.
- Pass the unresolved identity to PolicyContext so open-by-default
  deployments still work.
- Filter ListVectorBuckets on the vector policy itself, so buckets a
  caller was granted under s3vectors actions stay listed.

* s3vectors: reuse SnapshotExtended for the vector-policy CAS precondition

* s3vectors: skip buckets that vanish mid-list instead of failing the page

* s3vectors: reject non-vector bucket formats inside authorizeVector

* s3vectors: check bucket format after authorization in authorizeVector

An unauthorized caller probing an ICEBERG bucket got NoSuchBucket,\nleaking that the bucket exists. The format check now runs after the\npermission check so denied callers always see AccessDenied.

* s3vectors: cover legacy shared-slot policy removal on delete

* s3tables: resolve the forwarded identity name like getAccountID in PrincipalFor

A synthesized manager request resolves its caller through the identity-
name branch of getAccountID, which normalizes the name (ARNs collapse to
their trailing segment, whitespace trims) before falling back to the
handler's account ID. PrincipalFor returned the raw name, so ARN-shaped
identities never matched their own grants.

* s3tables: assert the vector policy key in update preconditions

s3vectors-policy is stored on table bucket entries but was missing from
s3tablesExtendedKeys, so a catalog update that read the entry before the
first vector policy was saved could overwrite it without tripping the
compare-and-swap. Asserting the key makes such a race fail instead of
silently erasing the policy.

* s3vectors: reject rules the evaluator cannot honor in vector bucket policies

The shared Statement shape has no negated fields, the vector evaluation
supplies no condition keys, and a document that fails the typed decode
is discarded wholesale at evaluation — including denies the writer
believed active. Reject NotPrincipal, Condition, foreign-service actions
and resource ARNs, and require the typed PolicyDocument decode. Also
cover that an admin-named identity still reaches its own buckets.

* s3vectors: read vector policy fields the way the evaluator decodes them

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3tables: match ARN policy principals through caller normalization

Caller principals are normalized ids, so a statement naming a full IAM
ARN could never match: an ARN-scoped deny was silently dead and an
ARN-scoped allow unreachable. Compare arn: specs against the caller
under the same normalizePrincipalID the ownership path uses, while an
ARN caller keeps verbatim wildcard matching.

* s3tables: grant the admin bypass on the caller's actions, not the name

A principal that only normalizes to the admin id skipped every policy
deny. When the request carries an identity, the bypass now requires the
identity's own admin action — the same gate getAccountID applies — while
name-only transports with no action list keep it. PolicyContext takes
the request so it can carry IdentityActions, and the vector context
helpers route through the shared identity keys so the resolved name and
actions agree.

* s3tables: match ARN policy principals only for deny statements

Normalization drops the ARN's account and path, so an allow matched on the
trailing name could grant a same-named caller the statement never verified.
Denies keep the normalized match so the rule cannot be ducked through the
same collapse.

* s3tables: scope service wildcards to their own service

s3tables:* counted as blanket admin inside the shared permission check, so
an identity holding only catalog grants administered s3vectors operations
too. hasAdminActionFor now requires the wildcard to name the operation's
service; the global admin markers and s3:* stay service-agnostic.

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-29 13:35:34 -07:00

525 lines
19 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},
// ARN statements do not reach normalized-name callers on the allow
// path: normalization drops the ARN's account and path, so the grant
// could land on a same-named caller the statement never verified.
{"arn spec exact caller", "arn:aws:iam::123456789012:user/bob", "bob", false},
{"arn spec wildcard caller", "arn:aws:iam::*:user/alice", "alice", false},
{"arn spec no match", "arn:aws:iam::123456789012:user/carol", "dan", false},
// 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")
}
}
// A principal that merely normalizes to the admin id must not bypass policy
// denies: when the caller's actions are known, the admin bypass requires an
// admin action; transports carrying no action list keep it.
func TestCheckPermissionAdminRequiresAdminAction(t *testing.T) {
denyAll := `{"Statement":[{"Effect":"Deny","Principal":"*","Action":"s3tables:DeleteTable","Resource":"*"}]}`
arn := "arn:aws:s3tables:us-east-1:acct:bucket/b/table/ns/t"
reader := &PolicyContext{IdentityActions: []string{"Read"}}
if CheckPermissionWithContext("s3tables:DeleteTable", "admin", "admin", denyAll, arn, reader) {
t.Error("a name-only admin must not bypass an explicit deny")
}
admin := &PolicyContext{IdentityActions: []string{"Admin"}}
if !CheckPermissionWithContext("s3tables:DeleteTable", "admin", "admin", denyAll, arn, admin) {
t.Error("an identity holding the admin action keeps the bypass")
}
if !CheckPermissionWithContext("s3tables:DeleteTable", "admin", "admin", denyAll, arn, nil) {
t.Error("a caller with no action list keeps the trusted bypass")
}
}
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")
}
}
func TestValidatePolicyDocument(t *testing.T) {
valid := `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"s3tables:GetTable","Resource":"*"}]}`
if err := validatePolicyDocument(valid); err != nil {
t.Fatalf("valid policy rejected: %v", err)
}
if err := validatePolicyDocument("not-json"); err == nil {
t.Fatal("malformed policy should be rejected")
}
dead := `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"s3vectors:PutVectors","Resource":"*"}]}`
if err := validatePolicyDocument(dead); err == nil {
t.Fatal("s3vectors action should be rejected")
}
deadList := `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":["s3tables:*","s3vectors:*"],"Resource":"*"}]}`
if err := validatePolicyDocument(deadList); err == nil {
t.Fatal("action list containing s3vectors should be rejected")
}
deadResource := `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"s3tables:*","Resource":"arn:aws:s3vectors:r:a:bucket/b"}]}`
if err := validatePolicyDocument(deadResource); err == nil {
t.Fatal("s3vectors resource should be rejected")
}
}
// An ARN-scoped statement reaches a caller recorded under the ARN's trailing
// name only for denies: normalization drops the account and path, so an allow
// matched that way could grant a same-named caller the statement never
// verified, while a deny that missed would let the caller duck the rule.
func TestArnScopedPrincipalMatchesOnlyForDeny(t *testing.T) {
arn := "arn:aws:s3vectors:us-east-1:acct:bucket/b"
denyPolicy := `{"Statement":[{"Effect":"Deny","Principal":"arn:aws:iam::111:user/bob","Action":"s3vectors:DeleteVectors","Resource":"*"}]}`
allowPolicy := `{"Statement":[{"Effect":"Allow","Principal":"arn:aws:iam::111:user/bob","Action":"s3vectors:DeleteVectors","Resource":"*"}]}`
if CheckPermissionWithContext("s3vectors:DeleteVectors", "bob", "owner", denyPolicy, arn, &PolicyContext{}) {
t.Error("an ARN-scoped deny must still reach the normalized caller")
}
if CheckPermissionWithContext("s3vectors:DeleteVectors", "bob", "owner", allowPolicy, arn, &PolicyContext{}) {
t.Error("an ARN-scoped allow must not grant on the normalized name alone")
}
}
// A service wildcard administers only its own service: s3tables:* satisfies
// the admin and identity grants for table operations but must not reach an
// s3vectors operation.
func TestServiceWildcardScopesToOwnService(t *testing.T) {
vectorOp := "s3vectors:DeleteVectors"
vectorArn := "arn:aws:s3vectors:us-east-1:acct:bucket/b"
tableOnly := &PolicyContext{IdentityActions: []string{"s3tables:*"}}
if CheckPermissionWithContext(vectorOp, "bob", "owner", "", vectorArn, tableOnly) {
t.Error("s3tables:* must not grant a vector operation")
}
if CheckPermissionWithContext(vectorOp, "admin", "owner", "", vectorArn, tableOnly) {
t.Error("s3tables:* must not fire the admin bypass for a vector operation")
}
if !CheckPermissionWithContext("GetTable", "bob", "owner", "", "arn:aws:s3tables:b", tableOnly) {
t.Error("s3tables:* keeps administering table operations")
}
vectorAdmin := &PolicyContext{IdentityActions: []string{"s3vectors:*"}}
if !CheckPermissionWithContext(vectorOp, "bob", "owner", "", vectorArn, vectorAdmin) {
t.Error("s3vectors:* administers vector operations")
}
global := &PolicyContext{IdentityActions: []string{"Admin"}}
if !CheckPermissionWithContext(vectorOp, "admin", "owner", "", vectorArn, global) {
t.Error("the global admin action keeps the bypass on vector operations")
}
}