Files
seaweedfs/weed/s3api/s3tables/handler_maintenance.go
T
Chris Lu 8c55425d55 s3tables: tighten legacy OIDC subject re-anchoring (#11808)
* sts: recognize issuer-scoped parent user ids

ComputeParentUser output has a fixed shape (base64url SHA-256); let
callers distinguish it from bare subject strings.

* s3tables: keep legacy owner re-anchoring off local and scoped account ids

Re-anchoring a stored bare subject under the single trusted issuer must
only apply to strings that could actually be a legacy OIDC subject. A
stored owner naming the reserved or a configured local account, or an
already issuer-scoped id, can otherwise be claimed by any caller whose
token asserts that string as sub.

* s3tables: re-anchor legacy policy principals in map and array forms

A stored bare subject can appear as {"AWS": sub} or inside a principal
list, not only as a top-level string; re-anchoring only the string form
left legacy Deny statements unable to match the caller.

* s3tables: keep built-in legacy exclusions independent of the account checker

A missing local-account predicate must not disable re-anchoring entirely:
the reserved accounts, ARN-shaped values, and scoped-id shapes are never
OIDC subjects regardless, while legacy Deny statements for plain subjects
still need to resolve so a denied caller cannot fall through to an
identity grant.

* s3tables: exercise legacy Deny against an owner grant

The previous case denied the caller regardless of whether the statement
principal matched; make the caller the owner so a missed match shows.
Cover the array Deny form alongside the AWS map.
2026-10-18 02:43:03 -07:00

534 lines
20 KiB
Go

package s3tables
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
)
// Maintenance types are scoped: AWS configures unreferenced file removal on the
// table bucket and compaction and snapshot management on the table.
var (
bucketMaintenanceTypes = map[string]bool{
MaintenanceTypeIcebergUnreferencedFileRemoval: true,
}
tableMaintenanceTypes = map[string]bool{
MaintenanceTypeIcebergCompaction: true,
MaintenanceTypeIcebergSnapshotManagement: true,
}
)
// handlePutTableBucketMaintenanceConfiguration sets a maintenance configuration on a table bucket
func (h *S3TablesHandler) handlePutTableBucketMaintenanceConfiguration(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
var req PutTableBucketMaintenanceConfigurationRequest
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
}
if err := validateMaintenanceValue(req.Type, bucketMaintenanceTypes, req.Value); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
if _, err := h.authorizeMaintenanceBucket(r, filerClient, "PutTableBucketMaintenanceConfiguration", bucketName); err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
return err
}
if err := h.putMaintenanceConfiguration(r, filerClient, GetTableBucketPath(bucketName), req.Type, req.Value); err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
return err
}
h.writeJSON(w, http.StatusOK, nil)
return nil
}
// handleGetTableBucketMaintenanceConfiguration gets the maintenance configuration of a table bucket
func (h *S3TablesHandler) handleGetTableBucketMaintenanceConfiguration(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
var req GetTableBucketMaintenanceConfigurationRequest
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
}
bucketARN, err := h.authorizeMaintenanceBucket(r, filerClient, "GetTableBucketMaintenanceConfiguration", bucketName)
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
return err
}
config, err := h.readMaintenanceConfiguration(r, filerClient, GetTableBucketPath(bucketName))
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
return err
}
h.writeJSON(w, http.StatusOK, &GetTableBucketMaintenanceConfigurationResponse{
TableBucketARN: bucketARN,
Configuration: config,
})
return nil
}
// handlePutTableMaintenanceConfiguration sets a maintenance configuration on a table
func (h *S3TablesHandler) handlePutTableMaintenanceConfiguration(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
var req PutTableMaintenanceConfigurationRequest
if err := h.readRequestBody(r, &req); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
bucketName, namespaceName, tableName, err := h.parseTableTarget(req.TableBucketARN, req.Namespace, req.Name)
if err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
if err := validateMaintenanceValue(req.Type, tableMaintenanceTypes, req.Value); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
if _, err := h.authorizeMaintenanceTable(r, filerClient, "PutTableMaintenanceConfiguration", bucketName, namespaceName, tableName); err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
tablePath := GetTablePath(bucketName, namespaceName, tableName)
if err := h.putMaintenanceConfiguration(r, filerClient, tablePath, req.Type, req.Value); err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
h.writeJSON(w, http.StatusOK, nil)
return nil
}
// handleGetTableMaintenanceConfiguration gets the maintenance configuration of a table
func (h *S3TablesHandler) handleGetTableMaintenanceConfiguration(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
var req GetTableMaintenanceConfigurationRequest
if err := h.readRequestBody(r, &req); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
bucketName, namespaceName, tableName, err := h.parseTableTarget(req.TableBucketARN, req.Namespace, req.Name)
if err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
tableARN, err := h.authorizeMaintenanceTable(r, filerClient, "GetTableMaintenanceConfiguration", bucketName, namespaceName, tableName)
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
tablePath := GetTablePath(bucketName, namespaceName, tableName)
config, err := h.readMaintenanceConfiguration(r, filerClient, tablePath)
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
h.writeJSON(w, http.StatusOK, &GetTableMaintenanceConfigurationResponse{
TableARN: tableARN,
Namespace: []string{namespaceName},
Name: tableName,
Configuration: config,
})
return nil
}
// handleGetTableMaintenanceJobStatus reports the outcome of the last maintenance run on a table
func (h *S3TablesHandler) handleGetTableMaintenanceJobStatus(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
var req GetTableMaintenanceJobStatusRequest
if err := h.readRequestBody(r, &req); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
bucketName, namespaceName, tableName, err := h.parseTableTarget(req.TableBucketARN, req.Namespace, req.Name)
if err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
tableARN, err := h.authorizeMaintenanceTable(r, filerClient, "GetTableMaintenanceJobStatus", bucketName, namespaceName, tableName)
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
tablePath := GetTablePath(bucketName, namespaceName, tableName)
// Unreferenced file removal is configured on the bucket, so a bucket-level
// disable has to show up here as Disabled rather than a stale table status.
bucketConfig, err := h.readMaintenanceConfiguration(r, filerClient, GetTableBucketPath(bucketName))
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
return err
}
tableConfig, err := h.readMaintenanceConfiguration(r, filerClient, tablePath)
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
config := MergeMaintenanceConfiguration(bucketConfig, tableConfig)
recorded := MaintenanceJobStatus{}
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
data, err := h.getExtendedAttribute(r.Context(), client, tablePath, ExtendedKeyMaintenanceStatus)
if err != nil {
if errors.Is(err, ErrAttributeNotFound) {
return nil
}
return err
}
return json.Unmarshal(data, &recorded)
})
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
h.writeJSON(w, http.StatusOK, &GetTableMaintenanceJobStatusResponse{
TableARN: tableARN,
Status: buildJobStatusResponse(recorded, config),
})
return nil
}
// buildJobStatusResponse reports every job type: what the worker recorded, or
// Disabled when the configuration switched the type off, or Not_Yet_Run.
func buildJobStatusResponse(recorded MaintenanceJobStatus, config MaintenanceConfiguration) MaintenanceJobStatus {
jobTypes := []string{
MaintenanceTypeIcebergCompaction,
MaintenanceTypeIcebergSnapshotManagement,
MaintenanceTypeIcebergUnreferencedFileRemoval,
}
status := make(MaintenanceJobStatus, len(jobTypes))
for _, jobType := range jobTypes {
if value, ok := config[jobType]; ok && value != nil && value.Status == MaintenanceStatusDisabled {
status[jobType] = &MaintenanceJobStatusValue{Status: MaintenanceJobStatusDisabled}
continue
}
if value, ok := recorded[jobType]; ok && value != nil {
status[jobType] = value
continue
}
status[jobType] = &MaintenanceJobStatusValue{Status: MaintenanceJobStatusNotYetRun}
}
return status
}
// putMaintenanceConfiguration merges one maintenance type into the stored
// configuration, leaving the other types alone. The write checks the catalog
// identity in the same conditional mutation, so it cannot land on a name a
// concurrent rename or delete has already soft-deleted.
func (h *S3TablesHandler) putMaintenanceConfiguration(
r *http.Request,
filerClient FilerClient,
resourcePath, maintenanceType string,
value *MaintenanceConfigurationValue,
) error {
return filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return h.mutateEntryExtended(r.Context(), client, resourcePath, func(extended map[string][]byte) error {
if len(extended[ExtendedKeyMetadata]) == 0 {
return fmt.Errorf("%w: %s", filer_pb.ErrNotFound, resourcePath)
}
config := MaintenanceConfiguration{}
if current := extended[ExtendedKeyMaintenance]; len(current) > 0 {
if err := json.Unmarshal(current, &config); err != nil {
return fmt.Errorf("failed to unmarshal maintenance configuration: %w", err)
}
}
config[maintenanceType] = value
data, err := json.Marshal(config)
if err != nil {
return err
}
extended[ExtendedKeyMaintenance] = data
return nil
})
})
}
// readMaintenanceConfiguration returns the stored configuration, or an empty
// one when the resource has never been configured.
func (h *S3TablesHandler) readMaintenanceConfiguration(r *http.Request, filerClient FilerClient, resourcePath string) (MaintenanceConfiguration, error) {
config := MaintenanceConfiguration{}
err := filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
data, err := h.getExtendedAttribute(r.Context(), client, resourcePath, ExtendedKeyMaintenance)
if err != nil {
if errors.Is(err, ErrAttributeNotFound) {
return nil
}
return err
}
return json.Unmarshal(data, &config)
})
if err != nil {
return nil, err
}
return config, nil
}
// parseTableTarget validates the bucket/namespace/table triple every
// table-scoped maintenance request carries.
func (h *S3TablesHandler) parseTableTarget(tableBucketARN string, namespace []string, name string) (bucketName, namespaceName, tableName string, err error) {
if tableBucketARN == "" || len(namespace) == 0 || name == "" {
return "", "", "", fmt.Errorf("tableBucketARN, namespace, and name are required")
}
if namespaceName, err = validateNamespace(namespace); err != nil {
return "", "", "", err
}
if bucketName, err = parseBucketNameFromARN(tableBucketARN); err != nil {
return "", "", "", err
}
if tableName, err = validateTableName(name); err != nil {
return "", "", "", err
}
return bucketName, namespaceName, tableName, nil
}
// authorizeMaintenanceBucket checks the caller may perform operation on the
// table bucket, returning the bucket ARN.
func (h *S3TablesHandler) authorizeMaintenanceBucket(r *http.Request, filerClient FilerClient, operation, bucketName string) (string, error) {
bucketPath := GetTableBucketPath(bucketName)
var bucketMetadata tableBucketMetadata
var bucketPolicy string
err := filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
data, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata)
if err != nil {
return err
}
if err := json.Unmarshal(data, &bucketMetadata); err != nil {
return fmt.Errorf("failed to unmarshal bucket metadata: %w", err)
}
bucketPolicy, err = h.readBucketPolicy(r, client, bucketPath)
return err
})
if err != nil {
return "", err
}
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
principal := h.getAccountID(r)
if !CheckPermissionWithContext(operation, principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
IsLocalAccountID: h.localAccountIDs,
TableBucketName: bucketName,
IdentityActions: getIdentityActions(r),
DefaultAllow: h.defaultAllowFor(r),
}) {
return "", NewAuthError(operation, principal, "not authorized to "+operation)
}
return bucketARN, nil
}
// authorizeMaintenanceTable checks the caller may perform operation on the
// table, returning the table ARN.
func (h *S3TablesHandler) authorizeMaintenanceTable(r *http.Request, filerClient FilerClient, operation, bucketName, namespaceName, tableName string) (string, error) {
tablePath := GetTablePath(bucketName, namespaceName, tableName)
bucketPath := GetTableBucketPath(bucketName)
var metadata tableMetadataInternal
var bucketPolicy string
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)
}
bucketPolicy, err = h.readBucketPolicy(r, client, bucketPath)
return err
})
if err != nil {
return "", err
}
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespaceName+"/"+tableName)
principal := h.getAccountID(r)
if !CheckPermissionWithContext(operation, principal, metadata.OwnerAccountID, bucketPolicy, tableARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
IsLocalAccountID: h.localAccountIDs,
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
IdentityActions: getIdentityActions(r),
DefaultAllow: h.defaultAllowFor(r),
}) {
return "", NewAuthError(operation, principal, "not authorized to "+operation)
}
return tableARN, nil
}
// readBucketPolicy returns the bucket policy, or an empty string when the
// bucket has none.
func (h *S3TablesHandler) readBucketPolicy(r *http.Request, client filer_pb.SeaweedFilerClient, bucketPath string) (string, error) {
data, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyPolicy)
if err != nil {
if errors.Is(err, ErrAttributeNotFound) {
return "", nil
}
return "", fmt.Errorf("failed to read bucket policy: %w", err)
}
return string(data), nil
}
// writeMaintenanceError maps the shared failure modes of the maintenance
// handlers onto responses.
func (h *S3TablesHandler) writeMaintenanceError(w http.ResponseWriter, err error, notFoundCode, notFoundMessage string) {
switch {
case errors.Is(err, filer_pb.ErrNotFound):
h.writeError(w, http.StatusNotFound, notFoundCode, notFoundMessage)
case isAuthError(err):
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, err.Error())
case errors.Is(err, ErrConcurrentUpdate):
h.writeError(w, http.StatusConflict, ErrCodeConflict, "maintenance configuration changed concurrently, retry the request")
default:
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, err.Error())
}
}
// validateMaintenanceValue rejects a type that does not belong to the scope and
// settings that do not name that same type.
func validateMaintenanceValue(maintenanceType string, allowed map[string]bool, value *MaintenanceConfigurationValue) error {
if maintenanceType == "" {
return fmt.Errorf("type is required")
}
if !allowed[maintenanceType] {
return fmt.Errorf("unsupported maintenance type %q", maintenanceType)
}
if value == nil {
return fmt.Errorf("value is required")
}
switch value.Status {
case MaintenanceStatusEnabled, MaintenanceStatusDisabled:
case "":
return fmt.Errorf("value.status is required")
default:
return fmt.Errorf("invalid value.status %q, expected %q or %q", value.Status, MaintenanceStatusEnabled, MaintenanceStatusDisabled)
}
if value.Settings != nil && !settingsMatchType(maintenanceType, value.Settings) {
return fmt.Errorf("value.settings must only contain %s", maintenanceType)
}
return validateMaintenanceSettings(value.Settings)
}
// AWS bounds every maintenance setting to a positive 32-bit value. Accepting
// anything else would store a number the worker then ignores or saturates, so
// the configuration read back would not be the one that runs.
const (
maintenanceSettingMin int64 = 1
maintenanceSettingMax int64 = 2147483647
)
func validateMaintenanceSettings(settings *MaintenanceSettings) error {
if settings == nil {
return nil
}
if c := settings.IcebergCompaction; c != nil {
if err := validateCompactionStrategy(c.Strategy); err != nil {
return err
}
if err := validateMaintenanceSetting("targetFileSizeMB", c.TargetFileSizeMB); err != nil {
return err
}
}
if m := settings.IcebergSnapshotManagement; m != nil {
if err := validateMaintenanceSetting("minSnapshotsToKeep", m.MinSnapshotsToKeep); err != nil {
return err
}
if err := validateMaintenanceSetting("maxSnapshotAgeHours", m.MaxSnapshotAgeHours); err != nil {
return err
}
}
if u := settings.IcebergUnreferencedFileRemoval; u != nil {
if err := validateMaintenanceSetting("unreferencedDays", u.UnreferencedDays); err != nil {
return err
}
if err := validateMaintenanceSetting("nonCurrentDays", u.NonCurrentDays); err != nil {
return err
}
}
return nil
}
func validateMaintenanceSetting(name string, value *int64) error {
if value == nil {
return nil
}
if *value < maintenanceSettingMin || *value > maintenanceSettingMax {
return fmt.Errorf("%s must be between %d and %d, got %d", name, maintenanceSettingMin, maintenanceSettingMax, *value)
}
return nil
}
// validateCompactionStrategy rejects a strategy the maintenance worker cannot
// carry out, rather than accepting it and quietly compacting some other way.
func validateCompactionStrategy(strategy string) error {
switch strategy {
case "", CompactionStrategyAuto, CompactionStrategyBinpack, CompactionStrategySort:
return nil
case CompactionStrategyZOrder:
return fmt.Errorf("compaction strategy %q is not supported", strategy)
default:
return fmt.Errorf("invalid compaction strategy %q, expected one of %q, %q, %q",
strategy, CompactionStrategyAuto, CompactionStrategyBinpack, CompactionStrategySort)
}
}
func settingsMatchType(maintenanceType string, settings *MaintenanceSettings) bool {
switch maintenanceType {
case MaintenanceTypeIcebergCompaction:
return settings.IcebergSnapshotManagement == nil && settings.IcebergUnreferencedFileRemoval == nil
case MaintenanceTypeIcebergSnapshotManagement:
return settings.IcebergCompaction == nil && settings.IcebergUnreferencedFileRemoval == nil
case MaintenanceTypeIcebergUnreferencedFileRemoval:
return settings.IcebergCompaction == nil && settings.IcebergSnapshotManagement == nil
}
return false
}