Files
seaweedfs/weed/admin/dash/s3tables_management.go
T
Chris Lu 5eead9409a fix(admin): S3 Tables CSRF token + non-empty 409 status (#9221)
* fix(admin): attach CSRF token to S3 Tables write requests

Several POST/PUT/DELETE calls in s3tables.js were sent without an
X-CSRF-Token header while the corresponding handlers in
weed/admin/dash/s3tables_management.go enforce CSRF via
requireSessionCSRFToken, so authenticated users hit "invalid CSRF token"
on actions like creating a table bucket (#9220), updating policies, and
managing tags.

Add an s3tWriteHeaders helper that pulls the token from the existing
csrf-token meta tag and use it on every write to /api/s3tables/buckets,
/bucket-policy, /tables, /table-policy, and /tags. The Iceberg-page
write paths already attached the token and are unchanged.

Fixes #9220

* fix(admin): map BucketNotEmpty/NamespaceNotEmpty to 409 for S3 Tables

DELETE on a non-empty table bucket or namespace returned HTTP 500
because s3TablesErrorStatus didn't list ErrCodeBucketNotEmpty or
ErrCodeNamespaceNotEmpty in its conflict case, even though the
backend handler emits them with 409 Conflict (matching AWS S3 Tables).
Add both codes to the existing conflict mapping.

* refactor(admin): route Iceberg S3 Tables writes through s3tWriteHeaders

Iceberg namespace/table create and Iceberg table delete were still
hand-rolling CSRF headers. Replace those blocks with the existing
s3tWriteHeaders() helper so every S3 Tables write uses the same code
path. Drop the now-unused csrfTokenInput.value population in
initIcebergNamespaces and initIcebergTables (the templ hidden inputs
have no server-rendered value, and nothing reads the input now that
the JS reads the token from the meta tag via getCSRFToken()).
2026-04-24 22:48:41 -07:00

1070 lines
34 KiB
Go

package dash
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
// S3Tables data structures for admin UI
type S3TablesBucketsData struct {
Username string `json:"username"`
Buckets []S3TablesBucketSummary `json:"buckets"`
TotalBuckets int `json:"total_buckets"`
IcebergPort int `json:"iceberg_port"`
LastUpdated time.Time `json:"last_updated"`
}
type S3TablesBucketSummary struct {
ARN string `json:"arn"`
Name string `json:"name"`
OwnerAccountID string `json:"ownerAccountId"`
CreatedAt time.Time `json:"createdAt"`
}
type S3TablesNamespacesData struct {
Username string `json:"username"`
BucketARN string `json:"bucket_arn"`
Namespaces []s3tables.NamespaceSummary `json:"namespaces"`
TotalNamespaces int `json:"total_namespaces"`
LastUpdated time.Time `json:"last_updated"`
}
type S3TablesTablesData struct {
Username string `json:"username"`
BucketARN string `json:"bucket_arn"`
Namespace string `json:"namespace"`
Tables []s3tables.TableSummary `json:"tables"`
TotalTables int `json:"total_tables"`
LastUpdated time.Time `json:"last_updated"`
}
type tableBucketMetadata struct {
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"`
OwnerAccountID string `json:"ownerAccountId"`
}
// S3Tables manager helpers
const s3TablesAdminListLimit = 1000
func parseNamespaceInput(namespace string) ([]string, error) {
return s3tables.ParseNamespace(namespace)
}
func (s *AdminServer) parseNamespaceFromRequest(w http.ResponseWriter, namespace string) ([]string, bool) {
parts, err := parseNamespaceInput(namespace)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid namespace: "+err.Error())
return nil, false
}
return parts, true
}
func newS3TablesManager() *s3tables.Manager {
manager := s3tables.NewManager()
manager.SetAccountID(s3_constants.AccountAdminId)
return manager
}
func (s *AdminServer) executeS3TablesOperation(ctx context.Context, operation string, req interface{}, resp interface{}) error {
return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.s3TablesManager.Execute(ctx, mgrClient, operation, req, resp, s3_constants.AccountAdminId)
})
}
// S3Tables data retrieval for pages
func (s *AdminServer) GetS3TablesBucketsData(ctx context.Context) (S3TablesBucketsData, error) {
var buckets []S3TablesBucketSummary
err := s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
resp, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{
Directory: s3tables.TablesPath,
Limit: uint32(s3TablesAdminListLimit * 2),
InclusiveStartFrom: true,
})
if err != nil {
return err
}
for len(buckets) < s3TablesAdminListLimit {
entry, recvErr := resp.Recv()
if recvErr != nil {
if recvErr == io.EOF {
break
}
return recvErr
}
if entry.Entry == nil || !entry.Entry.IsDirectory {
continue
}
if strings.HasPrefix(entry.Entry.Name, ".") {
continue
}
if !s3tables.IsTableBucketEntry(entry.Entry) {
continue
}
metaBytes, ok := entry.Entry.Extended[s3tables.ExtendedKeyMetadata]
if !ok {
continue
}
var metadata tableBucketMetadata
if err := json.Unmarshal(metaBytes, &metadata); err != nil {
glog.V(1).Infof("S3Tables: failed to decode table bucket metadata for %s: %v", entry.Entry.Name, err)
continue
}
arn, err := s3tables.BuildBucketARN(s3tables.DefaultRegion, metadata.OwnerAccountID, entry.Entry.Name)
if err != nil {
glog.V(1).Infof("S3Tables: failed to build table bucket ARN for %s: %v", entry.Entry.Name, err)
continue
}
buckets = append(buckets, S3TablesBucketSummary{
ARN: arn,
Name: entry.Entry.Name,
OwnerAccountID: metadata.OwnerAccountID,
CreatedAt: metadata.CreatedAt,
})
}
return nil
})
if err != nil {
return S3TablesBucketsData{}, err
}
return S3TablesBucketsData{
Buckets: buckets,
TotalBuckets: len(buckets),
IcebergPort: s.icebergPort,
LastUpdated: time.Now(),
}, nil
}
func (s *AdminServer) GetS3TablesNamespacesData(ctx context.Context, bucketArn string) (S3TablesNamespacesData, error) {
var resp s3tables.ListNamespacesResponse
req := &s3tables.ListNamespacesRequest{TableBucketARN: bucketArn, MaxNamespaces: s3TablesAdminListLimit}
if err := s.executeS3TablesOperation(ctx, "ListNamespaces", req, &resp); err != nil {
return S3TablesNamespacesData{}, err
}
return S3TablesNamespacesData{
BucketARN: bucketArn,
Namespaces: resp.Namespaces,
TotalNamespaces: len(resp.Namespaces),
LastUpdated: time.Now(),
}, nil
}
func (s *AdminServer) GetS3TablesTablesData(ctx context.Context, bucketArn, namespace string) (S3TablesTablesData, error) {
var resp s3tables.ListTablesResponse
var ns []string
if namespace != "" {
parts, err := parseNamespaceInput(namespace)
if err != nil {
return S3TablesTablesData{}, err
}
ns = parts
}
req := &s3tables.ListTablesRequest{TableBucketARN: bucketArn, Namespace: ns, MaxTables: s3TablesAdminListLimit}
if err := s.executeS3TablesOperation(ctx, "ListTables", req, &resp); err != nil {
return S3TablesTablesData{}, err
}
return S3TablesTablesData{
BucketARN: bucketArn,
Namespace: namespace,
Tables: resp.Tables,
TotalTables: len(resp.Tables),
LastUpdated: time.Now(),
}, nil
}
// Iceberg Catalog data providers
// GetIcebergCatalogData returns the Iceberg catalog overview data.
// Each S3 Table Bucket is exposed as an Iceberg catalog.
func (s *AdminServer) GetIcebergCatalogData(ctx context.Context) (IcebergCatalogData, error) {
bucketsData, err := s.GetS3TablesBucketsData(ctx)
if err != nil {
return IcebergCatalogData{}, err
}
catalogs := make([]IcebergCatalogInfo, 0, len(bucketsData.Buckets))
for _, bucket := range bucketsData.Buckets {
catalogs = append(catalogs, IcebergCatalogInfo{
Name: bucket.Name,
ARN: bucket.ARN,
OwnerAccountID: bucket.OwnerAccountID,
CreatedAt: bucket.CreatedAt,
})
}
return IcebergCatalogData{
Catalogs: catalogs,
TotalCatalogs: len(catalogs),
IcebergPort: s.icebergPort, // Use the port passed to AdminServer
LastUpdated: time.Now(),
}, nil
}
// GetIcebergNamespacesData returns namespaces for an Iceberg catalog.
func (s *AdminServer) GetIcebergNamespacesData(ctx context.Context, catalogName, bucketArn string) (IcebergNamespacesData, error) {
nsData, err := s.GetS3TablesNamespacesData(ctx, bucketArn)
if err != nil {
return IcebergNamespacesData{}, err
}
namespaces := make([]IcebergNamespaceInfo, 0, len(nsData.Namespaces))
for _, ns := range nsData.Namespaces {
name := ""
if len(ns.Namespace) > 0 {
name = strings.Join(ns.Namespace, ".")
}
namespaces = append(namespaces, IcebergNamespaceInfo{
Name: name,
CreatedAt: ns.CreatedAt,
})
}
return IcebergNamespacesData{
CatalogName: catalogName,
BucketARN: bucketArn,
Namespaces: namespaces,
TotalNamespaces: len(namespaces),
LastUpdated: time.Now(),
}, nil
}
// GetIcebergTablesData returns tables for an Iceberg namespace.
func (s *AdminServer) GetIcebergTablesData(ctx context.Context, catalogName, bucketArn, namespace string) (IcebergTablesData, error) {
tablesData, err := s.GetS3TablesTablesData(ctx, bucketArn, namespace)
if err != nil {
return IcebergTablesData{}, err
}
tables := make([]IcebergTableInfo, 0, len(tablesData.Tables))
for _, t := range tablesData.Tables {
tables = append(tables, IcebergTableInfo{
Name: t.Name,
CreatedAt: t.CreatedAt,
})
}
return IcebergTablesData{
CatalogName: catalogName,
NamespaceName: namespace,
BucketARN: bucketArn,
Tables: tables,
TotalTables: len(tables),
LastUpdated: time.Now(),
}, nil
}
// GetIcebergTableDetailsData returns Iceberg table metadata and snapshot information.
func (s *AdminServer) GetIcebergTableDetailsData(ctx context.Context, catalogName, bucketArn, namespace, tableName string) (IcebergTableDetailsData, error) {
var resp s3tables.GetTableResponse
namespaceParts, err := parseNamespaceInput(namespace)
if err != nil {
return IcebergTableDetailsData{}, err
}
req := &s3tables.GetTableRequest{
TableBucketARN: bucketArn,
Namespace: namespaceParts,
Name: tableName,
}
if err := s.executeS3TablesOperation(ctx, "GetTable", req, &resp); err != nil {
return IcebergTableDetailsData{}, err
}
details := IcebergTableDetailsData{
CatalogName: catalogName,
NamespaceName: namespace,
TableName: resp.Name,
BucketARN: bucketArn,
TableARN: resp.TableARN,
Format: resp.Format,
CreatedAt: resp.CreatedAt,
ModifiedAt: resp.ModifiedAt,
MetadataLocation: resp.MetadataLocation,
}
applyIcebergMetadata(resp.Metadata, &details)
return details, nil
}
type icebergFullMetadata struct {
FormatVersion int `json:"format-version"`
TableUUID string `json:"table-uuid"`
Location string `json:"location"`
LastUpdatedMs int64 `json:"last-updated-ms"`
Schemas []icebergSchema `json:"schemas"`
Schema *icebergSchema `json:"schema"`
CurrentSchemaID int `json:"current-schema-id"`
PartitionSpecs []icebergPartitionSpec `json:"partition-specs"`
PartitionSpec *icebergPartitionSpec `json:"partition-spec"`
DefaultSpecID int `json:"default-spec-id"`
Properties map[string]string `json:"properties"`
Snapshots []icebergSnapshot `json:"snapshots"`
CurrentSnapshotID int64 `json:"current-snapshot-id"`
}
type icebergSchema struct {
SchemaID int `json:"schema-id"`
Fields []icebergSchemaField `json:"fields"`
}
type icebergSchemaField struct {
ID int `json:"id"`
Name string `json:"name"`
Type json.RawMessage `json:"type"`
Required bool `json:"required"`
}
type icebergPartitionSpec struct {
SpecID int `json:"spec-id"`
Fields []icebergPartitionField `json:"fields"`
}
type icebergPartitionField struct {
SourceID int `json:"source-id"`
FieldID int `json:"field-id"`
Name string `json:"name"`
Transform string `json:"transform"`
}
type icebergSnapshot struct {
SnapshotID int64 `json:"snapshot-id"`
TimestampMs int64 `json:"timestamp-ms"`
ManifestList string `json:"manifest-list"`
Summary map[string]string `json:"summary"`
}
func applyIcebergMetadata(metadata *s3tables.TableMetadata, details *IcebergTableDetailsData) {
if details == nil || metadata == nil {
return
}
if len(metadata.FullMetadata) == 0 {
details.SchemaFields = schemaFieldsFromIceberg(metadata.Iceberg)
return
}
var full icebergFullMetadata
if err := json.Unmarshal(metadata.FullMetadata, &full); err != nil {
glog.V(1).Infof("iceberg metadata parse failed: %v", err)
details.MetadataError = fmt.Sprintf("Failed to parse Iceberg metadata: %v", err)
details.SchemaFields = schemaFieldsFromIceberg(metadata.Iceberg)
return
}
details.TableLocation = full.Location
details.SchemaFields = schemaFieldsFromFullMetadata(full, metadata.Iceberg)
details.PartitionFields = partitionFieldsFromFullMetadata(full)
details.Properties = propertiesFromFullMetadata(full.Properties)
details.Snapshots = snapshotsFromFullMetadata(full.Snapshots)
details.SnapshotCount = len(full.Snapshots)
details.HasSnapshotCount = true
if metricsSnapshot := selectSnapshotForMetrics(full); metricsSnapshot != nil {
if value, ok := parseSummaryInt(metricsSnapshot.Summary, "total-data-files", "total-data-file-count", "total-files", "total-file-count"); ok {
details.DataFileCount = value
details.HasDataFileCount = true
}
if value, ok := parseSummaryInt(metricsSnapshot.Summary, "total-files-size", "total-data-files-size", "total-file-size", "total-data-file-size", "total-data-size", "total-size"); ok {
details.TotalSizeBytes = value
details.HasTotalSize = true
}
}
}
func typeToString(t json.RawMessage) json.RawMessage {
if t == nil || len(t) == 0 {
return json.RawMessage(`(complex)`)
}
var primitive string
if err := json.Unmarshal(t, &primitive); err == nil {
return json.RawMessage(primitive)
}
var v interface{}
if err := json.Unmarshal(t, &v); err != nil {
return json.RawMessage(`(complex)`)
}
return json.RawMessage(`(complex)`)
}
func schemaFieldsFromFullMetadata(full icebergFullMetadata, fallback *s3tables.IcebergMetadata) []IcebergSchemaFieldInfo {
if schema := selectSchema(full); schema != nil {
fields := make([]IcebergSchemaFieldInfo, 0, len(schema.Fields))
for _, field := range schema.Fields {
fields = append(fields, IcebergSchemaFieldInfo{
ID: field.ID,
Name: field.Name,
Type: typeToString(field.Type),
Required: field.Required,
})
}
return fields
}
return schemaFieldsFromIceberg(fallback)
}
func schemaFieldsFromIceberg(metadata *s3tables.IcebergMetadata) []IcebergSchemaFieldInfo {
if metadata == nil {
return nil
}
fields := make([]IcebergSchemaFieldInfo, 0, len(metadata.Schema.Fields))
for _, field := range metadata.Schema.Fields {
typeBytes, err := json.Marshal(field.Type)
if err != nil {
typeBytes = json.RawMessage(`(complex)`)
} else {
typeBytes = typeToString(typeBytes)
}
fields = append(fields, IcebergSchemaFieldInfo{
Name: field.Name,
Type: typeBytes,
Required: field.Required,
})
}
return fields
}
func selectSchema(full icebergFullMetadata) *icebergSchema {
if len(full.Schemas) == 0 && full.Schema == nil {
return nil
}
if len(full.Schemas) == 0 {
return full.Schema
}
for i := range full.Schemas {
if full.Schemas[i].SchemaID == full.CurrentSchemaID {
return &full.Schemas[i]
}
}
return &full.Schemas[0]
}
func partitionFieldsFromFullMetadata(full icebergFullMetadata) []IcebergPartitionFieldInfo {
var spec *icebergPartitionSpec
if len(full.PartitionSpecs) == 0 && full.PartitionSpec == nil {
return nil
}
if len(full.PartitionSpecs) == 0 {
spec = full.PartitionSpec
} else {
for i := range full.PartitionSpecs {
if full.PartitionSpecs[i].SpecID == full.DefaultSpecID {
spec = &full.PartitionSpecs[i]
break
}
}
if spec == nil {
spec = &full.PartitionSpecs[0]
}
}
if spec == nil {
return nil
}
fields := make([]IcebergPartitionFieldInfo, 0, len(spec.Fields))
for _, field := range spec.Fields {
fields = append(fields, IcebergPartitionFieldInfo{
Name: field.Name,
Transform: field.Transform,
SourceID: field.SourceID,
FieldID: field.FieldID,
})
}
return fields
}
func propertiesFromFullMetadata(properties map[string]string) []IcebergPropertyInfo {
if len(properties) == 0 {
return nil
}
keys := make([]string, 0, len(properties))
for key := range properties {
keys = append(keys, key)
}
sort.Strings(keys)
entries := make([]IcebergPropertyInfo, 0, len(keys))
for _, key := range keys {
entries = append(entries, IcebergPropertyInfo{Key: key, Value: properties[key]})
}
return entries
}
func snapshotsFromFullMetadata(snapshots []icebergSnapshot) []IcebergSnapshotInfo {
if len(snapshots) == 0 {
return nil
}
sorted := make([]icebergSnapshot, len(snapshots))
copy(sorted, snapshots)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].TimestampMs > sorted[j].TimestampMs
})
info := make([]IcebergSnapshotInfo, 0, len(sorted))
for _, snapshot := range sorted {
operation := ""
if snapshot.Summary != nil {
operation = snapshot.Summary["operation"]
}
timestamp := time.Time{}
if snapshot.TimestampMs > 0 {
timestamp = time.Unix(0, snapshot.TimestampMs*int64(time.Millisecond))
}
info = append(info, IcebergSnapshotInfo{
SnapshotID: snapshot.SnapshotID,
Timestamp: timestamp,
Operation: operation,
ManifestList: snapshot.ManifestList,
})
}
return info
}
func selectSnapshotForMetrics(full icebergFullMetadata) *icebergSnapshot {
if len(full.Snapshots) == 0 {
return nil
}
for i := range full.Snapshots {
if full.Snapshots[i].SnapshotID == full.CurrentSnapshotID {
return &full.Snapshots[i]
}
}
latestIdx := 0
for i := 1; i < len(full.Snapshots); i++ {
if full.Snapshots[i].TimestampMs > full.Snapshots[latestIdx].TimestampMs {
latestIdx = i
}
}
return &full.Snapshots[latestIdx]
}
func parseSummaryInt(summary map[string]string, keys ...string) (int64, bool) {
if len(summary) == 0 {
return 0, false
}
for _, key := range keys {
value, ok := summary[key]
if !ok || value == "" {
continue
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
continue
}
return parsed, true
}
return 0, false
}
// API handlers
func (s *AdminServer) ListS3TablesBucketsAPI(w http.ResponseWriter, r *http.Request) {
data, err := s.GetS3TablesBucketsData(r.Context())
if err != nil {
writeS3TablesError(w, err)
return
}
writeJSON(w, http.StatusOK, data)
}
func (s *AdminServer) CreateS3TablesBucket(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
var req struct {
Name string `json:"name"`
Tags map[string]string `json:"tags"`
Owner string `json:"owner"`
}
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if req.Name == "" {
writeJSONError(w, http.StatusBadRequest, "Bucket name is required")
return
}
owner := strings.TrimSpace(req.Owner)
if len(owner) > MaxOwnerNameLength {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength))
return
}
if len(req.Tags) > 0 {
if err := s3tables.ValidateTags(req.Tags); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid tags: "+err.Error())
return
}
}
createReq := &s3tables.CreateTableBucketRequest{Name: req.Name, Tags: req.Tags}
var resp s3tables.CreateTableBucketResponse
if err := s.executeS3TablesOperation(r.Context(), "CreateTableBucket", createReq, &resp); err != nil {
writeS3TablesError(w, err)
return
}
if owner != "" {
if err := s.SetTableBucketOwner(r.Context(), req.Name, owner); err != nil {
deleteReq := &s3tables.DeleteTableBucketRequest{TableBucketARN: resp.ARN}
if deleteErr := s.executeS3TablesOperation(r.Context(), "DeleteTableBucket", deleteReq, nil); deleteErr != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to set table bucket owner: %v; rollback delete failed: %v", err, deleteErr))
return
}
writeS3TablesError(w, err)
return
}
}
writeJSON(w, http.StatusCreated, map[string]interface{}{"arn": resp.ARN})
}
func (s *AdminServer) SetTableBucketOwner(ctx context.Context, bucketName, owner string) error {
return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
Directory: s3tables.TablesPath,
Name: bucketName,
})
if err != nil {
return fmt.Errorf("lookup table bucket %s: %w", bucketName, err)
}
if resp.Entry == nil {
return fmt.Errorf("table bucket %s not found", bucketName)
}
entry := resp.Entry
if entry.Extended == nil {
return fmt.Errorf("table bucket %s metadata missing", bucketName)
}
metaBytes, ok := entry.Extended[s3tables.ExtendedKeyMetadata]
if !ok {
return fmt.Errorf("table bucket %s metadata missing", bucketName)
}
var metadata tableBucketMetadata
if err := json.Unmarshal(metaBytes, &metadata); err != nil {
return fmt.Errorf("failed to parse table bucket metadata: %w", err)
}
metadata.OwnerAccountID = owner
updated, err := json.Marshal(&metadata)
if err != nil {
return fmt.Errorf("failed to marshal table bucket metadata: %w", err)
}
entry.Extended[s3tables.ExtendedKeyMetadata] = updated
if _, err := client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
Directory: s3tables.TablesPath,
Entry: entry,
}); err != nil {
return fmt.Errorf("failed to update table bucket owner: %w", err)
}
return nil
})
}
func (s *AdminServer) DeleteS3TablesBucket(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
bucketArn := r.URL.Query().Get("bucket")
if bucketArn == "" {
writeJSONError(w, http.StatusBadRequest, "Bucket ARN is required")
return
}
req := &s3tables.DeleteTableBucketRequest{TableBucketARN: bucketArn}
if err := s.executeS3TablesOperation(r.Context(), "DeleteTableBucket", req, nil); err != nil {
writeS3TablesError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Bucket deleted"})
}
func (s *AdminServer) ListS3TablesNamespacesAPI(w http.ResponseWriter, r *http.Request) {
bucketArn := r.URL.Query().Get("bucket")
if bucketArn == "" {
writeJSONError(w, http.StatusBadRequest, "bucket query parameter is required")
return
}
data, err := s.GetS3TablesNamespacesData(r.Context(), bucketArn)
if err != nil {
writeS3TablesError(w, err)
return
}
writeJSON(w, http.StatusOK, data)
}
func (s *AdminServer) CreateS3TablesNamespace(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
var req struct {
BucketARN string `json:"bucket_arn"`
Name string `json:"name"`
}
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if req.BucketARN == "" || req.Name == "" {
writeJSONError(w, http.StatusBadRequest, "bucket_arn and name are required")
return
}
namespaceParts, ok := s.parseNamespaceFromRequest(w, req.Name)
if !ok {
return
}
createReq := &s3tables.CreateNamespaceRequest{TableBucketARN: req.BucketARN, Namespace: namespaceParts}
var resp s3tables.CreateNamespaceResponse
if err := s.executeS3TablesOperation(r.Context(), "CreateNamespace", createReq, &resp); err != nil {
writeS3TablesError(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{"namespace": resp.Namespace})
}
func (s *AdminServer) DeleteS3TablesNamespace(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
bucketArn := r.URL.Query().Get("bucket")
namespace := r.URL.Query().Get("name")
if bucketArn == "" || namespace == "" {
writeJSONError(w, http.StatusBadRequest, "bucket and name query parameters are required")
return
}
namespaceParts, ok := s.parseNamespaceFromRequest(w, namespace)
if !ok {
return
}
req := &s3tables.DeleteNamespaceRequest{TableBucketARN: bucketArn, Namespace: namespaceParts}
if err := s.executeS3TablesOperation(r.Context(), "DeleteNamespace", req, nil); err != nil {
writeS3TablesError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Namespace deleted"})
}
func (s *AdminServer) ListS3TablesTablesAPI(w http.ResponseWriter, r *http.Request) {
bucketArn := r.URL.Query().Get("bucket")
if bucketArn == "" {
writeJSONError(w, http.StatusBadRequest, "bucket query parameter is required")
return
}
namespace := r.URL.Query().Get("namespace")
data, err := s.GetS3TablesTablesData(r.Context(), bucketArn, namespace)
if err != nil {
writeS3TablesError(w, err)
return
}
writeJSON(w, http.StatusOK, data)
}
func (s *AdminServer) CreateS3TablesTable(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
var req struct {
BucketARN string `json:"bucket_arn"`
Namespace string `json:"namespace"`
Name string `json:"name"`
Format string `json:"format"`
Tags map[string]string `json:"tags"`
Metadata *s3tables.TableMetadata `json:"metadata"`
}
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if req.BucketARN == "" || req.Namespace == "" || req.Name == "" {
writeJSONError(w, http.StatusBadRequest, "bucket_arn, namespace, and name are required")
return
}
namespaceParts, ok := s.parseNamespaceFromRequest(w, req.Namespace)
if !ok {
return
}
format := req.Format
if format == "" {
format = "ICEBERG"
}
if len(req.Tags) > 0 {
if err := s3tables.ValidateTags(req.Tags); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid tags: "+err.Error())
return
}
}
createReq := &s3tables.CreateTableRequest{
TableBucketARN: req.BucketARN,
Namespace: namespaceParts,
Name: req.Name,
Format: format,
Tags: req.Tags,
Metadata: req.Metadata,
}
var resp s3tables.CreateTableResponse
if err := s.executeS3TablesOperation(r.Context(), "CreateTable", createReq, &resp); err != nil {
writeS3TablesError(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{"table_arn": resp.TableARN, "version_token": resp.VersionToken})
}
func (s *AdminServer) DeleteS3TablesTable(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
bucketArn := r.URL.Query().Get("bucket")
namespace := r.URL.Query().Get("namespace")
name := r.URL.Query().Get("name")
version := r.URL.Query().Get("version")
if bucketArn == "" || namespace == "" || name == "" {
writeJSONError(w, http.StatusBadRequest, "bucket, namespace, and name query parameters are required")
return
}
namespaceParts, ok := s.parseNamespaceFromRequest(w, namespace)
if !ok {
return
}
req := &s3tables.DeleteTableRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name, VersionToken: version}
if err := s.executeS3TablesOperation(r.Context(), "DeleteTable", req, nil); err != nil {
writeS3TablesError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Table deleted"})
}
func (s *AdminServer) PutS3TablesBucketPolicy(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
var req struct {
BucketARN string `json:"bucket_arn"`
Policy string `json:"policy"`
}
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if req.BucketARN == "" || req.Policy == "" {
writeJSONError(w, http.StatusBadRequest, "bucket_arn and policy are required")
return
}
putReq := &s3tables.PutTableBucketPolicyRequest{TableBucketARN: req.BucketARN, ResourcePolicy: req.Policy}
if err := s.executeS3TablesOperation(r.Context(), "PutTableBucketPolicy", putReq, nil); err != nil {
writeS3TablesError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Policy updated"})
}
func (s *AdminServer) GetS3TablesBucketPolicy(w http.ResponseWriter, r *http.Request) {
bucketArn := r.URL.Query().Get("bucket")
if bucketArn == "" {
writeJSONError(w, http.StatusBadRequest, "bucket query parameter is required")
return
}
getReq := &s3tables.GetTableBucketPolicyRequest{TableBucketARN: bucketArn}
var resp s3tables.GetTableBucketPolicyResponse
if err := s.executeS3TablesOperation(r.Context(), "GetTableBucketPolicy", getReq, &resp); err != nil {
writeS3TablesError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"policy": resp.ResourcePolicy})
}
func (s *AdminServer) DeleteS3TablesBucketPolicy(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
bucketArn := r.URL.Query().Get("bucket")
if bucketArn == "" {
writeJSONError(w, http.StatusBadRequest, "bucket query parameter is required")
return
}
deleteReq := &s3tables.DeleteTableBucketPolicyRequest{TableBucketARN: bucketArn}
if err := s.executeS3TablesOperation(r.Context(), "DeleteTableBucketPolicy", deleteReq, nil); err != nil {
writeS3TablesError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Policy deleted"})
}
func (s *AdminServer) PutS3TablesTablePolicy(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
var req struct {
BucketARN string `json:"bucket_arn"`
Namespace string `json:"namespace"`
Name string `json:"name"`
Policy string `json:"policy"`
}
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if req.BucketARN == "" || req.Namespace == "" || req.Name == "" || req.Policy == "" {
writeJSONError(w, http.StatusBadRequest, "bucket_arn, namespace, name, and policy are required")
return
}
namespaceParts, ok := s.parseNamespaceFromRequest(w, req.Namespace)
if !ok {
return
}
putReq := &s3tables.PutTablePolicyRequest{TableBucketARN: req.BucketARN, Namespace: namespaceParts, Name: req.Name, ResourcePolicy: req.Policy}
if err := s.executeS3TablesOperation(r.Context(), "PutTablePolicy", putReq, nil); err != nil {
writeS3TablesError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Policy updated"})
}
func (s *AdminServer) GetS3TablesTablePolicy(w http.ResponseWriter, r *http.Request) {
bucketArn := r.URL.Query().Get("bucket")
namespace := r.URL.Query().Get("namespace")
name := r.URL.Query().Get("name")
if bucketArn == "" || namespace == "" || name == "" {
writeJSONError(w, http.StatusBadRequest, "bucket, namespace, and name query parameters are required")
return
}
namespaceParts, ok := s.parseNamespaceFromRequest(w, namespace)
if !ok {
return
}
getReq := &s3tables.GetTablePolicyRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name}
var resp s3tables.GetTablePolicyResponse
if err := s.executeS3TablesOperation(r.Context(), "GetTablePolicy", getReq, &resp); err != nil {
writeS3TablesError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"policy": resp.ResourcePolicy})
}
func (s *AdminServer) DeleteS3TablesTablePolicy(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
bucketArn := r.URL.Query().Get("bucket")
namespace := r.URL.Query().Get("namespace")
name := r.URL.Query().Get("name")
if bucketArn == "" || namespace == "" || name == "" {
writeJSONError(w, http.StatusBadRequest, "bucket, namespace, and name query parameters are required")
return
}
namespaceParts, ok := s.parseNamespaceFromRequest(w, namespace)
if !ok {
return
}
deleteReq := &s3tables.DeleteTablePolicyRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name}
if err := s.executeS3TablesOperation(r.Context(), "DeleteTablePolicy", deleteReq, nil); err != nil {
writeS3TablesError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Policy deleted"})
}
func (s *AdminServer) TagS3TablesResource(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
var req struct {
ResourceARN string `json:"resource_arn"`
Tags map[string]string `json:"tags"`
}
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if req.ResourceARN == "" || len(req.Tags) == 0 {
writeJSONError(w, http.StatusBadRequest, "resource_arn and tags are required")
return
}
if err := s3tables.ValidateTags(req.Tags); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid tags: "+err.Error())
return
}
tagReq := &s3tables.TagResourceRequest{ResourceARN: req.ResourceARN, Tags: req.Tags}
if err := s.executeS3TablesOperation(r.Context(), "TagResource", tagReq, nil); err != nil {
writeS3TablesError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Tags updated"})
}
func (s *AdminServer) ListS3TablesTags(w http.ResponseWriter, r *http.Request) {
resourceArn := r.URL.Query().Get("arn")
if resourceArn == "" {
writeJSONError(w, http.StatusBadRequest, "arn query parameter is required")
return
}
listReq := &s3tables.ListTagsForResourceRequest{ResourceARN: resourceArn}
var resp s3tables.ListTagsForResourceResponse
if err := s.executeS3TablesOperation(r.Context(), "ListTagsForResource", listReq, &resp); err != nil {
writeS3TablesError(w, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (s *AdminServer) UntagS3TablesResource(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
var req struct {
ResourceARN string `json:"resource_arn"`
TagKeys []string `json:"tag_keys"`
}
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if req.ResourceARN == "" || len(req.TagKeys) == 0 {
writeJSONError(w, http.StatusBadRequest, "resource_arn and tag_keys are required")
return
}
untagReq := &s3tables.UntagResourceRequest{ResourceARN: req.ResourceARN, TagKeys: req.TagKeys}
if err := s.executeS3TablesOperation(r.Context(), "UntagResource", untagReq, nil); err != nil {
writeS3TablesError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Tags removed"})
}
func parseS3TablesErrorMessage(err error) string {
if err == nil {
return ""
}
var s3Err *s3tables.S3TablesError
if errors.As(err, &s3Err) {
if s3Err.Message != "" {
return fmt.Sprintf("%s: %s", s3Err.Type, s3Err.Message)
}
return s3Err.Type
}
return err.Error()
}
func writeS3TablesError(w http.ResponseWriter, err error) {
writeJSONError(w, s3TablesErrorStatus(err), parseS3TablesErrorMessage(err))
}
func s3TablesErrorStatus(err error) int {
var s3Err *s3tables.S3TablesError
if errors.As(err, &s3Err) {
switch s3Err.Type {
case s3tables.ErrCodeInvalidRequest:
return http.StatusBadRequest
case s3tables.ErrCodeNoSuchBucket, s3tables.ErrCodeNoSuchNamespace, s3tables.ErrCodeNoSuchTable, s3tables.ErrCodeNoSuchPolicy:
return http.StatusNotFound
case s3tables.ErrCodeAccessDenied:
return http.StatusForbidden
case s3tables.ErrCodeBucketAlreadyExists, s3tables.ErrCodeNamespaceAlreadyExists, s3tables.ErrCodeTableAlreadyExists, s3tables.ErrCodeBucketNotEmpty, s3tables.ErrCodeNamespaceNotEmpty, s3tables.ErrCodeConflict:
return http.StatusConflict
}
}
return http.StatusInternalServerError
}