package s3tables import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "strings" "time" "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) 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 } // 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) } 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 } // Set metadata as extended attribute if err := h.setExtendedAttribute(r.Context(), client, tablePath, ExtendedKeyMetadata, metadataBytes); err != nil { return err } // Tag the entry as a table so view listings can exclude it. if err := h.setExtendedAttribute(r.Context(), client, tablePath, ExtendedKeyEntryType, []byte(EntryTypeTable)); err != nil { return err } // Set tags if provided if len(req.Tags) > 0 { tagsBytes, err := json.Marshal(req.Tags) if err != nil { return fmt.Errorf("failed to marshal tags: %w", err) } if err := h.setExtendedAttribute(r.Context(), client, tablePath, ExtendedKeyTags, tagsBytes); err != nil { return err } } return nil }) if err != nil { 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) if err != nil { return err } bucketName, namespaceName, tableName := target.bucketName, target.namespaceName, target.tableName 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.setExtendedAttribute(r.Context(), client, tablePath, ExtendedKeyMetadata, metadataBytes) }) if err != nil { 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 { entry, err := h.lookupEntry(r.Context(), client, tablePath) if err != nil { return err } if EntryType(entry.Extended) == EntryTypeView { return filer_pb.ErrNotFound } data, ok := entry.Extended[ExtendedKeyMetadata] if !ok { return fmt.Errorf("%w: %s", ErrAttributeNotFound, ExtendedKeyMetadata) } 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) tableAllowed := CheckPermissionWithContext("GetTable", accountID, metadata.OwnerAccountID, tablePolicy, tableARN, &PolicyContext{ TableBucketName: bucketName, Namespace: namespace, TableName: tableName, TableBucketTags: bucketTags, ResourceTags: tableTags, IdentityActions: identityActions, DefaultAllow: h.defaultAllowFor(r), }) bucketAllowed := CheckPermissionWithContext("GetTable", accountID, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{ TableBucketName: bucketName, Namespace: namespace, TableName: tableName, TableBucketTags: bucketTags, ResourceTags: tableTags, IdentityActions: identityActions, DefaultAllow: h.defaultAllowFor(r), }) if !tableAllowed && !bucketAllowed { 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 data, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyMetadata) if err != nil { return err // Not Found handled by caller } 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) { 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, 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) } 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) nsAllowed := CheckPermissionWithContext("ListTables", accountID, nsMeta.OwnerAccountID, namespacePolicy, bucketARN, &PolicyContext{ TableBucketName: bucketName, Namespace: namespaceName, TableBucketTags: bucketTags, IdentityActions: identityActions, DefaultAllow: h.defaultAllowFor(r), }) bucketAllowed := CheckPermissionWithContext("ListTables", accountID, bucketMeta.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{ TableBucketName: bucketName, Namespace: namespaceName, TableBucketTags: bucketTags, IdentityActions: identityActions, DefaultAllow: h.defaultAllowFor(r), }) if !nsAllowed && !bucketAllowed { 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 { return fmt.Errorf("failed to fetch bucket metadata: %w", err) } 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{ 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 err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { data, err := h.getExtendedAttribute(r.Context(), client, tablePath, 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 req.VersionToken != "" { if metadata.VersionToken != req.VersionToken { return ErrVersionTokenMismatch } } // 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) tableAllowed := CheckPermissionWithContext("DeleteTable", principal, metadata.OwnerAccountID, tablePolicy, tableARN, &PolicyContext{ TableBucketName: bucketName, Namespace: namespaceName, TableName: tableName, TableBucketTags: bucketTags, ResourceTags: tableTags, IdentityActions: identityActions, DefaultAllow: h.defaultAllowFor(r), }) bucketAllowed := CheckPermissionWithContext("DeleteTable", principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{ TableBucketName: bucketName, Namespace: namespaceName, TableName: tableName, TableBucketTags: bucketTags, ResourceTags: tableTags, IdentityActions: identityActions, DefaultAllow: h.defaultAllowFor(r), }) if !tableAllowed && !bucketAllowed { h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to delete table") return NewAuthError("DeleteTable", principal, "not authorized to delete table") } // 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. 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 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. return h.deleteDirectory(r.Context(), client, tablePath) }) if err != nil { 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 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", 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", notFoundCode: ErrCodeNoSuchView, existsCode: ErrCodeViewAlreadyExists, resourceARN: func(h *S3TablesHandler, ownerAccountID, bucketName, id string) string { return h.generateViewARN(ownerAccountID, bucketName, id) }, } ) // 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 // The values the rename copies. The source is only cleared while it still // holds exactly these, so a write that lands mid-rename is not deleted here // after having missed the copy to the destination. var copiedFromSource map[string][]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 copiedFromSource = make(map[string][]byte, len(renamedTableAttributes)) for _, key := range renamedTableAttributes { copiedFromSource[key] = srcEntry.Extended[key] } // 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) tableAllowed := CheckPermissionWithContext(kind.renameOp, principal, metadata.OwnerAccountID, tablePolicy, tableARN, &PolicyContext{ TableBucketName: bucketName, Namespace: srcNamespace, TableName: srcName, TableBucketTags: bucketTags, ResourceTags: tableTags, IdentityActions: identityActions, DefaultAllow: h.defaultAllowFor(r), }) bucketAllowed := CheckPermissionWithContext(kind.renameOp, principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{ TableBucketName: bucketName, Namespace: srcNamespace, TableName: srcName, TableBucketTags: bucketTags, ResourceTags: tableTags, IdentityActions: identityActions, DefaultAllow: h.defaultAllowFor(r), }) if !tableAllowed && !bucketAllowed { h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to rename "+kind.noun) return NewAuthError(kind.renameOp, principal, "not authorized to rename "+kind.noun) } // Require the destination namespace to exist and the destination table to be free. destNamespacePath := GetNamespacePath(bucketName, destNamespace) var destNamespaceMetadata namespaceMetadata var destNamespacePolicy string 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 { return ErrTableAlreadyExists } else if !errors.Is(err, filer_pb.ErrNotFound) && !errors.Is(err, ErrAttributeNotFound) { return err } return nil }) if err != nil { if errors.Is(err, ErrTableAlreadyExists) { h.writeError(w, http.StatusConflict, kind.existsCode, fmt.Sprintf("%s %s already exists", kind.noun, destName)) } else 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). destNamespaceAllowed := CheckPermissionWithContext(kind.createOp, principal, destNamespaceMetadata.OwnerAccountID, destNamespacePolicy, bucketARN, &PolicyContext{ TableBucketName: bucketName, Namespace: destNamespace, TableName: destName, TableBucketTags: bucketTags, IdentityActions: identityActions, DefaultAllow: h.defaultAllowFor(r), }) destBucketAllowed := CheckPermissionWithContext(kind.createOp, principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{ TableBucketName: bucketName, Namespace: destNamespace, TableName: destName, TableBucketTags: bucketTags, IdentityActions: identityActions, DefaultAllow: h.defaultAllowFor(r), }) if !destNamespaceAllowed && !destBucketAllowed { h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to create "+kind.noun+" in the destination namespace") return NewAuthError(kind.renameOp, principal, "not authorized to create "+kind.noun+" in the destination namespace") } 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) } // Write the destination entry before deleting the source so a mid-rename // failure can never lose the table. err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { if err := h.createDirectory(r.Context(), client, destPath); err != nil { return err } if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyMetadata, metadataBytes); err != nil { return err } if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyEntryType, []byte(kind.entryType)); err != nil { return err } if len(metadataVersionXattr) > 0 { if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyMetadataVersion, metadataVersionXattr); err != nil { return err } } if len(tableTags) > 0 { tagsBytes, err := json.Marshal(tableTags) if err != nil { return fmt.Errorf("failed to marshal tags: %w", err) } if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyTags, tagsBytes); err != nil { return err } } if tablePolicy != "" { if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyPolicy, []byte(tablePolicy)); err != nil { return err } } if len(maintenanceXattr) > 0 { if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyMaintenance, maintenanceXattr); err != nil { return err } } if len(maintenanceStatusXattr) > 0 { if err := h.setExtendedAttribute(r.Context(), client, destPath, ExtendedKeyMaintenanceStatus, maintenanceStatusXattr); err != nil { return 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). return h.removeExtendedAttributesIf(r.Context(), client, srcPath, copiedFromSource, renamedTableAttributes...) }) 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 } // 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 } 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) // 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.getExtendedAttribute(r.Context(), client, tablePath, ExtendedKeyMetadata) 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 err } // Authorization Check tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespaceName+"/"+tableName) bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName) principal := h.getAccountID(r) identityActions := getIdentityActions(r) tableAllowed := CheckPermissionWithContext("UpdateTable", principal, metadata.OwnerAccountID, tablePolicy, tableARN, &PolicyContext{ TableBucketName: bucketName, Namespace: namespaceName, TableName: tableName, TableBucketTags: bucketTags, ResourceTags: tableTags, IdentityActions: identityActions, DefaultAllow: h.defaultAllowFor(r), }) bucketAllowed := CheckPermissionWithContext("UpdateTable", principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{ TableBucketName: bucketName, Namespace: namespaceName, TableName: tableName, TableBucketTags: bucketTags, ResourceTags: tableTags, IdentityActions: identityActions, DefaultAllow: h.defaultAllowFor(r), }) if !tableAllowed && !bucketAllowed { h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to update table") return NewAuthError("UpdateTable", principal, "not authorized to update table") } // 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, tablePath, func(extended map[string][]byte) error { if !bytes.Equal(extended[ExtendedKeyMetadata], 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], 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: 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) (*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) namespaceMetadata, err := h.loadNamespaceMetadata(r.Context(), filerClient, bucketName, namespaceName) if err != nil { 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 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) } // 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 } bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName) identityActions := getIdentityActions(r) policyContext := &PolicyContext{ TableBucketName: bucketName, Namespace: namespaceName, TableName: tableName, RequestTags: requestTags, TagKeys: mapKeys(requestTags), TableBucketTags: bucketTags, IdentityActions: identityActions, DefaultAllow: h.defaultAllowFor(r), } nsAllowed := CheckPermissionWithContext("CreateTable", accountID, namespaceMetadata.OwnerAccountID, namespacePolicy, bucketARN, policyContext) bucketAllowed := CheckPermissionWithContext("CreateTable", accountID, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, policyContext) if !nsAllowed && !bucketAllowed { 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: namespaceMetadata.OwnerAccountID, }, nil }