mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-27 15:30:45 +01:00
* vector: address remaining s3vectors review findings Catalog and permission model: - Vector buckets are declared Lance table buckets end to end: create sets format=lance, get/list/delete reject or skip ICEBERG buckets, and the format lookup runs on an internal trusted catalog so callers need no GetTableBucket grant. - The implicit "vector" index namespace is created through the same internal manager, so a CreateTable grant alone authorizes CreateIndex; typed S3TablesError codes replace string matching on err.Error(). - CreateIndex pre-checks existence after the authorize, tags the record with a create nonce, and rolls back on engine failure under context.WithoutCancel with a versionToken CAS, so a retry never deletes a table it did not create. - DeleteIndex drops the catalog record directly; DropIndex is unimplemented on the shared VectorService. - DeleteVectorBucket removes the empty internal namespace on BucketNotEmpty and retries once. - PutVectors/DeleteVectors require the update grant, and engine gRPC errors map to typed API errors instead of generic 500s. API correctness: - CreateVectorBucket/CreateIndex return their resource ARNs. - ARNs reject empty bucket segments; vectorBucketArn must be an ARN. - ListIndexes returns summaries built only from catalog fields. - ListVectors honors maxResults/nextToken inside a segment via "seg:<pageStart>:<nextIndex>" resume tokens and validates segmentCount/segmentIndex. - QueryVectors requires positive topK; PutVectors rejects empty keys. - CreateVectorBucket rejects a supplied encryptionConfiguration rather than silently dropping it. - Metadata filters emit Lance's real JSONB UDFs: json_get_string/ json_get_float/json_get_bool picked by operand type, json_exists with a bracketed JSONPath, and per-member accessors for $in/$nin. Engine: - weed-vector-server stores the metadata column as Lance JSONB (lance.json extension) so the json_* UDFs apply; reads accept legacy Utf8 JSON columns. Vectors port TLS: - The TLS config is built before binding listeners, loads -cacert.file into ClientCAs when client-cert verification is on, fails startup on an empty/invalid CA file, and closes the gRPC conn on every failure path. * s3vectors: authorize index create before writing the implicit namespace A denied caller could still leave a persistent namespace behind because ensureIndexNamespace wrote through the trusted catalog before the caller's create grant ran. AuthorizeCreateTable now tolerates the not-yet-declared implicit namespace (bucket policy alone decides) and runs before any write; real table creates keep requiring the namespace. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3vectors: keep engine error details out of the API response Unavailable now maps to 503 ServiceUnavailableException and unclassified codes return a generic message; the real gRPC error stays in the log. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3vectors: exact-type metadata filters + NULL-safe $nin The jsonb extractors convert numeric-looking strings through json_get_float and 'true'/'false' through json_get_bool, so a stored '3' could satisfy a filter for the number 3. Numeric and boolean comparisons now also require the stored value not be a string, and each $in/$nin group coalesces NULL so NOT cannot invert NULL into NULL. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: validate the vectors TLS key pair before binding The reloading certificate provider reads the files asynchronously, so a missing or mismatched pair would only surface as failed handshakes on an announced HTTPS port. Load it once up front. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3tables: make a token-checked table delete atomic The version token was read in one filer call and enforced in another, so a delete+recreate slipping between them let the delete erase the replacement. When a token is supplied the name path now carries the observed mtime as an if_not_modified_after precondition (evaluated under the path lock), and a decoupled delete strips the catalog marker under a compare of the captured attributes before touching the data path. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3vectors: compare filter operands against the stored JSON type jsonb's strict converters make json_get_string return text for numbers and booleans too, so a get_string IS NULL guard is never true for scalar metadata — numeric and boolean comparisons silently matched nothing. Numbers and booleans now compare through TRY_CAST of the serialized json_extract text, which yields NULL on any other stored type; string comparisons stay on json_get_string behind a CASE guard so the extractor only runs on rows storing a JSON string. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3tables: allocate implicit namespace metadata before use A first table create in an undeclared namespace took the allowMissingNamespace branch with a nil record and panicked on the owner assignment. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3tables: read the delete target's metadata and snapshot in one lookup Two separate lookups let a delete+recreate in between validate the caller token against the old record while the conditional delete aimed at the replacement. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3tables: assert the captured record before a conditional table delete An xattr update keeps Mtime, so the IfNotModifiedAfterNs guard alone could not see a metadata write that had already rotated the version token, and a recreate preserving the mtime could slip the mtime check. The delete now first proves the entry still carries the captured attributes under the filer's path lock, then keeps the mtime precondition. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3vectors: match only JSON boolean literals in metadata filters * filer,s3tables: delete versioned tables under one attribute precondition * s3vectors: compare serialized JSON for string filter operands json_get_string converts numeric-looking strings and booleans, so it cannot serve exact-typed string comparisons even behind a guard. String operands now compare the serialized json_extract text directly, which is exact-typed and never errors; a Rust test pins the semantics against a mixed-type dataset. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3vectors: compare boolean filter operands as serialized text Arrow's boolean cast accepts '1' and '0', so a TRY_CAST column let a stored number satisfy a boolean filter. Booleans now compare the serialized json_extract text like strings do — a JSON boolean only ever serializes as 'true' or 'false'. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3tables: claim a catalog name's markers in one compare-checked write A create wrote its markers attribute-by-attribute after checking the name was free. A delete's restore landing in the check-to-write gap put the old markers back first, and the create then overwrote only the ones it sets — leaving the old tags, policy, and maintenance settings on the new table. CreateTable, RegisterTable, and CreateView now write all their markers in a single update that refuses to run while any catalog marker is present, so the second writer loses with a conflict instead of inheriting them. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3tables: test the attribute-preconditioned table delete and name claim Adds a BeforeDelete hook plus ExpectedExtended evaluation to the in-memory filer so tests can land a competing write in a handler's check-to-delete window, and covers two losing races: a token delete whose record changed since the read, and a create colliding with a delete's marker restore. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3tables: reserve a catalog record under one compare-checked claim claimCatalogRecord writes a deleting marker onto the record while it still holds exactly the caller's captured attributes, so a delete or rename can hold the record through a multi-step operation and a losing writer backs out before any data or destination entry is written. The claim key joins the markers a name-claim asserts absent, so the name cannot be reused while the reservation stands. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3tables: claim the record before purging or publishing past it A token delete of a decoupled table purged the data first and checked the marker afterwards, so a rename that finished in between left the moved record pointing at purged data while the delete reported a conflict. The delete now claims the record under the captured attributes before any data is removed and releases the claim if the purge fails, keeping the record intact for a retry. The rename claims the source before publishing the destination, so a losing rename backs out while no destination entry exists instead of leaving one behind pointing at data a delete went on to remove. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3tables: test the record claim across delete and rename races A rename completing in the token delete's check-to-claim window turns the delete into a conflict with the data intact, and a delete's claim on the source turns the rename into a conflict before the destination entry is published. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1965 lines
72 KiB
Go
1965 lines
72 KiB
Go
package s3tables
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
)
|
|
|
|
// handleCreateTable creates a new table in a namespace
|
|
func (h *S3TablesHandler) handleCreateTable(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
|
|
|
|
var req CreateTableRequest
|
|
if err := h.readRequestBody(r, &req); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
|
|
if req.Format == "" {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "format is required")
|
|
return fmt.Errorf("format is required")
|
|
}
|
|
|
|
// Validate format
|
|
if req.Format != FormatIceberg && req.Format != FormatLance {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, fmt.Sprintf("unsupported format %q", req.Format))
|
|
return fmt.Errorf("invalid format")
|
|
}
|
|
|
|
target, err := h.authorizeCreateTable(w, r, filerClient, req.TableBucketARN, req.Namespace, req.Name, req.Tags, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
bucketName, namespaceName, tableName := target.bucketName, target.namespaceName, target.tableName
|
|
|
|
// A bucket declares the format it holds, and a table of another format would
|
|
// be invisible to the catalog serving it. A bucket made before the
|
|
// declaration existed has none, and keeps taking anything.
|
|
if target.bucketFormat != "" && target.bucketFormat != req.Format {
|
|
message := fmt.Sprintf("table bucket %s holds %s tables", bucketName, target.bucketFormat)
|
|
h.writeError(w, http.StatusConflict, ErrCodeConflict, message)
|
|
return fmt.Errorf("%s", message)
|
|
}
|
|
|
|
tablePath := GetTablePath(bucketName, namespaceName, tableName)
|
|
|
|
// Check if a table or view already exists at this name. Names are unique
|
|
// across tables and views in a namespace.
|
|
var existingMetadata tableMetadataInternal
|
|
var existingIsView bool
|
|
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
entry, err := h.lookupEntry(r.Context(), client, tablePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if EntryType(entry.Extended) == EntryTypeView {
|
|
existingIsView = true
|
|
return nil
|
|
}
|
|
data, ok := entry.Extended[ExtendedKeyMetadata]
|
|
if !ok {
|
|
return fmt.Errorf("%w: %s", ErrAttributeNotFound, ExtendedKeyMetadata)
|
|
}
|
|
if unmarshalErr := json.Unmarshal(data, &existingMetadata); unmarshalErr != nil {
|
|
return fmt.Errorf("failed to parse existing table metadata: %w", unmarshalErr)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err == nil {
|
|
if existingIsView {
|
|
h.writeError(w, http.StatusConflict, ErrCodeTableAlreadyExists, fmt.Sprintf("a view named %s already exists", tableName))
|
|
return fmt.Errorf("view name conflict: %s", tableName)
|
|
}
|
|
// Creating a table that already exists is idempotent, but only for the
|
|
// same format. Handing a Lance client an Iceberg table's location, or the
|
|
// reverse, has it write one format's files into the other's directory.
|
|
if existingMetadata.Format != "" && existingMetadata.Format != req.Format {
|
|
h.writeError(w, http.StatusConflict, ErrCodeTableAlreadyExists,
|
|
fmt.Sprintf("a %s table named %s already exists", existingMetadata.Format, tableName))
|
|
return fmt.Errorf("format conflict: %s", tableName)
|
|
}
|
|
tableARN := h.generateTableARN(existingMetadata.OwnerAccountID, bucketName, namespaceName+"/"+tableName)
|
|
h.writeJSON(w, http.StatusOK, &CreateTableResponse{
|
|
TableARN: tableARN,
|
|
VersionToken: existingMetadata.VersionToken,
|
|
MetadataLocation: existingMetadata.MetadataLocation,
|
|
})
|
|
return nil
|
|
} else if !errors.Is(err, filer_pb.ErrNotFound) && !errors.Is(err, ErrAttributeNotFound) {
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to check table: %v", err))
|
|
return err
|
|
}
|
|
|
|
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName, namespaceName); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
|
|
// Create the table
|
|
now := time.Now()
|
|
versionToken := generateVersionToken()
|
|
|
|
metadata := &tableMetadataInternal{
|
|
Name: tableName,
|
|
Namespace: namespaceName,
|
|
Format: req.Format,
|
|
CreatedAt: now,
|
|
ModifiedAt: now,
|
|
OwnerAccountID: target.ownerAccountID, // Inherit namespace owner for consistency
|
|
VersionToken: versionToken,
|
|
MetadataVersion: max(req.MetadataVersion, 1),
|
|
MetadataLocation: req.MetadataLocation,
|
|
Metadata: req.Metadata,
|
|
}
|
|
|
|
metadataBytes, err := json.Marshal(metadata)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to marshal table metadata")
|
|
return fmt.Errorf("failed to marshal metadata: %w", err)
|
|
}
|
|
|
|
var tagsBytes []byte
|
|
if len(req.Tags) > 0 {
|
|
if tagsBytes, err = json.Marshal(req.Tags); err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to marshal tags")
|
|
return fmt.Errorf("failed to marshal tags: %w", err)
|
|
}
|
|
}
|
|
|
|
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
// Ensure table directory exists (may already be created by object storage clients)
|
|
if err := h.ensureDirectory(r.Context(), client, tablePath); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create data subdirectory for Iceberg files
|
|
dataPath := tablePath + "/data"
|
|
if err := h.ensureDirectory(r.Context(), client, dataPath); err != nil {
|
|
return err
|
|
}
|
|
|
|
attrs := map[string][]byte{
|
|
ExtendedKeyMetadata: metadataBytes,
|
|
ExtendedKeyEntryType: []byte(EntryTypeTable),
|
|
}
|
|
if len(tagsBytes) > 0 {
|
|
attrs[ExtendedKeyTags] = tagsBytes
|
|
}
|
|
return h.claimCatalogEntry(r.Context(), client, tablePath, attrs)
|
|
})
|
|
|
|
if err != nil {
|
|
if errors.Is(err, ErrNameTaken) {
|
|
h.writeError(w, http.StatusConflict, ErrCodeTableAlreadyExists, fmt.Sprintf("table %s already exists", tableName))
|
|
return err
|
|
}
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to create table")
|
|
return err
|
|
}
|
|
|
|
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespaceName+"/"+tableName)
|
|
|
|
resp := &CreateTableResponse{
|
|
TableARN: tableARN,
|
|
VersionToken: versionToken,
|
|
}
|
|
|
|
h.writeJSON(w, http.StatusOK, resp)
|
|
return nil
|
|
}
|
|
|
|
// metadataVersionFromLocation parses the version N from a metadata location.
|
|
// SeaweedFS writes v{N}.metadata.json; Iceberg engines (Spark/Trino/Flink/Java)
|
|
// write {NNNNN}-{uuid}.metadata.json with a zero-padded leading version. Returns
|
|
// 1 when no version can be parsed.
|
|
func metadataVersionFromLocation(metadataLocation string) int {
|
|
name := metadataLocation
|
|
if idx := strings.LastIndex(name, "/"); idx != -1 {
|
|
name = name[idx+1:]
|
|
}
|
|
name = strings.TrimSuffix(name, ".metadata.json")
|
|
// v{N} form
|
|
if v, err := strconv.Atoi(strings.TrimPrefix(name, "v")); err == nil && v > 0 {
|
|
return v
|
|
}
|
|
// v{N}-{unique} form, written when a commit finds v{N} already staged
|
|
if trimmed := strings.TrimPrefix(name, "v"); trimmed != name {
|
|
if idx := strings.IndexByte(trimmed, '-'); idx != -1 {
|
|
if v, err := strconv.Atoi(trimmed[:idx]); err == nil && v > 0 {
|
|
return v
|
|
}
|
|
}
|
|
}
|
|
// {NNNNN}-{uuid} form: the leading integer before the first '-'
|
|
if idx := strings.IndexByte(name, '-'); idx != -1 {
|
|
if v, err := strconv.Atoi(name[:idx]); err == nil && v > 0 {
|
|
return v
|
|
}
|
|
}
|
|
return 1
|
|
}
|
|
|
|
// handleRegisterTable registers an existing Iceberg metadata.json under a new
|
|
// catalog entry. Unlike CreateTable it does not generate metadata: it points the
|
|
// table at the caller-supplied MetadataLocation.
|
|
func (h *S3TablesHandler) handleRegisterTable(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
|
|
|
|
var req RegisterTableRequest
|
|
if err := h.readRequestBody(r, &req); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
|
|
if req.MetadataLocation == "" {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "metadataLocation is required")
|
|
return fmt.Errorf("metadataLocation is required")
|
|
}
|
|
|
|
target, err := h.authorizeCreateTable(w, r, filerClient, req.TableBucketARN, req.Namespace, req.Name, nil, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
bucketName, namespaceName, tableName := target.bucketName, target.namespaceName, target.tableName
|
|
|
|
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName, namespaceName); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
|
|
tablePath := GetTablePath(bucketName, namespaceName, tableName)
|
|
|
|
// Table must be absent.
|
|
var existingMetadata tableMetadataInternal
|
|
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
data, err := h.getExtendedAttribute(r.Context(), client, tablePath, ExtendedKeyMetadata)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(data, &existingMetadata)
|
|
})
|
|
if err == nil {
|
|
h.writeError(w, http.StatusConflict, ErrCodeTableAlreadyExists, fmt.Sprintf("table %s already exists", tableName))
|
|
return fmt.Errorf("table %s already exists", tableName)
|
|
} else if !errors.Is(err, filer_pb.ErrNotFound) && !errors.Is(err, ErrAttributeNotFound) {
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to check table: %v", err))
|
|
return err
|
|
}
|
|
|
|
now := time.Now()
|
|
versionToken := generateVersionToken()
|
|
metadata := &tableMetadataInternal{
|
|
Name: tableName,
|
|
Namespace: namespaceName,
|
|
Format: FormatIceberg,
|
|
CreatedAt: now,
|
|
ModifiedAt: now,
|
|
OwnerAccountID: target.ownerAccountID,
|
|
VersionToken: versionToken,
|
|
MetadataVersion: metadataVersionFromLocation(req.MetadataLocation),
|
|
MetadataLocation: req.MetadataLocation,
|
|
}
|
|
|
|
metadataBytes, err := json.Marshal(metadata)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to marshal table metadata")
|
|
return fmt.Errorf("failed to marshal metadata: %w", err)
|
|
}
|
|
|
|
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
if err := h.ensureDirectory(r.Context(), client, tablePath); err != nil {
|
|
return err
|
|
}
|
|
return h.claimCatalogEntry(r.Context(), client, tablePath, map[string][]byte{
|
|
ExtendedKeyMetadata: metadataBytes,
|
|
})
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, ErrNameTaken) {
|
|
h.writeError(w, http.StatusConflict, ErrCodeTableAlreadyExists, fmt.Sprintf("table %s already exists", tableName))
|
|
return err
|
|
}
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to register table")
|
|
return err
|
|
}
|
|
|
|
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespaceName+"/"+tableName)
|
|
h.writeJSON(w, http.StatusOK, &RegisterTableResponse{
|
|
TableARN: tableARN,
|
|
VersionToken: versionToken,
|
|
MetadataLocation: metadata.MetadataLocation,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// handleGetTable gets details of a table
|
|
func (h *S3TablesHandler) handleGetTable(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
|
|
|
|
var req GetTableRequest
|
|
if err := h.readRequestBody(r, &req); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
|
|
var bucketName, namespace, tableName string
|
|
var err error
|
|
|
|
// Support getting by ARN or by bucket/namespace/name
|
|
if req.TableARN != "" {
|
|
bucketName, namespace, tableName, err = parseTableFromARN(req.TableARN)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
} else if req.TableBucketARN != "" && len(req.Namespace) > 0 && req.Name != "" {
|
|
bucketName, err = parseBucketNameFromARN(req.TableBucketARN)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
namespace, err = validateNamespace(req.Namespace)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
tableName, err = validateTableName(req.Name)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
} else {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "either tableARN or (tableBucketARN, namespace, name) is required")
|
|
return fmt.Errorf("missing required parameters")
|
|
}
|
|
|
|
tablePath := GetTablePath(bucketName, namespace, tableName)
|
|
|
|
var metadata tableMetadataInternal
|
|
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
data, err := h.readTableMetadata(r.Context(), client, tablePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(data, &metadata); err != nil {
|
|
return fmt.Errorf("failed to unmarshal table metadata: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
// A directory without the table-metadata xattr is not a table (e.g. a renamed-away source).
|
|
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrAttributeNotFound) {
|
|
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
|
|
} else {
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to get table: %v", err))
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Authorize access to the table using policy framework
|
|
accountID := h.getAccountID(r)
|
|
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 {
|
|
// Fetch bucket metadata to use correct owner for bucket policy evaluation
|
|
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: %v", err)
|
|
}
|
|
|
|
// Fetch table policy if it exists
|
|
policyData, err := h.getExtendedAttribute(r.Context(), client, tablePath, ExtendedKeyPolicy)
|
|
if err == nil {
|
|
tablePolicy = string(policyData)
|
|
} 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)
|
|
if err == nil {
|
|
bucketPolicy = string(policyData)
|
|
} 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
|
|
})
|
|
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to fetch policies: %v", err))
|
|
return err
|
|
}
|
|
|
|
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespace+"/"+tableName)
|
|
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
|
|
identityActions := getIdentityActions(r)
|
|
allowed := CheckTableOrBucketPermission("GetTable", accountID,
|
|
metadata.OwnerAccountID, tablePolicy, tableARN,
|
|
bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
|
|
TrustedIssuers: h.trustedIssuers(r),
|
|
IsLocalAccountID: h.localAccountIDs,
|
|
TableBucketName: bucketName,
|
|
Namespace: namespace,
|
|
TableName: tableName,
|
|
TableBucketTags: bucketTags,
|
|
ResourceTags: tableTags,
|
|
IdentityActions: identityActions,
|
|
DefaultAllow: h.defaultAllowFor(r),
|
|
})
|
|
|
|
if !allowed {
|
|
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
|
|
return ErrAccessDenied
|
|
}
|
|
|
|
resp := &GetTableResponse{
|
|
Name: metadata.Name,
|
|
TableARN: tableARN,
|
|
Namespace: expandNamespace(metadata.Namespace),
|
|
Format: metadata.Format,
|
|
CreatedAt: metadata.CreatedAt,
|
|
ModifiedAt: metadata.ModifiedAt,
|
|
OwnerAccountID: metadata.OwnerAccountID,
|
|
MetadataLocation: metadata.MetadataLocation,
|
|
MetadataVersion: metadata.MetadataVersion,
|
|
VersionToken: metadata.VersionToken,
|
|
Metadata: metadata.Metadata,
|
|
}
|
|
|
|
h.writeJSON(w, http.StatusOK, resp)
|
|
return nil
|
|
}
|
|
|
|
// handleListTables lists all tables in a namespace or bucket
|
|
func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
|
|
|
|
var req ListTablesRequest
|
|
if err := h.readRequestBody(r, &req); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
|
|
if req.TableBucketARN == "" {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "tableBucketARN is required")
|
|
return fmt.Errorf("tableBucketARN is required")
|
|
}
|
|
|
|
bucketName, err := parseBucketNameFromARN(req.TableBucketARN)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
|
|
maxTables := req.MaxTables
|
|
if maxTables <= 0 {
|
|
maxTables = 100
|
|
}
|
|
// Cap to prevent uint32 overflow when used in uint32(maxTables*2)
|
|
const maxTablesLimit = 1000
|
|
if maxTables > maxTablesLimit {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "MaxTables exceeds maximum allowed value")
|
|
return fmt.Errorf("invalid maxTables value: %d", maxTables)
|
|
}
|
|
|
|
// Pre-validate namespace before calling WithFilerClient to return 400 on validation errors
|
|
var namespaceName string
|
|
if len(req.Namespace) > 0 {
|
|
var err error
|
|
namespaceName, err = validateNamespace(req.Namespace)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
}
|
|
|
|
var tables []TableSummary
|
|
var paginationToken string
|
|
|
|
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
var err error
|
|
accountID := h.getAccountID(r)
|
|
|
|
if len(req.Namespace) > 0 {
|
|
// Namespace has already been validated above
|
|
namespacePath := GetNamespacePath(bucketName, namespaceName)
|
|
bucketPath := GetTableBucketPath(bucketName)
|
|
var nsMeta namespaceMetadata
|
|
var bucketMeta tableBucketMetadata
|
|
var namespacePolicy, bucketPolicy string
|
|
bucketTags := map[string]string{}
|
|
|
|
// Fetch namespace metadata and policy
|
|
targetAbsent := false
|
|
data, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyMetadata)
|
|
if err != nil {
|
|
if !errors.Is(err, filer_pb.ErrNotFound) && !errors.Is(err, ErrAttributeNotFound) {
|
|
return err
|
|
}
|
|
targetAbsent = true
|
|
} else if err := json.Unmarshal(data, &nsMeta); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Fetch namespace policy if it exists
|
|
policyData, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyPolicy)
|
|
if err == nil {
|
|
namespacePolicy = string(policyData)
|
|
} else if !errors.Is(err, ErrAttributeNotFound) && !errors.Is(err, filer_pb.ErrNotFound) {
|
|
return fmt.Errorf("failed to fetch namespace policy: %w", err)
|
|
}
|
|
|
|
// Fetch bucket metadata and policy
|
|
data, err = h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata)
|
|
if err == nil {
|
|
if err := json.Unmarshal(data, &bucketMeta); err != nil {
|
|
return fmt.Errorf("failed to unmarshal bucket metadata: %w", err)
|
|
}
|
|
} else if errors.Is(err, filer_pb.ErrNotFound) {
|
|
targetAbsent = true
|
|
} 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 {
|
|
bucketPolicy = string(policyData)
|
|
} else if !errors.Is(err, ErrAttributeNotFound) && !errors.Is(err, filer_pb.ErrNotFound) {
|
|
return fmt.Errorf("failed to fetch bucket policy: %w", err)
|
|
}
|
|
if tags, err := h.readTags(r.Context(), client, bucketPath); err != nil {
|
|
if !errors.Is(err, filer_pb.ErrNotFound) {
|
|
return fmt.Errorf("failed to read bucket tags: %w", err)
|
|
}
|
|
} else if tags != nil {
|
|
bucketTags = tags
|
|
}
|
|
|
|
if targetAbsent {
|
|
if !h.authorizeAbsentTarget(r, &singleClient{client}, "ListTables", bucketName, namespaceName, "", "") {
|
|
return ErrAccessDenied
|
|
}
|
|
return filer_pb.ErrNotFound
|
|
}
|
|
|
|
bucketARN := h.generateTableBucketARN(bucketMeta.OwnerAccountID, bucketName)
|
|
identityActions := getIdentityActions(r)
|
|
if !CheckTableOrBucketPermission("ListTables", accountID,
|
|
nsMeta.OwnerAccountID, namespacePolicy, bucketARN,
|
|
bucketMeta.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
|
|
TrustedIssuers: h.trustedIssuers(r),
|
|
IsLocalAccountID: h.localAccountIDs,
|
|
TableBucketName: bucketName,
|
|
Namespace: namespaceName,
|
|
TableBucketTags: bucketTags,
|
|
IdentityActions: identityActions,
|
|
DefaultAllow: h.defaultAllowFor(r),
|
|
}) {
|
|
return ErrAccessDenied
|
|
}
|
|
|
|
tables, paginationToken, err = h.listTablesInNamespaceWithClient(r, client, bucketName, namespaceName, req.Prefix, req.ContinuationToken, maxTables)
|
|
if err != nil {
|
|
return fmt.Errorf("list tables in namespace %v: %w", namespaceName, err)
|
|
}
|
|
} else {
|
|
// List tables across all namespaces in bucket
|
|
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)
|
|
if err != nil {
|
|
if !errors.Is(err, filer_pb.ErrNotFound) && !errors.Is(err, ErrAttributeNotFound) {
|
|
return fmt.Errorf("failed to fetch bucket metadata: %w", err)
|
|
}
|
|
if !h.authorizeAbsentTarget(r, &singleClient{client}, "ListTables", bucketName, "", "", "") {
|
|
return ErrAccessDenied
|
|
}
|
|
return filer_pb.ErrNotFound
|
|
}
|
|
if err := json.Unmarshal(data, &bucketMeta); err != nil {
|
|
return fmt.Errorf("failed to unmarshal bucket metadata: %w", err)
|
|
}
|
|
|
|
// Fetch bucket policy if it exists
|
|
policyData, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyPolicy)
|
|
if err == nil {
|
|
bucketPolicy = string(policyData)
|
|
} else if !errors.Is(err, ErrAttributeNotFound) {
|
|
return fmt.Errorf("failed to fetch bucket policy: %w", err)
|
|
}
|
|
if tags, err := h.readTags(r.Context(), client, bucketPath); err != nil {
|
|
return fmt.Errorf("failed to read bucket tags: %w", err)
|
|
} else if tags != nil {
|
|
bucketTags = tags
|
|
}
|
|
|
|
bucketARN := h.generateTableBucketARN(bucketMeta.OwnerAccountID, bucketName)
|
|
identityActions := getIdentityActions(r)
|
|
if !CheckPermissionWithContext("ListTables", accountID, bucketMeta.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
|
|
TrustedIssuers: h.trustedIssuers(r),
|
|
IsLocalAccountID: h.localAccountIDs,
|
|
TableBucketName: bucketName,
|
|
TableBucketTags: bucketTags,
|
|
IdentityActions: identityActions,
|
|
DefaultAllow: h.defaultAllowFor(r),
|
|
}) {
|
|
return ErrAccessDenied
|
|
}
|
|
|
|
tables, paginationToken, err = h.listTablesInAllNamespaces(r, client, bucketName, req.Prefix, req.ContinuationToken, maxTables)
|
|
if err != nil {
|
|
return fmt.Errorf("list tables in all namespaces: %w", err)
|
|
}
|
|
}
|
|
return err
|
|
})
|
|
|
|
if err != nil {
|
|
if errors.Is(err, filer_pb.ErrNotFound) {
|
|
// If the bucket or namespace directory is not found, return an empty result
|
|
tables = []TableSummary{}
|
|
paginationToken = ""
|
|
} else if isAuthError(err) {
|
|
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "Access Denied")
|
|
return err
|
|
} else {
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to list tables: %v", err))
|
|
return err
|
|
}
|
|
}
|
|
|
|
resp := &ListTablesResponse{
|
|
Tables: tables,
|
|
ContinuationToken: paginationToken,
|
|
}
|
|
|
|
h.writeJSON(w, http.StatusOK, resp)
|
|
return nil
|
|
}
|
|
|
|
// listTablesInNamespaceWithClient lists tables in a specific namespace
|
|
func (h *S3TablesHandler) listTablesInNamespaceWithClient(r *http.Request, client filer_pb.SeaweedFilerClient, bucketName, namespaceName, prefix, continuationToken string, maxTables int) ([]TableSummary, string, error) {
|
|
namespacePath := GetNamespacePath(bucketName, namespaceName)
|
|
return h.listTablesWithClient(r, client, namespacePath, bucketName, namespaceName, prefix, continuationToken, maxTables)
|
|
}
|
|
|
|
func (h *S3TablesHandler) listTablesWithClient(r *http.Request, client filer_pb.SeaweedFilerClient, dirPath, bucketName, namespaceName, prefix, continuationToken string, maxTables int) ([]TableSummary, string, error) {
|
|
var tables []TableSummary
|
|
lastFileName := continuationToken
|
|
ctx := r.Context()
|
|
|
|
for len(tables) < maxTables {
|
|
resp, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{
|
|
Directory: dirPath,
|
|
Limit: uint32(maxTables * 2),
|
|
StartFromFileName: lastFileName,
|
|
InclusiveStartFrom: lastFileName == "" || lastFileName == continuationToken,
|
|
})
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
hasMore := false
|
|
for {
|
|
entry, respErr := resp.Recv()
|
|
if respErr != nil {
|
|
if respErr == io.EOF {
|
|
break
|
|
}
|
|
return nil, "", respErr
|
|
}
|
|
if entry.Entry == nil {
|
|
continue
|
|
}
|
|
|
|
// Skip the start item if it was included in the previous page
|
|
if len(tables) == 0 && continuationToken != "" && entry.Entry.Name == continuationToken {
|
|
continue
|
|
}
|
|
|
|
hasMore = true
|
|
lastFileName = entry.Entry.Name
|
|
|
|
if !entry.Entry.IsDirectory {
|
|
continue
|
|
}
|
|
|
|
// Skip hidden entries
|
|
if strings.HasPrefix(entry.Entry.Name, ".") {
|
|
continue
|
|
}
|
|
|
|
// Apply prefix filter
|
|
if prefix != "" && !strings.HasPrefix(entry.Entry.Name, prefix) {
|
|
continue
|
|
}
|
|
|
|
// Views share the table layout; exclude them from table listings.
|
|
if EntryType(entry.Entry.Extended) == EntryTypeView {
|
|
continue
|
|
}
|
|
|
|
// Read table metadata from extended attribute
|
|
data, ok := entry.Entry.Extended[ExtendedKeyMetadata]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
var metadata tableMetadataInternal
|
|
if err := json.Unmarshal(data, &metadata); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Note: Authorization (ownership or policy-based access) is checked at the handler level
|
|
// before calling this function. This filter is removed to allow policy-based sharing.
|
|
// The caller has already been verified to have ListTables permission for this namespace/bucket.
|
|
|
|
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespaceName+"/"+entry.Entry.Name)
|
|
|
|
tables = append(tables, TableSummary{
|
|
Name: entry.Entry.Name,
|
|
TableARN: tableARN,
|
|
Namespace: expandNamespace(namespaceName),
|
|
Format: metadata.Format,
|
|
CreatedAt: metadata.CreatedAt,
|
|
ModifiedAt: metadata.ModifiedAt,
|
|
MetadataLocation: metadata.MetadataLocation,
|
|
})
|
|
|
|
if len(tables) >= maxTables {
|
|
return tables, lastFileName, nil
|
|
}
|
|
}
|
|
|
|
if !hasMore {
|
|
break
|
|
}
|
|
}
|
|
|
|
if len(tables) < maxTables {
|
|
lastFileName = ""
|
|
}
|
|
return tables, lastFileName, nil
|
|
}
|
|
|
|
func (h *S3TablesHandler) listTablesInAllNamespaces(r *http.Request, client filer_pb.SeaweedFilerClient, bucketName, prefix, continuationToken string, maxTables int) ([]TableSummary, string, error) {
|
|
bucketPath := GetTableBucketPath(bucketName)
|
|
ctx := r.Context()
|
|
|
|
var continuationNamespace string
|
|
var startTableName string
|
|
if continuationToken != "" {
|
|
if parts := strings.SplitN(continuationToken, "/", 2); len(parts) == 2 {
|
|
continuationNamespace = parts[0]
|
|
startTableName = parts[1]
|
|
} else {
|
|
continuationNamespace = continuationToken
|
|
}
|
|
}
|
|
|
|
var tables []TableSummary
|
|
lastNamespace := continuationNamespace
|
|
for {
|
|
// List namespaces in batches
|
|
resp, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{
|
|
Directory: bucketPath,
|
|
Limit: 100,
|
|
StartFromFileName: lastNamespace,
|
|
InclusiveStartFrom: (lastNamespace == continuationNamespace && startTableName != "") || (lastNamespace == "" && continuationNamespace == ""),
|
|
})
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
hasMore := false
|
|
for {
|
|
entry, respErr := resp.Recv()
|
|
if respErr != nil {
|
|
if respErr == io.EOF {
|
|
break
|
|
}
|
|
return nil, "", respErr
|
|
}
|
|
if entry.Entry == nil {
|
|
continue
|
|
}
|
|
|
|
hasMore = true
|
|
lastNamespace = entry.Entry.Name
|
|
|
|
if !entry.Entry.IsDirectory || strings.HasPrefix(entry.Entry.Name, ".") {
|
|
continue
|
|
}
|
|
|
|
namespace := entry.Entry.Name
|
|
tableNameFilter := ""
|
|
if namespace == continuationNamespace {
|
|
tableNameFilter = startTableName
|
|
}
|
|
|
|
nsTables, nsToken, err := h.listTablesInNamespaceWithClient(r, client, bucketName, namespace, prefix, tableNameFilter, maxTables-len(tables))
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("list tables in namespace %s: %w", namespace, err)
|
|
}
|
|
|
|
tables = append(tables, nsTables...)
|
|
|
|
if namespace == continuationNamespace {
|
|
startTableName = ""
|
|
}
|
|
|
|
if len(tables) >= maxTables {
|
|
paginationToken := namespace + "/" + nsToken
|
|
if nsToken == "" {
|
|
// If we hit the limit exactly at the end of a namespace, the next token should be the next namespace
|
|
paginationToken = namespace // This will start from the NEXT namespace in the outer loop
|
|
}
|
|
return tables, paginationToken, nil
|
|
}
|
|
}
|
|
|
|
if !hasMore {
|
|
break
|
|
}
|
|
}
|
|
|
|
return tables, "", nil
|
|
}
|
|
|
|
// handleDeleteTable deletes a table from a namespace
|
|
func (h *S3TablesHandler) handleDeleteTable(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
|
|
|
|
var req DeleteTableRequest
|
|
if err := h.readRequestBody(r, &req); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
|
|
if req.TableBucketARN == "" || len(req.Namespace) == 0 || req.Name == "" {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "tableBucketARN, namespace, and name are required")
|
|
return fmt.Errorf("missing required parameters")
|
|
}
|
|
|
|
namespaceName, err := validateNamespace(req.Namespace)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
|
|
bucketName, err := parseBucketNameFromARN(req.TableBucketARN)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
|
|
tableName, err := validateTableName(req.Name)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
|
|
tablePath := GetTablePath(bucketName, namespaceName, tableName)
|
|
|
|
// 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
|
|
var tableEntry *filer_pb.Entry
|
|
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
// One lookup feeds both the version-token check and the conditional
|
|
// delete below: a second read could land on a replacement entry while
|
|
// the token was validated against the old one.
|
|
tableEntry, err = h.lookupEntry(r.Context(), client, tablePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err := tableMetadata(tableEntry)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := json.Unmarshal(data, &metadata); err != nil {
|
|
return fmt.Errorf("failed to unmarshal table metadata: %w", err)
|
|
}
|
|
|
|
// Fetch table policy if it exists
|
|
policyData, err := h.getExtendedAttribute(r.Context(), client, tablePath, ExtendedKeyPolicy)
|
|
if err != nil {
|
|
if errors.Is(err, ErrAttributeNotFound) {
|
|
// No table policy set; proceed with empty policy
|
|
} else {
|
|
return fmt.Errorf("failed to fetch table policy: %w", err)
|
|
}
|
|
} else {
|
|
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
|
|
})
|
|
|
|
if err != nil {
|
|
if errors.Is(err, filer_pb.ErrNotFound) {
|
|
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
|
|
} else if errors.Is(err, ErrVersionTokenMismatch) {
|
|
h.writeError(w, http.StatusConflict, ErrCodeConflict, "version token mismatch")
|
|
} else {
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to check table: %v", err))
|
|
}
|
|
return err
|
|
}
|
|
|
|
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespaceName+"/"+tableName)
|
|
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
|
|
principal := h.getAccountID(r)
|
|
identityActions := getIdentityActions(r)
|
|
allowed := CheckTableOrBucketPermission("DeleteTable", principal,
|
|
metadata.OwnerAccountID, tablePolicy, tableARN,
|
|
bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
|
|
TrustedIssuers: h.trustedIssuers(r),
|
|
IsLocalAccountID: h.localAccountIDs,
|
|
TableBucketName: bucketName,
|
|
Namespace: namespaceName,
|
|
TableName: tableName,
|
|
TableBucketTags: bucketTags,
|
|
ResourceTags: tableTags,
|
|
IdentityActions: identityActions,
|
|
DefaultAllow: h.defaultAllowFor(r),
|
|
})
|
|
if !allowed {
|
|
if h.canReadEntry("GetTable", principal, metadata.OwnerAccountID, bucketMetadata.OwnerAccountID, tablePolicy, bucketPolicy, tableARN, bucketARN, bucketName, namespaceName, tableName, bucketTags, tableTags, identityActions, r) {
|
|
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to delete table")
|
|
} else {
|
|
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
|
|
}
|
|
return NewAuthError("DeleteTable", principal, "not authorized to delete table")
|
|
}
|
|
if req.VersionToken != "" && metadata.VersionToken != req.VersionToken {
|
|
h.writeError(w, http.StatusConflict, ErrCodeConflict, "version token mismatch")
|
|
return ErrVersionTokenMismatch
|
|
}
|
|
|
|
// Delete the table
|
|
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
dataPath := TableDataDirFromMetadataLocation(metadata.MetadataLocation)
|
|
if dataPath != "" && dataPath != tablePath && strings.HasPrefix(dataPath+"/", GetTableBucketPath(bucketName)+"/") {
|
|
// Refuse to purge a data path that is an ancestor of the table's own
|
|
// name path (e.g. corrupt metadata resolving to the bucket or
|
|
// namespace root): the bucket-scope check above still admits the
|
|
// bucket root, and a recursive delete there would take out unrelated
|
|
// tables.
|
|
if strings.HasPrefix(tablePath+"/", dataPath+"/") {
|
|
return fmt.Errorf("refusing to delete table %s: data path %q is an ancestor of catalog path %q", tableName, dataPath, tablePath)
|
|
}
|
|
// The location is caller-supplied, so it may name a sibling that is
|
|
// still a live catalog entry. Only this table's authorization was
|
|
// checked; a decoupled location has had its catalog attributes
|
|
// stripped, so a surviving marker means the path belongs elsewhere.
|
|
// The marker lives on the entry's name path, so ancestors of the
|
|
// data path are checked as well: a location inside another table's
|
|
// directory resolves above any marker the data path itself would
|
|
// carry.
|
|
for dir := path.Dir(dataPath); dir != GetTableBucketPath(bucketName) && strings.HasPrefix(dir, GetTableBucketPath(bucketName)+"/"); dir = path.Dir(dir) {
|
|
ancestor, lookupErr := h.lookupEntry(r.Context(), client, dir)
|
|
if lookupErr != nil {
|
|
if errors.Is(lookupErr, filer_pb.ErrNotFound) {
|
|
continue
|
|
}
|
|
return fmt.Errorf("refusing to delete table %s: cannot read data path %q: %w", tableName, dataPath, lookupErr)
|
|
}
|
|
if _, ok := ancestor.Extended[ExtendedKeyEntryType]; ok {
|
|
return fmt.Errorf("refusing to delete table %s: data path %q is inside another catalog entry", tableName, dataPath)
|
|
}
|
|
var ownerMeta tableMetadataInternal
|
|
if data := ancestor.Extended[ExtendedKeyMetadata]; json.Unmarshal(data, &ownerMeta) == nil && ownerMeta.Name != "" {
|
|
return fmt.Errorf("refusing to delete table %s: data path %q is inside another catalog entry", tableName, dataPath)
|
|
}
|
|
}
|
|
switch _, markerErr := h.getExtendedAttribute(r.Context(), client, dataPath, ExtendedKeyMetadata); {
|
|
case markerErr == nil:
|
|
return fmt.Errorf("refusing to delete table %s: data path %q is another catalog entry", tableName, dataPath)
|
|
case !errors.Is(markerErr, ErrAttributeNotFound) && !errors.Is(markerErr, filer_pb.ErrNotFound):
|
|
return fmt.Errorf("refusing to delete table %s: cannot read data path %q: %w", tableName, dataPath, markerErr)
|
|
}
|
|
// Decoupled table (renamed, or created over a leftover): its data
|
|
// lives elsewhere. Purge the data, then clear the catalog marker
|
|
// without deleting the name path -- it may still hold another
|
|
// table's data that was left when this name was reused.
|
|
if req.VersionToken != "" && tableEntry != nil {
|
|
// A token caller owns exactly the record it read. Claim it
|
|
// under the captured attributes before any data is removed:
|
|
// a rename or delete+recreate that moved the record aborts
|
|
// here, while it still points at live data. The claim holds
|
|
// the name through the purge, a failed purge releases it so
|
|
// the intact record can be retried, and the marker comes off
|
|
// under a compare on the claimed record.
|
|
snapshot := snapshotExtended(tableEntry.Extended)
|
|
if err := h.claimCatalogRecord(r.Context(), client, tablePath, snapshot, "delete"); err != nil {
|
|
if errors.Is(err, ErrConcurrentUpdate) {
|
|
return ErrVersionTokenMismatch
|
|
}
|
|
return err
|
|
}
|
|
claimed := snapshotExtended(tableEntry.Extended)
|
|
claimed[ExtendedKeyDeleting] = []byte("delete")
|
|
if err := h.deleteDirectory(r.Context(), client, dataPath); err != nil {
|
|
if relErr := h.releaseCatalogRecord(r.Context(), client, tablePath, claimed); relErr != nil {
|
|
glog.Warningf("delete table %s: release claim: %v", tablePath, relErr)
|
|
}
|
|
return err
|
|
}
|
|
err := h.removeExtendedAttributesIf(r.Context(), client, tablePath, claimed,
|
|
ExtendedKeyMetadata, ExtendedKeyMetadataVersion, ExtendedKeyPolicy, ExtendedKeyTags, ExtendedKeyEntryType,
|
|
ExtendedKeyMaintenance, ExtendedKeyMaintenanceStatus, ExtendedKeyDeleting)
|
|
if err != nil {
|
|
if errors.Is(err, ErrConcurrentUpdate) {
|
|
return ErrVersionTokenMismatch
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
if err := h.deleteDirectory(r.Context(), client, dataPath); err != nil {
|
|
return err
|
|
}
|
|
return h.removeExtendedAttributes(r.Context(), client, tablePath,
|
|
ExtendedKeyMetadata, ExtendedKeyMetadataVersion, ExtendedKeyPolicy, ExtendedKeyTags, ExtendedKeyEntryType,
|
|
ExtendedKeyMaintenance, ExtendedKeyMaintenanceStatus)
|
|
}
|
|
// Colocated table: the name path holds the data. A token caller's
|
|
// delete carries the captured attributes as a filer precondition, so
|
|
// compare and removal are one operation under the path lock: an xattr
|
|
// write that keeps Mtime, or a delete+recreate in between, refuses it.
|
|
if req.VersionToken != "" && tableEntry != nil {
|
|
if err := h.deleteDirectoryIfMatches(r.Context(), client, tablePath, tableEntry); err != nil {
|
|
if errors.Is(err, ErrConcurrentUpdate) {
|
|
return ErrVersionTokenMismatch
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
return h.deleteDirectory(r.Context(), client, tablePath)
|
|
})
|
|
|
|
if err != nil {
|
|
if errors.Is(err, ErrVersionTokenMismatch) {
|
|
h.writeError(w, http.StatusConflict, ErrCodeConflict, "version token mismatch")
|
|
return err
|
|
}
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to delete table")
|
|
return err
|
|
}
|
|
|
|
h.writeJSON(w, http.StatusOK, nil)
|
|
return nil
|
|
}
|
|
|
|
// renamedTableAttributes are the catalog attributes a rename carries to the new
|
|
// name and clears from the old one.
|
|
var renamedTableAttributes = []string{
|
|
ExtendedKeyMetadata,
|
|
ExtendedKeyMetadataVersion,
|
|
ExtendedKeyPolicy,
|
|
ExtendedKeyTags,
|
|
ExtendedKeyMaintenance,
|
|
ExtendedKeyMaintenanceStatus,
|
|
ExtendedKeyEntryType,
|
|
}
|
|
|
|
// catalogEntryKind describes the entry a rename operates on, so tables and
|
|
// views share one implementation of the catalog-only move.
|
|
type catalogEntryKind struct {
|
|
entryType string
|
|
noun string
|
|
renameOp string
|
|
createOp string
|
|
readOp string
|
|
// tagsForRead mirrors whether the read handler supplies resource tags to
|
|
// the policy check, so the denied-write visibility test cannot accept a
|
|
// tag-conditioned allow the real read would not evaluate.
|
|
tagsForRead bool
|
|
notFoundCode string
|
|
existsCode string
|
|
// resourceARN builds the ARN a policy scoped to this entry would name, so a
|
|
// view is authorized against its view ARN and not a table ARN.
|
|
resourceARN func(h *S3TablesHandler, ownerAccountID, bucketName, id string) string
|
|
}
|
|
|
|
var (
|
|
tableEntryKind = catalogEntryKind{
|
|
entryType: EntryTypeTable,
|
|
noun: "table",
|
|
renameOp: "RenameTable",
|
|
createOp: "CreateTable",
|
|
readOp: "GetTable",
|
|
tagsForRead: true,
|
|
notFoundCode: ErrCodeNoSuchTable,
|
|
existsCode: ErrCodeTableAlreadyExists,
|
|
resourceARN: func(h *S3TablesHandler, ownerAccountID, bucketName, id string) string {
|
|
return h.generateTableARN(ownerAccountID, bucketName, id)
|
|
},
|
|
}
|
|
viewEntryKind = catalogEntryKind{
|
|
entryType: EntryTypeView,
|
|
noun: "view",
|
|
renameOp: "RenameView",
|
|
createOp: "CreateView",
|
|
readOp: "GetView",
|
|
tagsForRead: false,
|
|
notFoundCode: ErrCodeNoSuchView,
|
|
existsCode: ErrCodeViewAlreadyExists,
|
|
resourceARN: func(h *S3TablesHandler, ownerAccountID, bucketName, id string) string {
|
|
return h.generateViewARN(ownerAccountID, bucketName, id)
|
|
},
|
|
}
|
|
)
|
|
|
|
// readTableMetadata returns the entry's table-metadata xattr. A view stored at
|
|
// the same name is reported absent, the way GetTable and the renamer's
|
|
// entry-type check already treat kind mismatches.
|
|
func (h *S3TablesHandler) readTableMetadata(ctx context.Context, client filer_pb.SeaweedFilerClient, tablePath string) ([]byte, error) {
|
|
entry, err := h.lookupEntry(ctx, client, tablePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return tableMetadata(entry)
|
|
}
|
|
|
|
// tableMetadata extracts the table-metadata xattr from an entry. A view stored
|
|
// at the same name is reported absent, the way GetTable and the renamer's
|
|
// entry-type check already treat kind mismatches.
|
|
func tableMetadata(entry *filer_pb.Entry) ([]byte, error) {
|
|
if EntryType(entry.Extended) == EntryTypeView {
|
|
return nil, filer_pb.ErrNotFound
|
|
}
|
|
data, ok := entry.Extended[ExtendedKeyMetadata]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: %s", ErrAttributeNotFound, ExtendedKeyMetadata)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// canReadEntry reports whether the principal may read the catalog entry a
|
|
// denied write would touch. A denied write is then answered with 403 — the
|
|
// refusal leaks nothing a reader could not load anyway — while a caller that
|
|
// cannot see the entry keeps the not-found veil.
|
|
func (h *S3TablesHandler) canReadEntry(readOp, principal, tableOwner, bucketOwner, tablePolicy, bucketPolicy, tableARN, bucketARN, bucketName, namespace, name string, bucketTags, tableTags map[string]string, identityActions []string, r *http.Request) bool {
|
|
ctx := &PolicyContext{
|
|
TrustedIssuers: h.trustedIssuers(r),
|
|
IsLocalAccountID: h.localAccountIDs,
|
|
TableBucketName: bucketName,
|
|
Namespace: namespace,
|
|
TableName: name,
|
|
TableBucketTags: bucketTags,
|
|
ResourceTags: tableTags,
|
|
IdentityActions: identityActions,
|
|
DefaultAllow: h.defaultAllowFor(r),
|
|
}
|
|
return CheckTableOrBucketPermission(readOp, principal,
|
|
tableOwner, tablePolicy, tableARN,
|
|
bucketOwner, bucketPolicy, bucketARN, ctx)
|
|
}
|
|
|
|
// handleRenameTable moves a table's catalog entry to a new namespace/name within
|
|
// the same bucket. It is catalog-only: the metadata.json and data files stay put,
|
|
// the destination keeps the source's MetadataLocation, and the source name is
|
|
// soft-deleted in place (its catalog xattrs are dropped, its data is left intact).
|
|
func (h *S3TablesHandler) handleRenameTable(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
|
|
return h.renameCatalogEntry(w, r, filerClient, tableEntryKind)
|
|
}
|
|
|
|
// handleRenameView is handleRenameTable for views, which live in the same
|
|
// namespace directory under the same name rules.
|
|
func (h *S3TablesHandler) handleRenameView(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
|
|
return h.renameCatalogEntry(w, r, filerClient, viewEntryKind)
|
|
}
|
|
|
|
func (h *S3TablesHandler) renameCatalogEntry(w http.ResponseWriter, r *http.Request, filerClient FilerClient, kind catalogEntryKind) error {
|
|
var req RenameTableRequest
|
|
if err := h.readRequestBody(r, &req); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
|
|
if req.TableBucketARN == "" || len(req.SourceNamespace) == 0 || req.SourceName == "" || len(req.DestNamespace) == 0 || req.DestName == "" {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "tableBucketARN, sourceNamespace, sourceName, destNamespace, and destName are required")
|
|
return fmt.Errorf("missing required parameters")
|
|
}
|
|
|
|
bucketName, err := parseBucketNameFromARN(req.TableBucketARN)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
|
|
srcNamespace, err := validateNamespace(req.SourceNamespace)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
srcName, err := validateTableName(req.SourceName)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
destNamespace, err := validateNamespace(req.DestNamespace)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
destName, err := validateTableName(req.DestName)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
|
|
srcPath := GetTablePath(bucketName, srcNamespace, srcName)
|
|
destPath := GetTablePath(bucketName, destNamespace, destName)
|
|
|
|
var metadata tableMetadataInternal
|
|
var metadataVersionXattr []byte
|
|
var maintenanceXattr []byte
|
|
var maintenanceStatusXattr []byte
|
|
var tablePolicy string
|
|
var bucketPolicy string
|
|
var bucketTags map[string]string
|
|
var tableTags map[string]string
|
|
var bucketMetadata tableBucketMetadata
|
|
var srcExtended map[string][]byte
|
|
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
data, err := h.getExtendedAttribute(r.Context(), client, srcPath, ExtendedKeyMetadata)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(data, &metadata); err != nil {
|
|
return fmt.Errorf("failed to unmarshal table metadata: %w", err)
|
|
}
|
|
|
|
if versionData, err := h.getExtendedAttribute(r.Context(), client, srcPath, ExtendedKeyMetadataVersion); err == nil {
|
|
metadataVersionXattr = versionData
|
|
} else if !errors.Is(err, ErrAttributeNotFound) {
|
|
return fmt.Errorf("failed to fetch metadata version: %w", err)
|
|
}
|
|
|
|
policyData, err := h.getExtendedAttribute(r.Context(), client, srcPath, ExtendedKeyPolicy)
|
|
if err == nil {
|
|
tablePolicy = string(policyData)
|
|
} else if !errors.Is(err, ErrAttributeNotFound) {
|
|
return fmt.Errorf("failed to fetch table policy: %w", err)
|
|
}
|
|
tableTags, err = h.readTags(r.Context(), client, srcPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
srcEntry, err := h.lookupEntry(r.Context(), client, srcPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
srcExtended = srcEntry.Extended
|
|
|
|
// The maintenance configuration and its last-run status belong to the
|
|
// table, so they move with it; leaving them behind re-enables
|
|
// maintenance under the new name and leaks the old settings onto
|
|
// whatever is created at the old one.
|
|
for _, attr := range []struct {
|
|
key string
|
|
dest *[]byte
|
|
}{
|
|
{ExtendedKeyMaintenance, &maintenanceXattr},
|
|
{ExtendedKeyMaintenanceStatus, &maintenanceStatusXattr},
|
|
} {
|
|
data, err := h.getExtendedAttribute(r.Context(), client, srcPath, attr.key)
|
|
if err == nil {
|
|
*attr.dest = data
|
|
} else if !errors.Is(err, ErrAttributeNotFound) {
|
|
return fmt.Errorf("failed to fetch %s: %w", attr.key, 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 {
|
|
bucketPolicy = string(policyData)
|
|
} else if !errors.Is(err, ErrAttributeNotFound) {
|
|
return fmt.Errorf("failed to fetch bucket policy: %w", err)
|
|
}
|
|
bucketTags, err = h.readTags(r.Context(), client, bucketPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrAttributeNotFound) {
|
|
h.writeError(w, http.StatusNotFound, kind.notFoundCode, fmt.Sprintf("%s %s not found", kind.noun, srcName))
|
|
} else {
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to check %s: %v", kind.noun, err))
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Tables and views share the namespace directory, so a rename must not pick
|
|
// up the other kind under the same name.
|
|
if EntryType(srcExtended) != kind.entryType {
|
|
h.writeError(w, http.StatusNotFound, kind.notFoundCode, fmt.Sprintf("%s %s not found", kind.noun, srcName))
|
|
return fmt.Errorf("%s %s not found", kind.noun, srcName)
|
|
}
|
|
|
|
tableARN := kind.resourceARN(h, metadata.OwnerAccountID, bucketName, srcNamespace+"/"+srcName)
|
|
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
|
|
principal := h.getAccountID(r)
|
|
identityActions := getIdentityActions(r)
|
|
allowed := CheckTableOrBucketPermission(kind.renameOp, principal,
|
|
metadata.OwnerAccountID, tablePolicy, tableARN,
|
|
bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
|
|
TrustedIssuers: h.trustedIssuers(r),
|
|
IsLocalAccountID: h.localAccountIDs,
|
|
TableBucketName: bucketName,
|
|
Namespace: srcNamespace,
|
|
TableName: srcName,
|
|
TableBucketTags: bucketTags,
|
|
ResourceTags: tableTags,
|
|
IdentityActions: identityActions,
|
|
DefaultAllow: h.defaultAllowFor(r),
|
|
})
|
|
if !allowed {
|
|
readTags := tableTags
|
|
if !kind.tagsForRead {
|
|
readTags = nil
|
|
}
|
|
if h.canReadEntry(kind.readOp, principal, metadata.OwnerAccountID, bucketMetadata.OwnerAccountID, tablePolicy, bucketPolicy, tableARN, bucketARN, bucketName, srcNamespace, srcName, bucketTags, readTags, identityActions, r) {
|
|
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to rename "+kind.noun)
|
|
} else {
|
|
h.writeError(w, http.StatusNotFound, kind.notFoundCode, fmt.Sprintf("%s %s not found", kind.noun, srcName))
|
|
}
|
|
return NewAuthError(kind.renameOp, principal, "not authorized to rename "+kind.noun)
|
|
}
|
|
|
|
// Require the destination namespace to exist. Whether the destination name
|
|
// is taken is only recorded here; reporting the conflict before the
|
|
// destination authorization check would disclose it to denied callers.
|
|
destNamespacePath := GetNamespacePath(bucketName, destNamespace)
|
|
var destNamespaceMetadata namespaceMetadata
|
|
var destNamespacePolicy string
|
|
var destExists bool
|
|
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
data, err := h.getExtendedAttribute(r.Context(), client, destNamespacePath, ExtendedKeyMetadata)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(data, &destNamespaceMetadata); err != nil {
|
|
return fmt.Errorf("failed to unmarshal destination namespace metadata: %w", err)
|
|
}
|
|
policyData, err := h.getExtendedAttribute(r.Context(), client, destNamespacePath, ExtendedKeyPolicy)
|
|
if err == nil {
|
|
destNamespacePolicy = string(policyData)
|
|
} else if !errors.Is(err, ErrAttributeNotFound) {
|
|
return fmt.Errorf("failed to fetch destination namespace policy: %w", err)
|
|
}
|
|
if _, err := h.getExtendedAttribute(r.Context(), client, destPath, ExtendedKeyMetadata); err == nil {
|
|
destExists = true
|
|
} else if !errors.Is(err, filer_pb.ErrNotFound) && !errors.Is(err, ErrAttributeNotFound) {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
if errors.Is(err, filer_pb.ErrNotFound) {
|
|
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchNamespace, fmt.Sprintf("namespace %s not found", destNamespace))
|
|
} else {
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to check destination: %v", err))
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Renaming places the table into the destination namespace, so the principal
|
|
// must also be allowed to create a table there (the source check alone lets a
|
|
// caller move tables into namespaces they don't control). Denials report the
|
|
// same not-found as a missing destination namespace.
|
|
if !CheckTableOrBucketPermission(kind.createOp, principal,
|
|
destNamespaceMetadata.OwnerAccountID, destNamespacePolicy, bucketARN,
|
|
bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
|
|
TrustedIssuers: h.trustedIssuers(r),
|
|
IsLocalAccountID: h.localAccountIDs,
|
|
TableBucketName: bucketName,
|
|
Namespace: destNamespace,
|
|
TableName: destName,
|
|
TableBucketTags: bucketTags,
|
|
IdentityActions: identityActions,
|
|
DefaultAllow: h.defaultAllowFor(r),
|
|
}) {
|
|
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchNamespace, fmt.Sprintf("namespace %s not found", destNamespace))
|
|
return NewAuthError(kind.renameOp, principal, "not authorized to create "+kind.noun+" in the destination namespace")
|
|
}
|
|
|
|
if destExists {
|
|
h.writeError(w, http.StatusConflict, kind.existsCode, fmt.Sprintf("%s %s already exists", kind.noun, destName))
|
|
return ErrTableAlreadyExists
|
|
}
|
|
|
|
metadata.Name = destName
|
|
metadata.Namespace = destNamespace
|
|
metadata.ModifiedAt = time.Now()
|
|
|
|
metadataBytes, err := json.Marshal(&metadata)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to marshal "+kind.noun+" metadata")
|
|
return fmt.Errorf("failed to marshal metadata: %w", err)
|
|
}
|
|
|
|
// Claim the source record before publishing the destination: a delete or
|
|
// a second rename that wins the claim backs out here — while no
|
|
// destination entry exists — instead of leaving one behind pointing at
|
|
// data a delete already removed.
|
|
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
if err := h.claimCatalogRecord(r.Context(), client, srcPath, snapshotExtended(srcExtended), "rename"); err != nil {
|
|
return err
|
|
}
|
|
claimed := snapshotExtended(srcExtended)
|
|
claimed[ExtendedKeyDeleting] = []byte("rename")
|
|
releaseClaim := func(err error) error {
|
|
if relErr := h.releaseCatalogRecord(r.Context(), client, srcPath, claimed); relErr != nil {
|
|
glog.Warningf("rename %s: release claim: %v", srcPath, relErr)
|
|
}
|
|
return err
|
|
}
|
|
if err := h.createDirectory(r.Context(), client, destPath); err != nil {
|
|
return releaseClaim(err)
|
|
}
|
|
if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyMetadata, metadataBytes); err != nil {
|
|
return releaseClaim(err)
|
|
}
|
|
if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyEntryType, []byte(kind.entryType)); err != nil {
|
|
return releaseClaim(err)
|
|
}
|
|
if len(metadataVersionXattr) > 0 {
|
|
if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyMetadataVersion, metadataVersionXattr); err != nil {
|
|
return releaseClaim(err)
|
|
}
|
|
}
|
|
if len(tableTags) > 0 {
|
|
tagsBytes, err := json.Marshal(tableTags)
|
|
if err != nil {
|
|
return releaseClaim(fmt.Errorf("failed to marshal tags: %w", err))
|
|
}
|
|
if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyTags, tagsBytes); err != nil {
|
|
return releaseClaim(err)
|
|
}
|
|
}
|
|
if tablePolicy != "" {
|
|
if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyPolicy, []byte(tablePolicy)); err != nil {
|
|
return releaseClaim(err)
|
|
}
|
|
}
|
|
if len(maintenanceXattr) > 0 {
|
|
if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyMaintenance, maintenanceXattr); err != nil {
|
|
return releaseClaim(err)
|
|
}
|
|
}
|
|
if len(maintenanceStatusXattr) > 0 {
|
|
if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyMaintenanceStatus, maintenanceStatusXattr); err != nil {
|
|
return releaseClaim(err)
|
|
}
|
|
}
|
|
// Soft-delete the source catalog identity in place: drop its catalog xattrs
|
|
// so the name stops resolving while the metadata/ and data/ children stay put
|
|
// (manifests embed absolute paths, so the data must not move).
|
|
err := h.removeExtendedAttributesIf(r.Context(), client, srcPath, claimed,
|
|
append(append([]string{}, renamedTableAttributes...), ExtendedKeyDeleting)...)
|
|
if err != nil {
|
|
return releaseClaim(err)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
if errors.Is(err, ErrConcurrentUpdate) {
|
|
h.writeError(w, http.StatusConflict, ErrCodeConflict, kind.noun+" changed during rename, retry the request")
|
|
return err
|
|
}
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to rename "+kind.noun)
|
|
return err
|
|
}
|
|
|
|
h.writeJSON(w, http.StatusOK, &RenameTableResponse{
|
|
TableARN: kind.resourceARN(h, metadata.OwnerAccountID, bucketName, destNamespace+"/"+destName),
|
|
MetadataLocation: metadata.MetadataLocation,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// updateTableTarget is the table state an update was authorized against: the
|
|
// stored values the optimistic-concurrency check compares and the fields the
|
|
// response echoes back.
|
|
type updateTableTarget struct {
|
|
bucketName string
|
|
namespaceName string
|
|
tableName string
|
|
tablePath string
|
|
tableARN string
|
|
metadata tableMetadataInternal
|
|
storedMetadata []byte
|
|
storedPolicy []byte
|
|
}
|
|
|
|
// authorizeUpdateTable validates the names an update names, loads the table
|
|
// and the policies governing it, and checks the caller may update it, writing
|
|
// the error response itself. Credential vending asks the same question without
|
|
// updating anything, so it runs this gate through Manager.AuthorizeUpdateTable.
|
|
func (h *S3TablesHandler) authorizeUpdateTable(w http.ResponseWriter, r *http.Request, filerClient FilerClient, req *UpdateTableRequest) (*updateTableTarget, error) {
|
|
if req.TableBucketARN == "" || len(req.Namespace) == 0 || req.Name == "" {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "tableBucketARN, namespace, and name are required")
|
|
return nil, fmt.Errorf("missing required parameters")
|
|
}
|
|
|
|
namespaceName, err := validateNamespace(req.Namespace)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return nil, err
|
|
}
|
|
|
|
bucketName, err := parseBucketNameFromARN(req.TableBucketARN)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return nil, err
|
|
}
|
|
|
|
tableName, err := validateTableName(req.Name)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return nil, err
|
|
}
|
|
|
|
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName, namespaceName); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return nil, err
|
|
}
|
|
|
|
tablePath := GetTablePath(bucketName, namespaceName, tableName)
|
|
|
|
// Load existing metadata and policies for authorization
|
|
var metadata tableMetadataInternal
|
|
var storedMetadata []byte
|
|
var storedPolicy []byte
|
|
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 {
|
|
// 1. Get Table Metadata
|
|
data, err := h.readTableMetadata(r.Context(), client, tablePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(data, &metadata); err != nil {
|
|
return fmt.Errorf("failed to unmarshal table metadata: %w", err)
|
|
}
|
|
storedMetadata = data
|
|
|
|
// 2. Get Table Policy & Tags
|
|
policyData, err := h.getExtendedAttribute(r.Context(), client, tablePath, ExtendedKeyPolicy)
|
|
if err == nil {
|
|
tablePolicy = string(policyData)
|
|
storedPolicy = policyData
|
|
} else if !errors.Is(err, ErrAttributeNotFound) {
|
|
return fmt.Errorf("failed to fetch table policy: %w", err)
|
|
}
|
|
tableTags, err = h.readTags(r.Context(), client, tablePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 3. Get Bucket Metadata, Policy & Tags
|
|
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 {
|
|
bucketPolicy = string(policyData)
|
|
} else if !errors.Is(err, ErrAttributeNotFound) {
|
|
return fmt.Errorf("failed to fetch bucket policy: %w", err)
|
|
}
|
|
bucketTags, err = h.readTags(r.Context(), client, bucketPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
if errors.Is(err, filer_pb.ErrNotFound) {
|
|
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchTable, "table not found")
|
|
} else {
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, err.Error())
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Authorization Check
|
|
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespaceName+"/"+tableName)
|
|
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
|
|
principal := h.getAccountID(r)
|
|
identityActions := getIdentityActions(r)
|
|
|
|
allowed := CheckTableOrBucketPermission("UpdateTable", principal,
|
|
metadata.OwnerAccountID, tablePolicy, tableARN,
|
|
bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
|
|
TrustedIssuers: h.trustedIssuers(r),
|
|
IsLocalAccountID: h.localAccountIDs,
|
|
TableBucketName: bucketName,
|
|
Namespace: namespaceName,
|
|
TableName: tableName,
|
|
TableBucketTags: bucketTags,
|
|
ResourceTags: tableTags,
|
|
IdentityActions: identityActions,
|
|
DefaultAllow: h.defaultAllowFor(r),
|
|
})
|
|
|
|
if !allowed {
|
|
if h.canReadEntry("GetTable", principal, metadata.OwnerAccountID, bucketMetadata.OwnerAccountID, tablePolicy, bucketPolicy, tableARN, bucketARN, bucketName, namespaceName, tableName, bucketTags, tableTags, identityActions, r) {
|
|
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to update table")
|
|
} else {
|
|
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchTable, "table not found")
|
|
}
|
|
return nil, NewAuthError("UpdateTable", principal, "not authorized to update table")
|
|
}
|
|
|
|
return &updateTableTarget{
|
|
bucketName: bucketName,
|
|
namespaceName: namespaceName,
|
|
tableName: tableName,
|
|
tablePath: tablePath,
|
|
tableARN: tableARN,
|
|
metadata: metadata,
|
|
storedMetadata: storedMetadata,
|
|
storedPolicy: storedPolicy,
|
|
}, nil
|
|
}
|
|
|
|
// handleUpdateTable updates table metadata
|
|
func (h *S3TablesHandler) handleUpdateTable(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
|
|
var req UpdateTableRequest
|
|
if err := h.readRequestBody(r, &req); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return err
|
|
}
|
|
|
|
target, err := h.authorizeUpdateTable(w, r, filerClient, &req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
metadata := target.metadata
|
|
|
|
// Check version token if provided
|
|
if req.VersionToken != "" && req.VersionToken != metadata.VersionToken {
|
|
h.writeError(w, http.StatusConflict, ErrCodeConflict, "Version token mismatch")
|
|
return ErrVersionTokenMismatch
|
|
}
|
|
|
|
// Update metadata
|
|
if req.Metadata != nil {
|
|
if metadata.Metadata == nil {
|
|
metadata.Metadata = &TableMetadata{}
|
|
}
|
|
if req.Metadata.Iceberg != nil {
|
|
if metadata.Metadata.Iceberg == nil {
|
|
metadata.Metadata.Iceberg = &IcebergMetadata{}
|
|
}
|
|
if req.Metadata.Iceberg.TableUUID != "" {
|
|
metadata.Metadata.Iceberg.TableUUID = req.Metadata.Iceberg.TableUUID
|
|
}
|
|
}
|
|
if len(req.Metadata.FullMetadata) > 0 {
|
|
metadata.Metadata.FullMetadata = req.Metadata.FullMetadata
|
|
}
|
|
}
|
|
if req.MetadataLocation != "" {
|
|
metadata.MetadataLocation = req.MetadataLocation
|
|
}
|
|
if req.MetadataVersion > 0 {
|
|
metadata.MetadataVersion = req.MetadataVersion
|
|
} else if metadata.MetadataVersion == 0 {
|
|
metadata.MetadataVersion = 1
|
|
}
|
|
metadata.ModifiedAt = time.Now()
|
|
metadata.VersionToken = generateVersionToken()
|
|
|
|
metadataBytes, err := json.Marshal(metadata)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to marshal metadata")
|
|
return err
|
|
}
|
|
|
|
// Conditional on the metadata this request read and authorized against:
|
|
// two commits that both passed the version-token check would otherwise each
|
|
// write their own metadata and the later one would drop the earlier
|
|
// snapshot. mutateEntryExtended retries on a changed entry, so the check
|
|
// lives in the mutation, where it sees the value that is current now.
|
|
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
return h.mutateEntryExtended(r.Context(), client, target.tablePath, func(extended map[string][]byte) error {
|
|
if !bytes.Equal(extended[ExtendedKeyMetadata], target.storedMetadata) {
|
|
return fmt.Errorf("%w: %s", ErrConcurrentUpdate, ExtendedKeyMetadata)
|
|
}
|
|
// The policy this request was authorized against must still be the
|
|
// one in force: an administrator restricting it mid-commit should
|
|
// send the caller back through authorization, not have its decision
|
|
// applied afterwards.
|
|
if !bytes.Equal(extended[ExtendedKeyPolicy], target.storedPolicy) {
|
|
return fmt.Errorf("%w: %s", ErrConcurrentUpdate, ExtendedKeyPolicy)
|
|
}
|
|
extended[ExtendedKeyMetadata] = metadataBytes
|
|
return nil
|
|
})
|
|
})
|
|
|
|
if err != nil {
|
|
if errors.Is(err, ErrConcurrentUpdate) {
|
|
h.writeError(w, http.StatusConflict, ErrCodeConflict, "table was updated concurrently")
|
|
return ErrVersionTokenMismatch
|
|
}
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to update metadata")
|
|
return err
|
|
}
|
|
|
|
h.writeJSON(w, http.StatusOK, &UpdateTableResponse{
|
|
TableARN: target.tableARN,
|
|
MetadataLocation: metadata.MetadataLocation,
|
|
VersionToken: metadata.VersionToken,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// createTableTarget is the namespace a create or register resolved to, once the
|
|
// caller has been authorized to put a table there.
|
|
type createTableTarget struct {
|
|
bucketName string
|
|
namespaceName string
|
|
tableName string
|
|
bucketFormat string
|
|
ownerAccountID string
|
|
}
|
|
|
|
// authorizeCreateTable validates the names a create names and checks the caller
|
|
// may create a table in that namespace, writing the error response itself.
|
|
// Deferred creates (Iceberg stage-create) write into the table bucket before any
|
|
// table is registered, so they run this same gate first through
|
|
// Manager.AuthorizeCreateTable.
|
|
func (h *S3TablesHandler) authorizeCreateTable(w http.ResponseWriter, r *http.Request, filerClient FilerClient, tableBucketARN string, namespace []string, name string, requestTags map[string]string, allowMissingNamespace bool) (*createTableTarget, error) {
|
|
if tableBucketARN == "" {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "tableBucketARN is required")
|
|
return nil, fmt.Errorf("tableBucketARN is required")
|
|
}
|
|
|
|
namespaceName, err := validateNamespace(namespace)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return nil, err
|
|
}
|
|
|
|
if name == "" {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "name is required")
|
|
return nil, fmt.Errorf("name is required")
|
|
}
|
|
|
|
bucketName, err := parseBucketNameFromARN(tableBucketARN)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return nil, err
|
|
}
|
|
|
|
tableName, err := validateTableName(name)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
|
return nil, err
|
|
}
|
|
|
|
// Check if namespace exists
|
|
namespacePath := GetNamespacePath(bucketName, namespaceName)
|
|
nsMetadata, err := h.loadNamespaceMetadata(r.Context(), filerClient, bucketName, namespaceName)
|
|
namespaceExists := err == nil
|
|
if err != nil {
|
|
if errors.Is(err, filer_pb.ErrNotFound) && allowMissingNamespace {
|
|
// An implicit namespace can be authorized before it is declared:
|
|
// evaluate the grant against the bucket alone.
|
|
err = nil
|
|
} else {
|
|
if errors.Is(err, filer_pb.ErrNotFound) {
|
|
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchNamespace, fmt.Sprintf("namespace %s not found", namespaceName))
|
|
} else {
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to check namespace: %v", err))
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Authorize table creation using policy framework (namespace + bucket policies)
|
|
accountID := h.getAccountID(r)
|
|
bucketPath := GetTableBucketPath(bucketName)
|
|
namespacePolicy := ""
|
|
bucketPolicy := ""
|
|
bucketTags := map[string]string{}
|
|
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)
|
|
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: %v", err)
|
|
}
|
|
|
|
// Fetch namespace policy if it exists
|
|
if namespaceExists {
|
|
policyData, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyPolicy)
|
|
if err == nil {
|
|
namespacePolicy = string(policyData)
|
|
} else if !errors.Is(err, ErrAttributeNotFound) {
|
|
return fmt.Errorf("failed to fetch namespace policy: %v", err)
|
|
}
|
|
}
|
|
var policyData []byte
|
|
|
|
// Fetch bucket policy if it exists
|
|
policyData, err = h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyPolicy)
|
|
if err == nil {
|
|
bucketPolicy = string(policyData)
|
|
} 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
|
|
})
|
|
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to fetch policies: %v", err))
|
|
return nil, err
|
|
}
|
|
|
|
if !namespaceExists {
|
|
// The implicit namespace has no record yet: authorize it as the
|
|
// bucket owner's instead of dereferencing the absent metadata.
|
|
nsMetadata = &namespaceMetadata{OwnerAccountID: bucketMetadata.OwnerAccountID}
|
|
}
|
|
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
|
|
identityActions := getIdentityActions(r)
|
|
policyContext := &PolicyContext{
|
|
TrustedIssuers: h.trustedIssuers(r),
|
|
IsLocalAccountID: h.localAccountIDs,
|
|
TableBucketName: bucketName,
|
|
Namespace: namespaceName,
|
|
TableName: tableName,
|
|
RequestTags: requestTags,
|
|
TagKeys: mapKeys(requestTags),
|
|
TableBucketTags: bucketTags,
|
|
IdentityActions: identityActions,
|
|
DefaultAllow: h.defaultAllowFor(r),
|
|
}
|
|
if !CheckTableOrBucketPermission("CreateTable", accountID,
|
|
nsMetadata.OwnerAccountID, namespacePolicy, bucketARN,
|
|
bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, policyContext) {
|
|
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to create table in this namespace")
|
|
return nil, ErrAccessDenied
|
|
}
|
|
|
|
return &createTableTarget{
|
|
bucketName: bucketName,
|
|
namespaceName: namespaceName,
|
|
tableName: tableName,
|
|
bucketFormat: bucketMetadata.Format,
|
|
ownerAccountID: nsMetadata.OwnerAccountID,
|
|
}, nil
|
|
}
|