Add s3tables shell and admin UI (#8172)

* Add shared s3tables manager

* Add s3tables shell commands

* Add s3tables admin API

* Add s3tables admin UI

* Fix admin s3tables namespace create

* Rename table buckets menu

* Centralize s3tables tag validation

* Reuse s3tables manager in admin

* Extract s3tables list limit

* Add s3tables bucket ARN helper

* Remove write middleware from s3tables APIs

* Fix bucket link and policy hint

* Fix table tag parsing and nav link

* Disable namespace table link on invalid ARN

* Improve s3tables error decode

* Return flag parse errors for s3tables tag

* Accept query params for namespace create

* Bind namespace create form data

* Read s3tables JS data from DOM

* s3tables: allow empty region ARN

* shell: pass s3tables account id

* shell: require account for table buckets

* shell: use bucket name for namespaces

* shell: use bucket name for tables

* shell: use bucket name for tags

* admin: add table buckets links in file browser

* s3api: reuse s3tables tag validation

* admin: harden s3tables UI handlers

* fix admin list table buckets

* allow admin s3tables access

* validate s3tables bucket tags

* log s3tables bucket metadata errors

* rollback table bucket on owner failure

* show s3tables bucket owner

* add s3tables iam conditions

* Add s3tables user permissions UI

* Authorize s3tables using identity actions

* Add s3tables permissions to user modal

* Disambiguate bucket scope in user permissions

* Block table bucket names that match S3 buckets

* Pretty-print IAM identity JSON

* Include tags in s3tables permission context

* admin: refactor S3 Tables inline JavaScript into a separate file

* s3tables: extend IAM policy condition operators support

* shell: use LookupEntry wrapper for s3tables bucket conflict check

* admin: handle buildBucketPermissions validation in create/update flows
This commit is contained in:
Chris Lu
2026-01-30 22:57:05 -08:00
committed by GitHub
parent b2b0a38e71
commit 79722bcf30
37 changed files with 5004 additions and 475 deletions

View File

@@ -1,11 +1,13 @@
package s3tables
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"reflect"
"strings"
"github.com/seaweedfs/seaweedfs/weed/glog"
@@ -169,6 +171,44 @@ func (h *S3TablesHandler) getAccountID(r *http.Request) string {
return h.accountID
}
// getIdentityActions extracts the action list from the identity object in the request context.
// Uses reflection to avoid import cycles with s3api package.
func getIdentityActions(r *http.Request) []string {
identityRaw := s3_constants.GetIdentityFromContext(r)
if identityRaw == nil {
return nil
}
// Use reflection to access the Actions field to avoid import cycle
val := reflect.ValueOf(identityRaw)
if val.Kind() == reflect.Ptr {
val = val.Elem()
}
if val.Kind() != reflect.Struct {
return nil
}
actionsField := val.FieldByName("Actions")
if !actionsField.IsValid() || actionsField.Kind() != reflect.Slice {
return nil
}
// Convert actions to string slice
actions := make([]string, actionsField.Len())
for i := 0; i < actionsField.Len(); i++ {
action := actionsField.Index(i)
// Action is likely a custom type (e.g., type Action string)
// Convert to string using String() or direct string conversion
if action.Kind() == reflect.String {
actions[i] = action.String()
} else if action.CanInterface() {
// Try to convert via fmt.Sprint
actions[i] = fmt.Sprint(action.Interface())
}
}
return actions
}
// Request/Response helpers
func (h *S3TablesHandler) readRequestBody(r *http.Request, v interface{}) error {
@@ -235,3 +275,29 @@ func isAuthError(err error) bool {
var authErr *AuthError
return errors.As(err, &authErr) || errors.Is(err, ErrAccessDenied)
}
func (h *S3TablesHandler) readTags(ctx context.Context, client filer_pb.SeaweedFilerClient, path string) (map[string]string, error) {
data, err := h.getExtendedAttribute(ctx, client, path, ExtendedKeyTags)
if err != nil {
if errors.Is(err, ErrAttributeNotFound) {
return nil, nil
}
return nil, err
}
tags := make(map[string]string)
if err := json.Unmarshal(data, &tags); err != nil {
return nil, fmt.Errorf("failed to unmarshal tags: %w", err)
}
return tags, nil
}
func mapKeys(tags map[string]string) []string {
if len(tags) == 0 {
return nil
}
keys := make([]string, 0, len(tags))
for key := range tags {
keys = append(keys, key)
}
return keys
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
// handleCreateTableBucket creates a new table bucket
@@ -34,10 +35,30 @@ func (h *S3TablesHandler) handleCreateTableBucket(w http.ResponseWriter, r *http
bucketPath := getTableBucketPath(req.Name)
// Check if bucket already exists
exists := false
// Check if bucket already exists and ensure no conflict with object store buckets
tableBucketExists := false
s3BucketExists := false
err := filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
_, err := filer_pb.LookupEntry(r.Context(), client, &filer_pb.LookupDirectoryEntryRequest{
resp, err := client.GetFilerConfiguration(r.Context(), &filer_pb.GetFilerConfigurationRequest{})
if err != nil {
return err
}
bucketsPath := resp.DirBuckets
if bucketsPath == "" {
bucketsPath = s3_constants.DefaultBucketsPath
}
_, err = filer_pb.LookupEntry(r.Context(), client, &filer_pb.LookupDirectoryEntryRequest{
Directory: bucketsPath,
Name: req.Name,
})
if err != nil {
if !errors.Is(err, filer_pb.ErrNotFound) {
return err
}
} else {
s3BucketExists = true
}
_, err = filer_pb.LookupEntry(r.Context(), client, &filer_pb.LookupDirectoryEntryRequest{
Directory: TablesPath,
Name: req.Name,
})
@@ -47,7 +68,7 @@ func (h *S3TablesHandler) handleCreateTableBucket(w http.ResponseWriter, r *http
}
return err
}
exists = true
tableBucketExists = true
return nil
})
@@ -57,7 +78,12 @@ func (h *S3TablesHandler) handleCreateTableBucket(w http.ResponseWriter, r *http
return err
}
if exists {
if s3BucketExists {
h.writeError(w, http.StatusConflict, ErrCodeBucketAlreadyExists, fmt.Sprintf("bucket name %s is already used by an object store bucket", req.Name))
return fmt.Errorf("bucket name conflicts with object store bucket")
}
if tableBucketExists {
h.writeError(w, http.StatusConflict, ErrCodeBucketAlreadyExists, fmt.Sprintf("table bucket %s already exists", req.Name))
return fmt.Errorf("bucket already exists")
}

View File

@@ -65,9 +65,13 @@ func (h *S3TablesHandler) handleGetTableBucket(w http.ResponseWriter, r *http.Re
return err
}
// Check permission
bucketARN := h.generateTableBucketARN(metadata.OwnerAccountID, bucketName)
principal := h.getAccountID(r)
if !CanGetTableBucket(principal, metadata.OwnerAccountID, bucketPolicy) {
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("GetTableBucket", principal, metadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
IdentityActions: identityActions,
}) {
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to get table bucket details")
return ErrAccessDenied
}
@@ -91,10 +95,12 @@ func (h *S3TablesHandler) handleListTableBuckets(w http.ResponseWriter, r *http.
return err
}
// Check permission
principal := h.getAccountID(r)
accountID := h.getAccountID(r)
if !CanListTableBuckets(principal, accountID, "") {
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("ListTableBuckets", principal, accountID, "", "", &PolicyContext{
IdentityActions: identityActions,
}) {
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to list table buckets")
return NewAuthError("ListTableBuckets", principal, "not authorized to list table buckets")
}
@@ -171,12 +177,28 @@ func (h *S3TablesHandler) handleListTableBuckets(w http.ResponseWriter, r *http.
continue
}
if metadata.OwnerAccountID != accountID {
bucketPath := getTableBucketPath(entry.Entry.Name)
bucketPolicy := ""
policyData, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyPolicy)
if err != nil {
if !errors.Is(err, ErrAttributeNotFound) {
continue
}
} else {
bucketPolicy = string(policyData)
}
bucketARN := h.generateTableBucketARN(metadata.OwnerAccountID, entry.Entry.Name)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("GetTableBucket", accountID, metadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TableBucketName: entry.Entry.Name,
IdentityActions: identityActions,
}) {
continue
}
buckets = append(buckets, TableBucketSummary{
ARN: h.generateTableBucketARN(metadata.OwnerAccountID, entry.Entry.Name),
ARN: bucketARN,
Name: entry.Entry.Name,
CreatedAt: metadata.CreatedAt,
})
@@ -267,9 +289,13 @@ func (h *S3TablesHandler) handleDeleteTableBucket(w http.ResponseWriter, r *http
bucketPolicy = string(policyData)
}
// 2. Check permission
bucketARN := h.generateTableBucketARN(metadata.OwnerAccountID, bucketName)
principal := h.getAccountID(r)
if !CanDeleteTableBucket(principal, metadata.OwnerAccountID, bucketPolicy) {
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("DeleteTableBucket", principal, metadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
IdentityActions: identityActions,
}) {
return NewAuthError("DeleteTableBucket", principal, fmt.Sprintf("not authorized to delete bucket %s", bucketName))
}

View File

@@ -46,6 +46,7 @@ func (h *S3TablesHandler) handleCreateNamespace(w http.ResponseWriter, r *http.R
bucketPath := getTableBucketPath(bucketName)
var bucketMetadata tableBucketMetadata
var bucketPolicy string
var bucketTags map[string]string
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
data, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata)
if err != nil {
@@ -62,6 +63,10 @@ func (h *S3TablesHandler) handleCreateNamespace(w http.ResponseWriter, r *http.R
} else if !errors.Is(err, ErrAttributeNotFound) {
return fmt.Errorf("failed to fetch bucket policy: %v", err)
}
bucketTags, err = h.readTags(r.Context(), client, bucketPath)
if err != nil {
return err
}
return nil
})
@@ -75,9 +80,15 @@ func (h *S3TablesHandler) handleCreateNamespace(w http.ResponseWriter, r *http.R
return err
}
// Check permission
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
principal := h.getAccountID(r)
if !CanCreateNamespace(principal, bucketMetadata.OwnerAccountID, bucketPolicy) {
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("CreateNamespace", principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespaceName,
TableBucketTags: bucketTags,
IdentityActions: identityActions,
}) {
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to create namespace in this bucket")
return ErrAccessDenied
}
@@ -172,6 +183,7 @@ func (h *S3TablesHandler) handleGetNamespace(w http.ResponseWriter, r *http.Requ
// Get namespace and bucket policy
var metadata namespaceMetadata
var bucketPolicy string
var bucketTags map[string]string
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
data, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyMetadata)
if err != nil {
@@ -188,6 +200,10 @@ func (h *S3TablesHandler) handleGetNamespace(w http.ResponseWriter, r *http.Requ
} else if !errors.Is(err, ErrAttributeNotFound) {
return fmt.Errorf("failed to fetch bucket policy: %v", err)
}
bucketTags, err = h.readTags(r.Context(), client, bucketPath)
if err != nil {
return err
}
return nil
})
@@ -201,9 +217,15 @@ func (h *S3TablesHandler) handleGetNamespace(w http.ResponseWriter, r *http.Requ
return err
}
// Check permission
bucketARN := h.generateTableBucketARN(metadata.OwnerAccountID, bucketName)
principal := h.getAccountID(r)
if !CanGetNamespace(principal, metadata.OwnerAccountID, bucketPolicy) {
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("GetNamespace", principal, metadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespaceName,
TableBucketTags: bucketTags,
IdentityActions: identityActions,
}) {
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchNamespace, "namespace not found")
return ErrAccessDenied
}
@@ -247,6 +269,7 @@ func (h *S3TablesHandler) handleListNamespaces(w http.ResponseWriter, r *http.Re
// Check permission (check bucket ownership)
var bucketMetadata tableBucketMetadata
var bucketPolicy string
var bucketTags map[string]string
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
data, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata)
if err != nil {
@@ -263,6 +286,10 @@ func (h *S3TablesHandler) handleListNamespaces(w http.ResponseWriter, r *http.Re
} else if !errors.Is(err, ErrAttributeNotFound) {
return fmt.Errorf("failed to fetch bucket policy: %v", err)
}
bucketTags, err = h.readTags(r.Context(), client, bucketPath)
if err != nil {
return err
}
return nil
})
@@ -276,8 +303,14 @@ func (h *S3TablesHandler) handleListNamespaces(w http.ResponseWriter, r *http.Re
return err
}
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
principal := h.getAccountID(r)
if !CanListNamespaces(principal, bucketMetadata.OwnerAccountID, bucketPolicy) {
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("ListNamespaces", principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
TableBucketTags: bucketTags,
IdentityActions: identityActions,
}) {
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
return ErrAccessDenied
}
@@ -419,6 +452,7 @@ func (h *S3TablesHandler) handleDeleteNamespace(w http.ResponseWriter, r *http.R
// Check if namespace exists and get metadata for permission check
var metadata namespaceMetadata
var bucketPolicy string
var bucketTags map[string]string
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
data, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyMetadata)
if err != nil {
@@ -435,6 +469,10 @@ func (h *S3TablesHandler) handleDeleteNamespace(w http.ResponseWriter, r *http.R
} else if !errors.Is(err, ErrAttributeNotFound) {
return fmt.Errorf("failed to fetch bucket policy: %v", err)
}
bucketTags, err = h.readTags(r.Context(), client, bucketPath)
if err != nil {
return err
}
return nil
})
@@ -448,9 +486,15 @@ func (h *S3TablesHandler) handleDeleteNamespace(w http.ResponseWriter, r *http.R
return err
}
// Check permission
bucketARN := h.generateTableBucketARN(metadata.OwnerAccountID, bucketName)
principal := h.getAccountID(r)
if !CanDeleteNamespace(principal, metadata.OwnerAccountID, bucketPolicy) {
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("DeleteNamespace", principal, metadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespaceName,
TableBucketTags: bucketTags,
IdentityActions: identityActions,
}) {
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchNamespace, "namespace not found")
return ErrAccessDenied
}

View File

@@ -88,9 +88,13 @@ func (h *S3TablesHandler) handlePutTableBucketPolicy(w http.ResponseWriter, r *h
return err
}
// Check permission
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
principal := h.getAccountID(r)
if !CanPutTableBucketPolicy(principal, bucketMetadata.OwnerAccountID, "") {
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("PutTableBucketPolicy", principal, bucketMetadata.OwnerAccountID, "", bucketARN, &PolicyContext{
TableBucketName: bucketName,
IdentityActions: identityActions,
}) {
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to put table bucket policy")
return NewAuthError("PutTableBucketPolicy", principal, "not authorized to put table bucket policy")
}
@@ -161,9 +165,13 @@ func (h *S3TablesHandler) handleGetTableBucketPolicy(w http.ResponseWriter, r *h
return err
}
// Check permission
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
principal := h.getAccountID(r)
if !CanGetTableBucketPolicy(principal, bucketMetadata.OwnerAccountID, string(policy)) {
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("GetTableBucketPolicy", principal, bucketMetadata.OwnerAccountID, string(policy), bucketARN, &PolicyContext{
TableBucketName: bucketName,
IdentityActions: identityActions,
}) {
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to get table bucket policy")
return NewAuthError("GetTableBucketPolicy", principal, "not authorized to get table bucket policy")
}
@@ -232,9 +240,13 @@ func (h *S3TablesHandler) handleDeleteTableBucketPolicy(w http.ResponseWriter, r
return err
}
// Check permission
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
principal := h.getAccountID(r)
if !CanDeleteTableBucketPolicy(principal, bucketMetadata.OwnerAccountID, bucketPolicy) {
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("DeleteTableBucketPolicy", principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
IdentityActions: identityActions,
}) {
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to delete table bucket policy")
return NewAuthError("DeleteTableBucketPolicy", principal, "not authorized to delete table bucket policy")
}
@@ -326,9 +338,15 @@ func (h *S3TablesHandler) handlePutTablePolicy(w http.ResponseWriter, r *http.Re
return err
}
// Check permission
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespaceName+"/"+tableName)
principal := h.getAccountID(r)
if !CanPutTablePolicy(principal, metadata.OwnerAccountID, bucketPolicy) {
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("PutTablePolicy", principal, metadata.OwnerAccountID, bucketPolicy, tableARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
IdentityActions: identityActions,
}) {
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to put table policy")
return NewAuthError("PutTablePolicy", principal, "not authorized to put table policy")
}
@@ -427,9 +445,15 @@ func (h *S3TablesHandler) handleGetTablePolicy(w http.ResponseWriter, r *http.Re
return err
}
// Check permission
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespaceName+"/"+tableName)
principal := h.getAccountID(r)
if !CanGetTablePolicy(principal, metadata.OwnerAccountID, bucketPolicy) {
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("GetTablePolicy", principal, metadata.OwnerAccountID, bucketPolicy, tableARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
IdentityActions: identityActions,
}) {
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to get table policy")
return NewAuthError("GetTablePolicy", principal, "not authorized to get table policy")
}
@@ -510,9 +534,15 @@ func (h *S3TablesHandler) handleDeleteTablePolicy(w http.ResponseWriter, r *http
return err
}
// Check permission
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespaceName+"/"+tableName)
principal := h.getAccountID(r)
if !CanDeleteTablePolicy(principal, metadata.OwnerAccountID, bucketPolicy) {
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("DeleteTablePolicy", principal, metadata.OwnerAccountID, bucketPolicy, tableARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
IdentityActions: identityActions,
}) {
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to delete table policy")
return NewAuthError("DeleteTablePolicy", principal, "not authorized to delete table policy")
}
@@ -558,6 +588,8 @@ func (h *S3TablesHandler) handleTagResource(w http.ResponseWriter, r *http.Reque
// Read existing tags and merge, AND check permissions based on metadata ownership
existingTags := make(map[string]string)
var bucketPolicy string
var bucketTags map[string]string
requestTagKeys := mapKeys(req.Tags)
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
// Read metadata for ownership check
data, err := h.getExtendedAttribute(r.Context(), client, resourcePath, ExtendedKeyMetadata)
@@ -582,23 +614,36 @@ func (h *S3TablesHandler) handleTagResource(w http.ResponseWriter, r *http.Reque
} else {
bucketPolicy = string(policyData)
}
}
// Check Permission inside the closure because we just got the ID
principal := h.getAccountID(r)
if !CanManageTags(principal, ownerAccountID, bucketPolicy) {
return NewAuthError("TagResource", principal, "not authorized to tag resource")
bucketTags, err = h.readTags(r.Context(), client, bucketPath)
if err != nil {
return err
}
}
// Read existing tags
data, err = h.getExtendedAttribute(r.Context(), client, resourcePath, extendedKey)
if err != nil {
if errors.Is(err, ErrAttributeNotFound) {
return nil // No existing tags, which is fine.
if !errors.Is(err, ErrAttributeNotFound) {
return err
}
return err // Propagate other errors.
} else if err := json.Unmarshal(data, &existingTags); err != nil {
return err
}
return json.Unmarshal(data, &existingTags)
resourceARN := req.ResourceARN
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("TagResource", principal, ownerAccountID, bucketPolicy, resourceARN, &PolicyContext{
TableBucketName: bucketName,
TableBucketTags: bucketTags,
RequestTags: req.Tags,
TagKeys: requestTagKeys,
ResourceTags: existingTags,
IdentityActions: identityActions,
}) {
return NewAuthError("TagResource", principal, "not authorized to tag resource")
}
return nil
})
if err != nil {
@@ -662,6 +707,7 @@ func (h *S3TablesHandler) handleListTagsForResource(w http.ResponseWriter, r *ht
tags := make(map[string]string)
var bucketPolicy string
var bucketTags map[string]string
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
// Read metadata for ownership check
data, err := h.getExtendedAttribute(r.Context(), client, resourcePath, ExtendedKeyMetadata)
@@ -686,12 +732,10 @@ func (h *S3TablesHandler) handleListTagsForResource(w http.ResponseWriter, r *ht
} else {
bucketPolicy = string(policyData)
}
}
// Check Permission
principal := h.getAccountID(r)
if !CheckPermission("ListTagsForResource", principal, ownerAccountID, bucketPolicy) {
return NewAuthError("ListTagsForResource", principal, "not authorized to list tags for resource")
bucketTags, err = h.readTags(r.Context(), client, bucketPath)
if err != nil {
return err
}
}
data, err = h.getExtendedAttribute(r.Context(), client, resourcePath, extendedKey)
@@ -701,7 +745,22 @@ func (h *S3TablesHandler) handleListTagsForResource(w http.ResponseWriter, r *ht
}
return err // Propagate other errors.
}
return json.Unmarshal(data, &tags)
if err := json.Unmarshal(data, &tags); err != nil {
return err
}
resourceARN := req.ResourceARN
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("ListTagsForResource", principal, ownerAccountID, bucketPolicy, resourceARN, &PolicyContext{
TableBucketName: bucketName,
TableBucketTags: bucketTags,
ResourceTags: tags,
IdentityActions: identityActions,
}) {
return NewAuthError("ListTagsForResource", principal, "not authorized to list tags for resource")
}
return nil
})
if err != nil {
@@ -754,6 +813,7 @@ func (h *S3TablesHandler) handleUntagResource(w http.ResponseWriter, r *http.Req
// Read existing tags, check permission
tags := make(map[string]string)
var bucketPolicy string
var bucketTags map[string]string
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
// Read metadata for ownership check
data, err := h.getExtendedAttribute(r.Context(), client, resourcePath, ExtendedKeyMetadata)
@@ -778,12 +838,10 @@ func (h *S3TablesHandler) handleUntagResource(w http.ResponseWriter, r *http.Req
} else {
bucketPolicy = string(policyData)
}
}
// Check Permission
principal := h.getAccountID(r)
if !CanManageTags(principal, ownerAccountID, bucketPolicy) {
return NewAuthError("UntagResource", principal, "not authorized to untag resource")
bucketTags, err = h.readTags(r.Context(), client, bucketPath)
if err != nil {
return err
}
}
data, err = h.getExtendedAttribute(r.Context(), client, resourcePath, extendedKey)
@@ -793,7 +851,23 @@ func (h *S3TablesHandler) handleUntagResource(w http.ResponseWriter, r *http.Req
}
return err
}
return json.Unmarshal(data, &tags)
if err := json.Unmarshal(data, &tags); err != nil {
return err
}
resourceARN := req.ResourceARN
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("UntagResource", principal, ownerAccountID, bucketPolicy, resourceARN, &PolicyContext{
TableBucketName: bucketName,
TableBucketTags: bucketTags,
TagKeys: req.TagKeys,
ResourceTags: tags,
IdentityActions: identityActions,
}) {
return NewAuthError("UntagResource", principal, "not authorized to untag resource")
}
return nil
})
if err != nil {

View File

@@ -90,11 +90,13 @@ func (h *S3TablesHandler) handleCreateTable(w http.ResponseWriter, r *http.Reque
bucketPath := getTableBucketPath(bucketName)
namespacePolicy := ""
bucketPolicy := ""
bucketTags := map[string]string{}
var data []byte
var bucketMetadata tableBucketMetadata
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
// Fetch bucket metadata to use correct owner for bucket policy evaluation
data, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata)
data, err = h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata)
if err == nil {
if err := json.Unmarshal(data, &bucketMetadata); err != nil {
return fmt.Errorf("failed to unmarshal bucket metadata: %w", err)
@@ -118,6 +120,11 @@ func (h *S3TablesHandler) handleCreateTable(w http.ResponseWriter, r *http.Reque
} else if !errors.Is(err, ErrAttributeNotFound) {
return fmt.Errorf("failed to fetch bucket policy: %v", err)
}
if tags, err := h.readTags(r.Context(), client, bucketPath); err != nil {
return err
} else if tags != nil {
bucketTags = tags
}
return nil
})
@@ -127,11 +134,26 @@ func (h *S3TablesHandler) handleCreateTable(w http.ResponseWriter, r *http.Reque
return err
}
// Check authorization: namespace policy OR bucket policy OR ownership
// Use namespace owner for namespace policy (consistent with namespace authorization)
nsAllowed := CanCreateTable(accountID, namespaceMetadata.OwnerAccountID, namespacePolicy)
// Use bucket owner for bucket policy (bucket policy applies to bucket-level operations)
bucketAllowed := CanCreateTable(accountID, bucketMetadata.OwnerAccountID, bucketPolicy)
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
identityActions := getIdentityActions(r)
nsAllowed := CheckPermissionWithContext("CreateTable", accountID, namespaceMetadata.OwnerAccountID, namespacePolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
RequestTags: req.Tags,
TagKeys: mapKeys(req.Tags),
TableBucketTags: bucketTags,
IdentityActions: identityActions,
})
bucketAllowed := CheckPermissionWithContext("CreateTable", accountID, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
RequestTags: req.Tags,
TagKeys: mapKeys(req.Tags),
TableBucketTags: bucketTags,
IdentityActions: identityActions,
})
if !nsAllowed && !bucketAllowed {
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to create table in this namespace")
@@ -290,6 +312,8 @@ func (h *S3TablesHandler) handleGetTable(w http.ResponseWriter, r *http.Request,
bucketPath := getTableBucketPath(bucketName)
tablePolicy := ""
bucketPolicy := ""
bucketTags := map[string]string{}
tableTags := map[string]string{}
var bucketMetadata tableBucketMetadata
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
@@ -310,6 +334,11 @@ func (h *S3TablesHandler) handleGetTable(w http.ResponseWriter, r *http.Request,
} else if !errors.Is(err, ErrAttributeNotFound) {
return fmt.Errorf("failed to fetch table policy: %v", err)
}
if tags, err := h.readTags(r.Context(), client, tablePath); err != nil {
return err
} else if tags != nil {
tableTags = tags
}
// Fetch bucket policy if it exists
policyData, err = h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyPolicy)
@@ -318,6 +347,11 @@ func (h *S3TablesHandler) handleGetTable(w http.ResponseWriter, r *http.Request,
} else if !errors.Is(err, ErrAttributeNotFound) {
return fmt.Errorf("failed to fetch bucket policy: %v", err)
}
if tags, err := h.readTags(r.Context(), client, bucketPath); err != nil {
return err
} else if tags != nil {
bucketTags = tags
}
return nil
})
@@ -327,19 +361,31 @@ func (h *S3TablesHandler) handleGetTable(w http.ResponseWriter, r *http.Request,
return err
}
// Check authorization: table policy OR bucket policy OR ownership
// Use table owner for table policy (table-level access control)
tableAllowed := CanGetTable(accountID, metadata.OwnerAccountID, tablePolicy)
// Use bucket owner for bucket policy (bucket-level access control)
bucketAllowed := CanGetTable(accountID, bucketMetadata.OwnerAccountID, bucketPolicy)
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespace+"/"+tableName)
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
identityActions := getIdentityActions(r)
tableAllowed := CheckPermissionWithContext("GetTable", accountID, metadata.OwnerAccountID, tablePolicy, tableARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespace,
TableName: tableName,
TableBucketTags: bucketTags,
ResourceTags: tableTags,
IdentityActions: identityActions,
})
bucketAllowed := CheckPermissionWithContext("GetTable", accountID, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespace,
TableName: tableName,
TableBucketTags: bucketTags,
ResourceTags: tableTags,
IdentityActions: identityActions,
})
if !tableAllowed && !bucketAllowed {
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return ErrAccessDenied
}
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespace+"/"+tableName)
resp := &GetTableResponse{
Name: metadata.Name,
TableARN: tableARN,
@@ -412,6 +458,7 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
var nsMeta namespaceMetadata
var bucketMeta tableBucketMetadata
var namespacePolicy, bucketPolicy string
bucketTags := map[string]string{}
// Fetch namespace metadata and policy
data, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyMetadata)
@@ -446,10 +493,26 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
} else if !errors.Is(err, ErrAttributeNotFound) {
return fmt.Errorf("failed to fetch bucket policy: %v", err)
}
if tags, err := h.readTags(r.Context(), client, bucketPath); err != nil {
return err
} else if tags != nil {
bucketTags = tags
}
// Authorize listing: namespace policy OR bucket policy OR ownership
nsAllowed := CanListTables(accountID, nsMeta.OwnerAccountID, namespacePolicy)
bucketAllowed := CanListTables(accountID, bucketMeta.OwnerAccountID, bucketPolicy)
bucketARN := h.generateTableBucketARN(bucketMeta.OwnerAccountID, bucketName)
identityActions := getIdentityActions(r)
nsAllowed := CheckPermissionWithContext("ListTables", accountID, nsMeta.OwnerAccountID, namespacePolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespaceName,
TableBucketTags: bucketTags,
IdentityActions: identityActions,
})
bucketAllowed := CheckPermissionWithContext("ListTables", accountID, bucketMeta.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespaceName,
TableBucketTags: bucketTags,
IdentityActions: identityActions,
})
if !nsAllowed && !bucketAllowed {
return ErrAccessDenied
}
@@ -460,6 +523,7 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
bucketPath := getTableBucketPath(bucketName)
var bucketMeta tableBucketMetadata
var bucketPolicy string
bucketTags := map[string]string{}
// Fetch bucket metadata and policy
data, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata)
@@ -477,9 +541,19 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
} else if !errors.Is(err, ErrAttributeNotFound) {
return fmt.Errorf("failed to fetch bucket policy: %v", err)
}
if tags, err := h.readTags(r.Context(), client, bucketPath); err != nil {
return err
} else if tags != nil {
bucketTags = tags
}
// Authorize listing: bucket policy OR ownership
if !CanListTables(accountID, bucketMeta.OwnerAccountID, bucketPolicy) {
bucketARN := h.generateTableBucketARN(bucketMeta.OwnerAccountID, bucketName)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("ListTables", accountID, bucketMeta.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
TableBucketTags: bucketTags,
IdentityActions: identityActions,
}) {
return ErrAccessDenied
}
@@ -731,6 +805,10 @@ func (h *S3TablesHandler) handleDeleteTable(w http.ResponseWriter, r *http.Reque
// Check if table exists and enforce VersionToken if provided
var metadata tableMetadataInternal
var tablePolicy string
var bucketPolicy string
var bucketTags map[string]string
var tableTags map[string]string
var bucketMetadata tableBucketMetadata
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
data, err := h.getExtendedAttribute(r.Context(), client, tablePath, ExtendedKeyMetadata)
if err != nil {
@@ -759,6 +837,33 @@ func (h *S3TablesHandler) handleDeleteTable(w http.ResponseWriter, r *http.Reque
tablePolicy = string(policyData)
}
tableTags, err = h.readTags(r.Context(), client, tablePath)
if err != nil {
return err
}
bucketPath := getTableBucketPath(bucketName)
data, err = h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata)
if err == nil {
if err := json.Unmarshal(data, &bucketMetadata); err != nil {
return fmt.Errorf("failed to unmarshal bucket metadata: %w", err)
}
} else if !errors.Is(err, ErrAttributeNotFound) {
return fmt.Errorf("failed to fetch bucket metadata: %w", err)
}
policyData, err = h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyPolicy)
if err != nil {
if !errors.Is(err, ErrAttributeNotFound) {
return fmt.Errorf("failed to fetch bucket policy: %w", err)
}
} else {
bucketPolicy = string(policyData)
}
bucketTags, err = h.readTags(r.Context(), client, bucketPath)
if err != nil {
return err
}
return nil
})
@@ -773,9 +878,27 @@ func (h *S3TablesHandler) handleDeleteTable(w http.ResponseWriter, r *http.Reque
return err
}
// Check permission using table and bucket policies
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespaceName+"/"+tableName)
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
principal := h.getAccountID(r)
if !CanDeleteTable(principal, metadata.OwnerAccountID, tablePolicy) {
identityActions := getIdentityActions(r)
tableAllowed := CheckPermissionWithContext("DeleteTable", principal, metadata.OwnerAccountID, tablePolicy, tableARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
TableBucketTags: bucketTags,
ResourceTags: tableTags,
IdentityActions: identityActions,
})
bucketAllowed := CheckPermissionWithContext("DeleteTable", principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
TableBucketTags: bucketTags,
ResourceTags: tableTags,
IdentityActions: identityActions,
})
if !tableAllowed && !bucketAllowed {
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to delete table")
return NewAuthError("DeleteTable", principal, "not authorized to delete table")
}

View File

@@ -0,0 +1,98 @@
package s3tables
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
// Manager provides reusable S3 Tables operations for shell/admin without HTTP routing.
type Manager struct {
handler *S3TablesHandler
}
// NewManager creates a new Manager.
func NewManager() *Manager {
return &Manager{handler: NewS3TablesHandler()}
}
// SetRegion sets the AWS region for ARN generation.
func (m *Manager) SetRegion(region string) {
m.handler.SetRegion(region)
}
// SetAccountID sets the AWS account ID for ARN generation.
func (m *Manager) SetAccountID(accountID string) {
m.handler.SetAccountID(accountID)
}
// Execute runs an S3 Tables operation and decodes the response into resp (if provided).
func (m *Manager) Execute(ctx context.Context, filerClient FilerClient, operation string, req interface{}, resp interface{}, identity string) error {
body, err := json.Marshal(req)
if err != nil {
return err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "/", bytes.NewReader(body))
if err != nil {
return err
}
httpReq.Header.Set("Content-Type", "application/x-amz-json-1.1")
httpReq.Header.Set("X-Amz-Target", "S3Tables."+operation)
if identity != "" {
httpReq.Header.Set(s3_constants.AmzAccountId, identity)
httpReq = httpReq.WithContext(s3_constants.SetIdentityNameInContext(httpReq.Context(), identity))
}
recorder := httptest.NewRecorder()
m.handler.HandleRequest(recorder, httpReq, filerClient)
return decodeS3TablesHTTPResponse(recorder, resp)
}
func decodeS3TablesHTTPResponse(recorder *httptest.ResponseRecorder, resp interface{}) error {
result := recorder.Result()
defer result.Body.Close()
data, err := io.ReadAll(result.Body)
if err != nil {
return err
}
if result.StatusCode >= http.StatusBadRequest {
var errResp S3TablesError
if len(data) > 0 {
if jsonErr := json.Unmarshal(data, &errResp); jsonErr == nil && (errResp.Type != "" || errResp.Message != "") {
return &errResp
}
}
return &S3TablesError{Type: ErrCodeInternalError, Message: string(bytes.TrimSpace(data))}
}
if resp == nil || len(data) == 0 {
return nil
}
if err := json.Unmarshal(data, resp); err != nil {
return err
}
return nil
}
// ManagerClient adapts a SeaweedFilerClient to the FilerClient interface.
type ManagerClient struct {
client filer_pb.SeaweedFilerClient
}
// NewManagerClient wraps a filer client.
func NewManagerClient(client filer_pb.SeaweedFilerClient) *ManagerClient {
return &ManagerClient{client: client}
}
// WithFilerClient implements FilerClient.
func (m *ManagerClient) WithFilerClient(streamingMode bool, fn func(client filer_pb.SeaweedFilerClient) error) error {
if m.client == nil {
return errors.New("nil filer client")
}
return fn(m.client)
}

View File

@@ -6,6 +6,7 @@ import (
"strings"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
// Permission represents a specific action permission
@@ -65,24 +66,63 @@ func (pd *PolicyDocument) UnmarshalJSON(data []byte) error {
}
type Statement struct {
Effect string `json:"Effect"` // "Allow" or "Deny"
Principal interface{} `json:"Principal"` // Can be string, []string, or map
Action interface{} `json:"Action"` // Can be string or []string
Resource interface{} `json:"Resource"` // Can be string or []string
Effect string `json:"Effect"` // "Allow" or "Deny"
Principal interface{} `json:"Principal"` // Can be string, []string, or map
Action interface{} `json:"Action"` // Can be string or []string
Resource interface{} `json:"Resource"` // Can be string or []string
Condition map[string]map[string]interface{} `json:"Condition,omitempty"`
}
type PolicyContext struct {
Namespace string
TableName string
TableBucketName string
IdentityActions []string
RequestTags map[string]string
ResourceTags map[string]string
TableBucketTags map[string]string
TagKeys []string
SSEAlgorithm string
KMSKeyArn string
StorageClass string
}
// CheckPermissionWithResource checks if a principal has permission to perform an operation on a specific resource
func CheckPermissionWithResource(operation, principal, owner, resourcePolicy, resourceARN string) bool {
return CheckPermissionWithContext(operation, principal, owner, resourcePolicy, resourceARN, nil)
}
// CheckPermission checks if a principal has permission to perform an operation
// (without resource-specific validation - for backward compatibility)
func CheckPermission(operation, principal, owner, resourcePolicy string) bool {
return CheckPermissionWithContext(operation, principal, owner, resourcePolicy, "", nil)
}
// CheckPermissionWithContext checks permission with optional resource and condition context.
func CheckPermissionWithContext(operation, principal, owner, resourcePolicy, resourceARN string, ctx *PolicyContext) bool {
// Deny access if identities are empty
if principal == "" || owner == "" {
return false
}
// Admin always has permission.
if principal == s3_constants.AccountAdminId {
return true
}
return checkPermission(operation, principal, owner, resourcePolicy, resourceARN, ctx)
}
func checkPermission(operation, principal, owner, resourcePolicy, resourceARN string, ctx *PolicyContext) bool {
// Owner always has permission
if principal == owner {
return true
}
if hasIdentityPermission(operation, ctx) {
return true
}
// If no policy is provided, deny access (default deny)
if resourcePolicy == "" {
return false
@@ -121,6 +161,10 @@ func CheckPermissionWithResource(operation, principal, owner, resourcePolicy, re
continue
}
if !matchesConditions(stmt.Condition, ctx) {
continue
}
// Statement matches - check effect
if stmt.Effect == "Allow" {
hasAllow = true
@@ -133,62 +177,29 @@ func CheckPermissionWithResource(operation, principal, owner, resourcePolicy, re
return hasAllow
}
// CheckPermission checks if a principal has permission to perform an operation
// (without resource-specific validation - for backward compatibility)
func CheckPermission(operation, principal, owner, resourcePolicy string) bool {
// Deny access if identities are empty
if principal == "" || owner == "" {
func hasIdentityPermission(operation string, ctx *PolicyContext) bool {
if ctx == nil || len(ctx.IdentityActions) == 0 {
return false
}
// Owner always has permission
if principal == owner {
return true
}
// If no policy is provided, deny access (default deny)
if resourcePolicy == "" {
return false
}
// Normalize operation to full IAM-style action name (e.g., "s3tables:CreateTableBucket")
// if not already prefixed
fullAction := operation
if !strings.Contains(operation, ":") {
fullAction = "s3tables:" + operation
}
// Parse and evaluate policy
var policy PolicyDocument
if err := json.Unmarshal([]byte(resourcePolicy), &policy); err != nil {
return false
candidates := []string{operation, fullAction}
if ctx.TableBucketName != "" {
candidates = append(candidates, operation+":"+ctx.TableBucketName, fullAction+":"+ctx.TableBucketName)
}
// Evaluate policy statements
// Default is deny, so we need an explicit allow
hasAllow := false
for _, stmt := range policy.Statement {
// Check if principal matches
if !matchesPrincipal(stmt.Principal, principal) {
continue
}
// Check if action matches (using normalized full action name)
if !matchesAction(stmt.Action, fullAction) {
continue
}
// Statement matches - check effect
if stmt.Effect == "Allow" {
hasAllow = true
} else if stmt.Effect == "Deny" {
// Explicit deny always wins
return false
for _, action := range ctx.IdentityActions {
for _, candidate := range candidates {
if action == candidate {
return true
}
if strings.ContainsAny(action, "*?") && policy_engine.MatchesWildcard(action, candidate) {
return true
}
}
}
return hasAllow
return false
}
// matchesPrincipal checks if the principal matches the statement's principal
@@ -271,6 +282,74 @@ func matchesActionPattern(pattern, action string) bool {
return policy_engine.MatchesWildcard(pattern, action)
}
func matchesConditions(conditions map[string]map[string]interface{}, ctx *PolicyContext) bool {
if len(conditions) == 0 {
return true
}
if ctx == nil {
return false
}
for operator, conditionValues := range conditions {
if !matchesConditionOperator(operator, conditionValues, ctx) {
return false
}
}
return true
}
func matchesConditionOperator(operator string, conditionValues map[string]interface{}, ctx *PolicyContext) bool {
evaluator, err := policy_engine.GetConditionEvaluator(operator)
if err != nil {
return false
}
for key, value := range conditionValues {
contextVals := getConditionContextValues(key, ctx)
if !evaluator.Evaluate(value, contextVals) {
return false
}
}
return true
}
func getConditionContextValues(key string, ctx *PolicyContext) []string {
switch key {
case "s3tables:namespace":
return []string{ctx.Namespace}
case "s3tables:tableName":
return []string{ctx.TableName}
case "s3tables:tableBucketName":
return []string{ctx.TableBucketName}
case "s3tables:SSEAlgorithm":
return []string{ctx.SSEAlgorithm}
case "s3tables:KMSKeyArn":
return []string{ctx.KMSKeyArn}
case "s3tables:StorageClass":
return []string{ctx.StorageClass}
case "aws:TagKeys":
return ctx.TagKeys
}
if strings.HasPrefix(key, "aws:RequestTag/") {
tagKey := strings.TrimPrefix(key, "aws:RequestTag/")
if val, ok := ctx.RequestTags[tagKey]; ok {
return []string{val}
}
}
if strings.HasPrefix(key, "aws:ResourceTag/") {
tagKey := strings.TrimPrefix(key, "aws:ResourceTag/")
if val, ok := ctx.ResourceTags[tagKey]; ok {
return []string{val}
}
}
if strings.HasPrefix(key, "s3tables:TableBucketTag/") {
tagKey := strings.TrimPrefix(key, "s3tables:TableBucketTag/")
if val, ok := ctx.TableBucketTags[tagKey]; ok {
return []string{val}
}
}
return nil
}
// matchesResource checks if the resource ARN matches the statement's resource specification
// Returns true if resource matches or if Resource is not specified (implicit match)
func matchesResource(resourceSpec interface{}, resourceARN string) bool {

View File

@@ -1,6 +1,9 @@
package s3tables
import "testing"
import (
"encoding/json"
"testing"
)
func TestMatchesActionPattern(t *testing.T) {
tests := []struct {
@@ -88,3 +91,118 @@ func TestMatchesPrincipal(t *testing.T) {
})
}
}
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)
}
})
}
}

View File

@@ -20,6 +20,7 @@ const (
var (
bucketARNPattern = regexp.MustCompile(`^arn:aws:s3tables:[^:]*:[^:]*:bucket/(` + bucketNamePatternStr + `)$`)
tableARNPattern = regexp.MustCompile(`^arn:aws:s3tables:[^:]*:[^:]*:bucket/(` + bucketNamePatternStr + `)/table/(` + tableNamespacePatternStr + `)/(` + tableNamePatternStr + `)$`)
tagPattern = regexp.MustCompile(`^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$`)
)
// ARN parsing functions
@@ -175,6 +176,80 @@ func validateBucketName(name string) error {
return nil
}
// ValidateBucketName validates bucket name and returns an error if invalid.
func ValidateBucketName(name string) error {
return validateBucketName(name)
}
// BuildBucketARN builds a bucket ARN with the provided region and account ID.
// If region is empty, the ARN will omit the region field.
func BuildBucketARN(region, accountID, bucketName string) (string, error) {
if bucketName == "" {
return "", fmt.Errorf("bucket name is required")
}
if err := validateBucketName(bucketName); err != nil {
return "", err
}
if accountID == "" {
accountID = DefaultAccountID
}
return buildARN(region, accountID, fmt.Sprintf("bucket/%s", bucketName)), nil
}
// BuildTableARN builds a table ARN with the provided region and account ID.
func BuildTableARN(region, accountID, bucketName, namespace, tableName string) (string, error) {
if bucketName == "" {
return "", fmt.Errorf("bucket name is required")
}
if err := validateBucketName(bucketName); err != nil {
return "", err
}
if namespace == "" {
return "", fmt.Errorf("namespace is required")
}
normalizedNamespace, err := validateNamespace([]string{namespace})
if err != nil {
return "", err
}
if tableName == "" {
return "", fmt.Errorf("table name is required")
}
normalizedTable, err := validateTableName(tableName)
if err != nil {
return "", err
}
if accountID == "" {
accountID = DefaultAccountID
}
return buildARN(region, accountID, fmt.Sprintf("bucket/%s/table/%s/%s", bucketName, normalizedNamespace, normalizedTable)), nil
}
func buildARN(region, accountID, resourcePath string) string {
return fmt.Sprintf("arn:aws:s3tables:%s:%s:%s", region, accountID, resourcePath)
}
// ValidateTags validates tags for S3 Tables.
func ValidateTags(tags map[string]string) error {
if len(tags) > 10 {
return fmt.Errorf("validate tags: %d tags more than 10", len(tags))
}
for k, v := range tags {
if len(k) > 128 {
return fmt.Errorf("validate tags: tag key longer than 128")
}
if !tagPattern.MatchString(k) {
return fmt.Errorf("validate tags key %s error, incorrect key", k)
}
if len(v) > 256 {
return fmt.Errorf("validate tags: tag value longer than 256")
}
if !tagPattern.MatchString(v) {
return fmt.Errorf("validate tags value %s error, incorrect value", v)
}
}
return nil
}
// isValidBucketName validates bucket name characters (kept for compatibility)
// Deprecated: use validateBucketName instead
func isValidBucketName(name string) bool {

View File

@@ -4,10 +4,10 @@ import (
"encoding/xml"
"fmt"
"net/url"
"regexp"
"sort"
"strings"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
"github.com/seaweedfs/seaweedfs/weed/util"
)
@@ -78,25 +78,5 @@ func parseTagsHeader(tags string) (map[string]string, error) {
}
func ValidateTags(tags map[string]string) error {
if len(tags) > 10 {
return fmt.Errorf("validate tags: %d tags more than 10", len(tags))
}
for k, v := range tags {
if len(k) > 128 {
return fmt.Errorf("validate tags: tag key longer than 128")
}
validateKey, err := regexp.MatchString(`^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$`, k)
if !validateKey || err != nil {
return fmt.Errorf("validate tags key %s error, incorrect key", k)
}
if len(v) > 256 {
return fmt.Errorf("validate tags: tag value longer than 256")
}
validateValue, err := regexp.MatchString(`^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$`, v)
if !validateValue || err != nil {
return fmt.Errorf("validate tags value %s error, incorrect value", v)
}
}
return nil
return s3tables.ValidateTags(tags)
}