mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-21 21:44:22 +02:00
* s3tables: share the UpdateTable authorization gate Extract the validation, load, and permission check from handleUpdateTable into authorizeUpdateTable so the table an update was authorized against is the same snapshot the mutation writes to. Manager gains AuthorizeUpdateTable, mirroring AuthorizeCreateTable, so callers can ask the question without updating anything. * s3tables: share the UpdateView authorization gate Extract the lookup, policy load, and permission check from handleUpdateView into authorizeUpdateView, mirroring authorizeUpdateTable, and expose it as Manager.AuthorizeUpdateView for callers that need the answer without the update. * s3api: scope vended table credentials to read-only on request VendTableCredentials takes a readOnly flag and tablePrefixSessionPolicy emits only s3:GetObject for the table prefix when it is set, keeping the conditioned ListBucket and unconditioned GetBucketLocation statements that reads still need. * iceberg: vend read-only credentials when the caller may not write LoadTable and friends vend storage credentials only for the authority the caller holds: the vend now checks UpdateTable/UpdateView permission and a caller that may only read the catalog entry gets a read-only session. Paths that just passed a write gate (stage-create, view commit) keep the full session. * lance: vend read-only credentials when the caller may not write storage_options vends the same read-only session as the Iceberg catalog when the caller lacks UpdateTable on the table. * lance: keep write credentials for callers authorized to declare A Lance commit is a direct S3 write that never revisits the catalog, so a caller that just passed CreateTable still gets a writable session; the UpdateTable gate applies to describe, the read path. * iceberg, lance: log a failed write check instead of only reading it as denied
209 lines
7.0 KiB
Go
209 lines
7.0 KiB
Go
package s3api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestTablePrefixSessionPolicyScopesToTheTable(t *testing.T) {
|
|
raw, err := tablePrefixSessionPolicy("warehouse", "analytics/events", false)
|
|
if err != nil {
|
|
t.Fatalf("tablePrefixSessionPolicy() error = %v", err)
|
|
}
|
|
|
|
var policy struct {
|
|
Statement []struct {
|
|
Effect string `json:"Effect"`
|
|
Action []string `json:"Action"`
|
|
Resource []string `json:"Resource"`
|
|
} `json:"Statement"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &policy); err != nil {
|
|
t.Fatalf("policy is not valid JSON: %v", err)
|
|
}
|
|
// objects, conditioned bucket listing, and unconditioned location lookup
|
|
if len(policy.Statement) != 3 {
|
|
t.Fatalf("policy has %d statements, want 3", len(policy.Statement))
|
|
}
|
|
|
|
objects := policy.Statement[0]
|
|
if objects.Resource[0] != "arn:aws:s3:::warehouse/analytics/events/*" {
|
|
t.Errorf("object resource = %q, want the table prefix only", objects.Resource[0])
|
|
}
|
|
if policy.Statement[1].Resource[0] != "arn:aws:s3:::warehouse" {
|
|
t.Errorf("bucket resource = %q", policy.Statement[1].Resource[0])
|
|
}
|
|
for _, statement := range policy.Statement {
|
|
if statement.Effect != "Allow" {
|
|
t.Errorf("statement effect = %q, want Allow", statement.Effect)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A caller the catalog would not let commit gets a session that can read the
|
|
// table's files and nothing more.
|
|
func TestTablePrefixSessionPolicyReadOnly(t *testing.T) {
|
|
raw, err := tablePrefixSessionPolicy("warehouse", "analytics/events", true)
|
|
if err != nil {
|
|
t.Fatalf("tablePrefixSessionPolicy() error = %v", err)
|
|
}
|
|
|
|
var policy struct {
|
|
Statement []struct {
|
|
Action []string `json:"Action"`
|
|
} `json:"Statement"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &policy); err != nil {
|
|
t.Fatalf("policy is not valid JSON: %v", err)
|
|
}
|
|
|
|
for _, statement := range policy.Statement {
|
|
for _, action := range statement.Action {
|
|
switch action {
|
|
case "s3:PutObject", "s3:DeleteObject", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts":
|
|
t.Errorf("read-only session policy grants %s", action)
|
|
}
|
|
}
|
|
}
|
|
if got := policy.Statement[0].Action; len(got) != 1 || got[0] != "s3:GetObject" {
|
|
t.Errorf("object actions = %v, want [s3:GetObject]", got)
|
|
}
|
|
}
|
|
|
|
// A table at the bucket root has no prefix to scope to, and vending would hand
|
|
// out read and write over every other table in the bucket.
|
|
func TestTablePrefixSessionPolicyRefusesAWholeBucket(t *testing.T) {
|
|
if _, err := tablePrefixSessionPolicy("warehouse", "", false); err == nil {
|
|
t.Error("tablePrefixSessionPolicy() error = nil, want a refusal for an empty prefix")
|
|
}
|
|
if _, err := tablePrefixSessionPolicy("warehouse", "/", false); err == nil {
|
|
t.Error("a prefix of only separators was accepted")
|
|
}
|
|
}
|
|
|
|
// GetBucketLocation carries no prefix, so conditioning it would deny location
|
|
// discovery to every vended credential.
|
|
func TestTablePrefixSessionPolicyLeavesBucketLocationUnconditioned(t *testing.T) {
|
|
raw, err := tablePrefixSessionPolicy("warehouse", "analytics/events", false)
|
|
if err != nil {
|
|
t.Fatalf("tablePrefixSessionPolicy() error = %v", err)
|
|
}
|
|
|
|
var policy struct {
|
|
Statement []struct {
|
|
Action []string `json:"Action"`
|
|
Condition map[string]any `json:"Condition"`
|
|
} `json:"Statement"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &policy); err != nil {
|
|
t.Fatalf("policy is not valid JSON: %v", err)
|
|
}
|
|
|
|
for _, statement := range policy.Statement {
|
|
for _, action := range statement.Action {
|
|
if action == "s3:GetBucketLocation" && statement.Condition != nil {
|
|
t.Errorf("s3:GetBucketLocation carries a condition it can never satisfy: %v", statement.Condition)
|
|
}
|
|
if action == "s3:ListBucketMultipartUploads" {
|
|
t.Error("s3:ListBucketMultipartUploads is granted; it cannot be scoped by prefix")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIcebergSessionNameIsBounded(t *testing.T) {
|
|
name := icebergSessionName(strings.Repeat("principal", 10), "warehouse", "ns/table")
|
|
if len(name) > 64 {
|
|
t.Errorf("session name is %d chars, want at most 64", len(name))
|
|
}
|
|
for _, r := range name {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
|
default:
|
|
t.Errorf("session name %q contains %q, which AWS rejects", name, r)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Vending stays off until an operator names a role, so the catalog keeps
|
|
// telling clients to use their own credentials.
|
|
func TestVendTableCredentialsDisabledWithoutRole(t *testing.T) {
|
|
s3a := &S3ApiServer{}
|
|
credentials, err := s3a.VendTableCredentials(context.Background(), "admin", "warehouse", "ns/t", false)
|
|
if err != nil {
|
|
t.Fatalf("VendTableCredentials() error = %v, want nil", err)
|
|
}
|
|
if credentials != nil {
|
|
t.Errorf("VendTableCredentials() = %v, want nil while no role is configured", credentials)
|
|
}
|
|
}
|
|
|
|
func TestVendTableCredentialsNeedsSTS(t *testing.T) {
|
|
s3a := &S3ApiServer{}
|
|
s3a.SetIcebergCredentialRole("arn:aws:iam::role/IcebergTableAccess", 3600)
|
|
|
|
if _, err := s3a.VendTableCredentials(context.Background(), "admin", "warehouse", "ns/t", false); err == nil {
|
|
t.Error("VendTableCredentials() error = nil, want an error when STS is not configured")
|
|
}
|
|
}
|
|
|
|
// Listing is granted on the bucket, so the prefix condition is what keeps a
|
|
// vended credential from enumerating other tables.
|
|
func TestTablePrefixSessionPolicyRestrictsListing(t *testing.T) {
|
|
raw, err := tablePrefixSessionPolicy("warehouse", "analytics/events", false)
|
|
if err != nil {
|
|
t.Fatalf("tablePrefixSessionPolicy() error = %v", err)
|
|
}
|
|
|
|
var policy struct {
|
|
Statement []struct {
|
|
Action []string `json:"Action"`
|
|
Condition map[string]map[string][]string `json:"Condition"`
|
|
} `json:"Statement"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &policy); err != nil {
|
|
t.Fatalf("policy is not valid JSON: %v", err)
|
|
}
|
|
|
|
var bucketStatement *struct {
|
|
Action []string `json:"Action"`
|
|
Condition map[string]map[string][]string `json:"Condition"`
|
|
}
|
|
for i := range policy.Statement {
|
|
for _, action := range policy.Statement[i].Action {
|
|
if action == "s3:ListBucket" {
|
|
bucketStatement = &policy.Statement[i]
|
|
}
|
|
}
|
|
}
|
|
if bucketStatement == nil {
|
|
t.Fatal("no statement grants s3:ListBucket")
|
|
}
|
|
|
|
prefixes := bucketStatement.Condition["StringLike"]["s3:prefix"]
|
|
want := map[string]bool{"analytics/events": true, "analytics/events/*": true}
|
|
if len(prefixes) != len(want) {
|
|
t.Fatalf("s3:prefix = %v, want %v", prefixes, want)
|
|
}
|
|
for _, prefix := range prefixes {
|
|
if !want[prefix] {
|
|
t.Errorf("s3:prefix contains %q, which is outside the table", prefix)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A location carrying a wildcard would widen the resource pattern to sibling
|
|
// tables, so it is refused instead of vended.
|
|
func TestTablePrefixSessionPolicyRefusesWildcards(t *testing.T) {
|
|
for _, prefix := range []string{"analytics/*", "analytics/ev?nts", "*"} {
|
|
if _, err := tablePrefixSessionPolicy("warehouse", prefix, false); err == nil {
|
|
t.Errorf("tablePrefixSessionPolicy(%q) error = nil, want a refusal", prefix)
|
|
}
|
|
}
|
|
if _, err := tablePrefixSessionPolicy("ware*house", "analytics/events", false); err == nil {
|
|
t.Error("a wildcard in the bucket name was accepted")
|
|
}
|
|
}
|