mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-12 06:48:45 +01:00
* lance: require UpdateTable to deregister a table Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * lance: require UpdateTable to register over an existing table Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * lance: test that GetTable alone cannot deregister or re-register a table Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * lance: a register that finds a table the caller cannot read needs UpdateTable to bring it back Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * lance: decide declare write access by whether the table existed, not by what the caller can read Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * lance: test register and declare by a caller that cannot read the table Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * lance: register clears the marker on the create branch only after checking it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * lance: keep a register whose table was created when the marker check fails Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
264 lines
9.7 KiB
Go
264 lines
9.7 KiB
Go
package lance
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gorilla/mux"
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
|
)
|
|
|
|
const defaultPageSize = 1000
|
|
|
|
// manager is the S3 Tables manager a request runs through: a cluster process,
|
|
// authenticated by the cluster token alone, has no identity of its own and is
|
|
// trusted; every other caller is checked against its grants.
|
|
func (s *Server) manager(r *http.Request) *s3tables.Manager {
|
|
if s3_constants.GetIdentityNameFromContext(r) == "" && isTrustedCaller(r.Context()) {
|
|
return s.internal
|
|
}
|
|
return s.tablesManager
|
|
}
|
|
|
|
// execute runs one S3 Tables operation as the request's authenticated caller.
|
|
func (s *Server) execute(r *http.Request, operation string, req, resp interface{}) error {
|
|
identityName := s3_constants.GetIdentityNameFromContext(r)
|
|
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
return s.manager(r).Execute(r.Context(), s3tables.NewManagerClient(client), operation, req, resp, identityName)
|
|
})
|
|
}
|
|
|
|
// executeInternal runs one S3 Tables operation through the trusted manager —
|
|
// the caller's catalog grants must not gate an internal resolution that the
|
|
// caller's own vector permission already authorized.
|
|
func (s *Server) executeInternal(r *http.Request, operation string, req, resp interface{}) error {
|
|
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
return s.internal.Execute(r.Context(), s3tables.NewManagerClient(client), operation, req, resp, "")
|
|
})
|
|
}
|
|
|
|
func bucketARN(bucket string) string {
|
|
arn, _ := s3tables.BuildBucketARN(s3tables.DefaultRegion, s3_constants.AccountAdminId, bucket)
|
|
return arn
|
|
}
|
|
|
|
// maxRequestBody bounds what one call can make the catalog hold. Every request
|
|
// this surface takes is a small JSON envelope; the largest carries a set of
|
|
// properties, not data.
|
|
const maxRequestBody = 4 << 20
|
|
|
|
// decodeBody reads an optional JSON request body. Every Lance operation carries
|
|
// one, but the fields that matter are also in the route, so an empty body is
|
|
// not an error.
|
|
func decodeBody(r *http.Request, into interface{}) error {
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBody+1))
|
|
if err != nil {
|
|
return fmt.Errorf("read request body: %w", err)
|
|
}
|
|
if len(body) > maxRequestBody {
|
|
return fmt.Errorf("request body is larger than %d bytes", maxRequestBody)
|
|
}
|
|
if len(strings.TrimSpace(string(body))) == 0 {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(body, into); err != nil {
|
|
return fmt.Errorf("invalid request body: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// routeIdentifier parses the {id} route variable, writing the error response
|
|
// itself when the identifier is malformed.
|
|
func routeIdentifier(w http.ResponseWriter, r *http.Request) (identifier, string, bool) {
|
|
delimiter := requestDelimiter(r)
|
|
id, err := parseIdentifier(mux.Vars(r)["id"], delimiter)
|
|
if err != nil {
|
|
writeError(w, r, http.StatusBadRequest, codeInvalidInput, err.Error())
|
|
return nil, delimiter, false
|
|
}
|
|
return id, delimiter, true
|
|
}
|
|
|
|
// checkBodyIdentifier enforces the spec rule that a route and a body naming
|
|
// different objects is a bad request rather than a silent preference.
|
|
func checkBodyIdentifier(w http.ResponseWriter, r *http.Request, id identifier, body []string) bool {
|
|
if id.matchesBody(body) {
|
|
return true
|
|
}
|
|
writeError(w, r, http.StatusBadRequest, codeInvalidInput,
|
|
"the identifier in the request body does not match the one in the route")
|
|
return false
|
|
}
|
|
|
|
func pageSize(r *http.Request) int {
|
|
if raw := r.URL.Query().Get("limit"); raw != "" {
|
|
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
|
|
return parsed
|
|
}
|
|
}
|
|
return defaultPageSize
|
|
}
|
|
|
|
func boolQuery(r *http.Request, name string) bool {
|
|
value, err := strconv.ParseBool(r.URL.Query().Get(name))
|
|
return err == nil && value
|
|
}
|
|
|
|
// wants resolves a tri-state request flag: the query parameter the REST spec
|
|
// adds, else the body field, else the implementation's own choice.
|
|
func wants(r *http.Request, name string, body *bool) bool {
|
|
if boolQuery(r, name) {
|
|
return true
|
|
}
|
|
return body != nil && *body
|
|
}
|
|
|
|
// tableLocation is where a table's dataset lives when the caller does not name
|
|
// a location. It mirrors the Iceberg catalog's layout so both catalogs put a
|
|
// table of the same name in the same place.
|
|
func tableLocation(bucket string, ns []string, name string) string {
|
|
return fmt.Sprintf("s3://%s/%s/%s", bucket, strings.Join(ns, "."), name)
|
|
}
|
|
|
|
// confineLocation rejects a client-supplied table location that does not resolve
|
|
// inside the caller's own bucket, the way the Iceberg gateway confines the same
|
|
// field. The location feeds TableDataDirFromMetadataLocation, which joins it
|
|
// under /buckets and collapses any "../" segments; an unconfined location lets a
|
|
// marker write escape into another tenant's namespace.
|
|
func confineLocation(bucket, location string) error {
|
|
rest, ok := strings.CutPrefix(location, "s3://")
|
|
if !ok {
|
|
return fmt.Errorf("location must be an s3:// URI")
|
|
}
|
|
name, keys, _ := strings.Cut(rest, "/")
|
|
if name != bucket {
|
|
return fmt.Errorf("location must be within bucket %s", bucket)
|
|
}
|
|
for _, segment := range strings.Split(keys, "/") {
|
|
switch {
|
|
case segment == "." || segment == "..":
|
|
return fmt.Errorf("location must not contain a path traversal segment")
|
|
case strings.ContainsAny(segment, "\\\x00"):
|
|
return fmt.Errorf("location contains an invalid character")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// storageOptions builds the object_store settings a Lance client needs to reach
|
|
// the dataset. The key names are the aws_-prefixed forms Lance clients pass
|
|
// through to object_store.
|
|
func (s *Server) storageOptions(r *http.Request, bucket, location string, vend bool, canWrite func() bool) (map[string]string, error) {
|
|
options := map[string]string{}
|
|
if s.s3Endpoint != "" {
|
|
options["aws_endpoint"] = s.s3Endpoint
|
|
if strings.HasPrefix(s.s3Endpoint, "http://") {
|
|
// object_store refuses a plaintext endpoint unless told to allow it,
|
|
// and the resulting failure reads like a credential problem.
|
|
options["allow_http"] = "true"
|
|
}
|
|
}
|
|
if s.s3Region != "" {
|
|
options["aws_region"] = s.s3Region
|
|
}
|
|
if !s.authEnabled() {
|
|
// The S3 port takes unsigned requests, and a client with no
|
|
// credentials of its own would otherwise fail looking for some.
|
|
options["aws_skip_signature"] = "true"
|
|
}
|
|
|
|
if !vend || s.credentialVendor == nil {
|
|
return options, nil
|
|
}
|
|
|
|
principal := s3_constants.GetIdentityNameFromContext(r)
|
|
// A cluster process reaches the table's files on the filers and volume
|
|
// servers itself; it has no principal and needs no S3 credentials.
|
|
if principal == "" && isTrustedCaller(r.Context()) {
|
|
return options, nil
|
|
}
|
|
credentials, err := s.credentialVendor.VendTableCredentials(r.Context(), principal, bucket, locationPrefix(location), canWrite == nil || !canWrite())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if credentials == nil {
|
|
return options, nil
|
|
}
|
|
options["aws_access_key_id"] = credentials.AccessKeyID
|
|
options["aws_secret_access_key"] = credentials.SecretAccessKey
|
|
if credentials.SessionToken != "" {
|
|
options["aws_session_token"] = credentials.SessionToken
|
|
}
|
|
if !credentials.Expiration.IsZero() {
|
|
options["expires_at_millis"] = strconv.FormatInt(credentials.Expiration.UnixMilli(), 10)
|
|
}
|
|
return options, nil
|
|
}
|
|
|
|
// mayUpdateTable reports whether the caller may commit to the table the
|
|
// credentials would cover, the same gate the Iceberg catalog applies so a
|
|
// read-only caller never gets write-capable storage_options.
|
|
func (s *Server) mayUpdateTable(r *http.Request, bucket string, ns []string, name string) bool {
|
|
if !s.mayWriteNamespace(r, bucket, ns) {
|
|
return false
|
|
}
|
|
req := &s3tables.UpdateTableRequest{
|
|
TableBucketARN: bucketARN(bucket),
|
|
Namespace: ns,
|
|
Name: name,
|
|
}
|
|
identityName := s3_constants.GetIdentityNameFromContext(r)
|
|
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
return s.tablesManager.AuthorizeUpdateTable(r.Context(), s3tables.NewManagerClient(client), req, identityName)
|
|
})
|
|
if err != nil {
|
|
glog.V(1).Infof("lance: UpdateTable check for %s/%s failed, vending read-only: %v", bucket, name, err)
|
|
}
|
|
return err == nil
|
|
}
|
|
|
|
// authorizeUpdateTable checks the grant a change to the table's catalog state
|
|
// needs; a cluster process goes through the trusted manager.
|
|
func (s *Server) authorizeUpdateTable(r *http.Request, bucket string, ns []string, name string) error {
|
|
req := &s3tables.UpdateTableRequest{
|
|
TableBucketARN: bucketARN(bucket),
|
|
Namespace: ns,
|
|
Name: name,
|
|
}
|
|
identityName := s3_constants.GetIdentityNameFromContext(r)
|
|
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
return s.manager(r).AuthorizeUpdateTable(r.Context(), s3tables.NewManagerClient(client), req, identityName)
|
|
})
|
|
}
|
|
|
|
// mayWriteNamespace keeps the vector namespace of a vector bucket out of reach
|
|
// of s3tables grants: its index data is written only by trusted callers, and
|
|
// the vector bucket policy gates everyone else through S3 Vectors.
|
|
func (s *Server) mayWriteNamespace(r *http.Request, bucket string, ns []string) bool {
|
|
if isTrustedCaller(r.Context()) {
|
|
return true
|
|
}
|
|
err := s.tablesManager.CheckVectorNamespaceWrite(r.Context(), s.filerClient, bucket, ns)
|
|
if err != nil {
|
|
glog.V(1).Infof("lance: %s/%s is read-only to this caller: %v", bucket, strings.Join(ns, "."), err)
|
|
}
|
|
return err == nil
|
|
}
|
|
|
|
// locationPrefix strips the s3://bucket/ part of a location, leaving the key
|
|
// prefix a credential is scoped to.
|
|
func locationPrefix(location string) string {
|
|
trimmed := strings.TrimPrefix(location, "s3://")
|
|
if _, prefix, found := strings.Cut(trimmed, "/"); found {
|
|
return prefix
|
|
}
|
|
return ""
|
|
}
|