Files
seaweedfs/weed/s3api/s3vectors/handlers.go
T
Chris LuandClaude Opus 5.5 e2826c48bd s3tables: make the vector namespace of a vector bucket read-only (#12292)
* s3tables: make the vector namespace of a vector bucket read-only

S3 Vectors authorizes index operations against the vector bucket policy,
but an index is also a catalog table in the bucket's "vector" namespace,
and the S3 Tables API only checks s3tables grants. A caller the vector
policy denies DeleteIndex could still delete the index, and its data,
with DeleteTable.

Untrusted writes that name the vector namespace of a LANCE bucket now run
behind a fence that passes filer reads and refuses filer writes. The
operation's own checks still answer everything short of a write, so a
caller without the grant gets its usual denial and learns nothing about
the bucket, and a CreateTable that finds the index already there, which
is what the vector engine's declare does, still succeeds. A refused write
answers 403 AccessDenied. Trusted callers (the gateway, the shell) are
unaffected, and so is a namespace named vector in an Iceberg bucket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* lance: refuse register and deregister in a vector bucket's vector namespace

Every catalog write the Lance namespace makes goes through the S3 Tables
handler and its vector namespace fence, but register over an existing
table at the same location and deregister only add or remove a marker
file in the dataset directory. Check the vector namespace before those
writes, after the table read that already gates them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3tables: refuse deferred creates in a vector bucket's vector namespace

Manager.AuthorizeCreateTable gates the Iceberg create paths that write
metadata into the table bucket before the catalog registers the table.
Those writes never pass the handler's fence, so the gate applies the same
read-only rule once the caller's own create permission is confirmed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3tables: let a declare of an existing index past the vector namespace fence

The vector engine declares every index the gateway has just created, and
that CreateTable only reaches its idempotent path. Upstream that path
writes nothing, so the fence never trips, but a build whose idempotent
path repairs table state would refuse the engine. Decide the exception
up front instead: a CreateTable naming a finished index in the vector
namespace runs unfenced, while a new name or an unfinished create stays
fenced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:36:48 -07:00

1887 lines
64 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package s3vectors
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
awsarn "github.com/aws/aws-sdk-go-v2/aws/arn"
"github.com/google/uuid"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/vector_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
var handlers = map[string]func(*Server, http.ResponseWriter, *http.Request){
"CreateVectorBucket": (*Server).handleCreateVectorBucket,
"GetVectorBucket": (*Server).handleGetVectorBucket,
"ListVectorBuckets": (*Server).handleListVectorBuckets,
"DeleteVectorBucket": (*Server).handleDeleteVectorBucket,
"PutVectorBucketPolicy": (*Server).handlePutVectorBucketPolicy,
"GetVectorBucketPolicy": (*Server).handleGetVectorBucketPolicy,
"DeleteVectorBucketPolicy": (*Server).handleDeleteVectorBucketPolicy,
"CreateIndex": (*Server).handleCreateIndex,
"GetIndex": (*Server).handleGetIndex,
"ListIndexes": (*Server).handleListIndexes,
"DeleteIndex": (*Server).handleDeleteIndex,
"PutVectors": (*Server).handlePutVectors,
"GetVectors": (*Server).handleGetVectors,
"ListVectors": (*Server).handleListVectors,
"DeleteVectors": (*Server).handleDeleteVectors,
"QueryVectors": (*Server).handleQueryVectors,
}
// lance table ids put indexes under a "vector" namespace inside their bucket.
const indexNamespace = s3tables.VectorIndexNamespace
// Vector bucket records are s3tables table buckets; the S3 Vectors ARN form is
// arn:aws:s3vectors:{region}:{account}:bucket/{name}[/index/{name}].
func (s *Server) vectorBucketArn(bucket string) string {
return fmt.Sprintf("arn:aws:s3vectors:%s:%s:bucket/%s", s.region, s.accountID, bucket)
}
func (s *Server) indexArn(bucket, index string) string {
return fmt.Sprintf("%s/index/%s", s.vectorBucketArn(bucket), index)
}
// s3tablesArn renders the same bucket in the table-bucket ARN form the S3
// Tables catalog keys records by.
func (s *Server) s3tablesArn(bucket string) string {
return fmt.Sprintf("arn:aws:s3tables:%s:%s:bucket/%s", s.region, s.accountID, bucket)
}
func (s *Server) arnParts(v *vectorBucketARNs, name, arn string) error {
if arn == "" {
v.bucket = name
return nil
}
if !strings.HasPrefix(arn, "arn:") {
v.bucket = arn
return nil
}
parsed, err := awsarn.Parse(arn)
if err != nil {
return fmt.Errorf("malformed ARN %q", arn)
}
if !strings.HasPrefix(arn, s.vectorBucketArn("")) {
return fmt.Errorf("ARN %q does not belong to this vector namespace", arn)
}
parts := strings.Split(parsed.Resource, "/")
if (len(parts) != 2 && len(parts) != 4) || parts[0] != "bucket" || parts[1] == "" {
return fmt.Errorf("malformed ARN %q", arn)
}
if len(parts) == 4 && (parts[2] != "index" || parts[3] == "") {
return fmt.Errorf("malformed ARN %q", arn)
}
v.bucket, v.index = parts[1], ""
if len(parts) == 4 {
v.index = parts[3]
}
return nil
}
// bucketARNs resolves bucket selectors and rejects index ARNs.
func (s *Server) bucketARNs(name, arn string) (vectorBucketARNs, error) {
var v vectorBucketARNs
if err := s.arnParts(&v, name, arn); err != nil {
return v, err
}
if v.index != "" {
return v, fmt.Errorf("vectorBucketArn %q names an index, not a bucket", arn)
}
if name != "" && name != v.bucket {
return v, fmt.Errorf("vectorBucketName and vectorBucketArn name different buckets")
}
return v, nil
}
// resolveIndexARNs resolves names, a bucket ARN, or an index ARN.
func (s *Server) resolveIndexARNs(bucketName, bucketArn, indexName, indexArn string) (vectorBucketARNs, error) {
var v vectorBucketARNs
if strings.HasPrefix(indexArn, "arn:") {
if err := s.arnParts(&v, "", indexArn); err != nil {
return v, err
}
if v.index == "" {
return v, fmt.Errorf("indexArn %q does not name an index", indexArn)
}
} else if indexArn != "" {
return v, fmt.Errorf("indexArn must be an ARN, got %q", indexArn)
}
if bucketArn != "" {
if !strings.HasPrefix(bucketArn, "arn:") {
return v, fmt.Errorf("vectorBucketArn must be an ARN, got %q", bucketArn)
}
bucket, err := s.bucketARNs("", bucketArn)
if err != nil {
return v, err
}
if v.bucket != "" && v.bucket != bucket.bucket {
return v, fmt.Errorf("vectorBucketArn and indexArn name different buckets")
}
v.bucket = bucket.bucket
}
if v.bucket == "" {
v.bucket = bucketName
} else if bucketName != "" && bucketName != v.bucket {
return v, fmt.Errorf("vectorBucketName and ARN bucket name different buckets")
}
if v.index == "" {
v.index = indexName
} else if indexName != "" && indexName != v.index {
return v, fmt.Errorf("indexName and indexArn name different indexes")
}
if v.bucket == "" || v.index == "" {
return v, fmt.Errorf("vectorBucketName and indexName (or indexArn) are required")
}
return v, nil
}
// indexRecord fetches the index's catalog record on the trusted path; the
// operation's own vector permission — checked in authorizeVector before any
// handler reaches this — is what gates access.
func (s *Server) indexRecord(ctx context.Context, bucket, index string) (map[string]interface{}, error) {
var resp map[string]interface{}
err := s.internalCatalog(ctx, "GetTable", &s3tables.GetTableRequest{
TableBucketARN: s.s3tablesArn(bucket),
Namespace: []string{indexNamespace},
Name: index,
}, &resp)
return resp, err
}
// internalCatalog runs catalog bookkeeping under the trusted identity. It is
// for reads and writes that accompany — never replace — the operation's own
// permission check: bucket format lookups, the implicit index namespace, and
// rollback of a record this request just created.
func (s *Server) internalCatalog(ctx context.Context, operation string, req, resp interface{}) error {
return s.internal.Execute(ctx, s.filerClient, operation, req, resp, "")
}
func catalogErrCode(err error) string {
var apiErr *s3tables.S3TablesError
if errors.As(err, &apiErr) {
return apiErr.Type
}
return ""
}
func catalogErr(w http.ResponseWriter, err error) {
var apiErr *s3tables.S3TablesError
if !errors.As(err, &apiErr) {
writeError(w, http.StatusInternalServerError, "InternalFailure", "internal error")
return
}
switch {
case apiErr.Type == s3tables.ErrCodeAccessDenied:
writeError(w, http.StatusForbidden, "AccessDeniedException", "access denied")
case strings.HasPrefix(apiErr.Type, "NoSuch"):
writeError(w, http.StatusNotFound, "NotFoundException", apiErr.Message)
case strings.Contains(apiErr.Type, "AlreadyExists") || strings.Contains(apiErr.Type, "NotEmpty") ||
apiErr.Type == s3tables.ErrCodeConflict:
writeError(w, http.StatusConflict, "ConflictException", apiErr.Message)
case apiErr.Type == s3tables.ErrCodeInternalError:
writeError(w, http.StatusInternalServerError, "InternalFailure", apiErr.Message)
default:
writeError(w, http.StatusBadRequest, "ValidationException", apiErr.Message)
}
}
// vectorErr maps a VectorService gRPC status onto the API's error shape.
// Engine messages reach clients only for codes that describe the request
// itself; transport and internal details stay in the server log.
func vectorErr(w http.ResponseWriter, err error) {
msg := status.Convert(err).Message()
switch status.Code(err) {
case codes.NotFound:
writeError(w, http.StatusNotFound, "NotFoundException", msg)
case codes.InvalidArgument, codes.FailedPrecondition, codes.OutOfRange:
writeError(w, http.StatusBadRequest, "ValidationException", msg)
case codes.PermissionDenied, codes.Unauthenticated:
writeError(w, http.StatusForbidden, "AccessDeniedException", "access denied")
case codes.Unavailable:
writeError(w, http.StatusServiceUnavailable, "ServiceUnavailableException", "vector service unavailable")
default:
glog.V(1).Infof("S3 Vectors: engine error: %v", err)
writeError(w, http.StatusInternalServerError, "InternalFailure", "internal error")
}
}
// ensureIndexNamespace declares the implicit index namespace when absent. The
// calls are internal: a caller's CreateTable grant is what authorizes the
// index, so it must not also need namespace reads or writes.
func (s *Server) ensureIndexNamespace(ctx context.Context, bucket string) error {
var resp map[string]interface{}
err := s.internalCatalog(ctx, "GetNamespace", &s3tables.GetNamespaceRequest{
TableBucketARN: s.s3tablesArn(bucket),
Namespace: []string{indexNamespace},
}, &resp)
if err == nil {
return nil
}
if code := catalogErrCode(err); code != s3tables.ErrCodeNoSuchNamespace && code != s3tables.ErrCodeNoSuchBucket {
return err
}
err = s.internalCatalog(ctx, "CreateNamespace", &s3tables.CreateNamespaceRequest{
TableBucketARN: s.s3tablesArn(bucket),
Namespace: []string{indexNamespace},
}, &resp)
// A concurrent CreateIndex may have won the create.
if err != nil && catalogErrCode(err) != s3tables.ErrCodeNamespaceAlreadyExists {
return err
}
return nil
}
func (s *Server) handleCreateVectorBucket(w http.ResponseWriter, r *http.Request) {
var req CreateVectorBucketRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if req.VectorBucketName == "" {
writeError(w, http.StatusBadRequest, "ValidationException", "vectorBucketName is required")
return
}
if err := s3tables.ValidateEncryptionConfiguration(req.EncryptionConfiguration, "sseType"); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
// The catalog keeps the s3tables field name; GetVectorBucket renames it back.
enc := req.EncryptionConfiguration
if len(enc) != 0 {
enc = map[string]interface{}{"sseAlgorithm": enc["sseType"]}
}
// No bucket exists yet, so check against the handler account as the
// catalog does for CreateTableBucket.
principal := s.tables.PrincipalFor(identityOf(r))
if !s3tables.CheckPermissionWithContext("s3vectors:CreateVectorBucket", principal, s.tables.PrincipalFor(""), "", "", s.tables.PolicyContext(r)) {
catalogErr(w, errVectorDenied)
return
}
// The caller's name still makes it the owner of the record.
var resp map[string]interface{}
err := s.internal.Execute(r.Context(), s.filerClient, "CreateTableBucket",
&s3tables.CreateTableBucketRequest{
Name: req.VectorBucketName,
Format: s3tables.FormatLance,
EncryptionConfiguration: enc,
}, &resp, identityOf(r))
if err != nil {
catalogErr(w, err)
return
}
writeJSON(w, http.StatusOK, &CreateVectorBucketResponse{VectorBucketArn: s.vectorBucketArn(req.VectorBucketName)})
}
func (s *Server) handleGetVectorBucket(w http.ResponseWriter, r *http.Request) {
var req GetVectorBucketRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.bucketARNs(req.VectorBucketName, req.VectorBucketArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
record, err := s.authorizeVector(r.Context(), r, "s3vectors:GetVectorBucket", arns.bucket, "")
if err != nil {
catalogErr(w, err)
return
}
bucket := &VectorBucket{
VectorBucketArn: s.vectorBucketArn(arns.bucket),
VectorBucketName: arns.bucket,
CreationTime: record.CreatedAt,
}
if cfg, ok := record.EncryptionConfiguration["sseAlgorithm"]; ok {
bucket.EncryptionConfiguration = map[string]interface{}{"sseType": cfg}
} else if len(record.EncryptionConfiguration) > 0 {
bucket.EncryptionConfiguration = record.EncryptionConfiguration
}
writeJSON(w, http.StatusOK, &GetVectorBucketResponse{VectorBucket: bucket})
}
func (s *Server) handleListVectorBuckets(w http.ResponseWriter, r *http.Request) {
var req ListVectorBucketsRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
maxResults := 0
if req.MaxResults != nil {
if *req.MaxResults < 1 || *req.MaxResults > vectorsMaxListPage {
writeError(w, http.StatusBadRequest, "ValidationException", fmt.Sprintf("maxResults must be between 1 and %d", vectorsMaxListPage))
return
}
maxResults = *req.MaxResults
}
var resp struct {
TableBuckets []struct {
Arn string `json:"arn"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
Format string `json:"format"`
} `json:"tableBuckets"`
ContinuationToken string `json:"continuationToken"`
}
// The listing runs internal and filters on the vector policy itself: a
// shared-slot check would hide buckets the caller was granted under
// s3vectors actions, while showing ones they were not.
err := s.internalCatalog(r.Context(), "ListTableBuckets",
&s3tables.ListTableBucketsRequest{
MaxBuckets: maxResults, ContinuationToken: req.NextToken, Prefix: req.Prefix,
}, &resp)
if err != nil {
catalogErr(w, err)
return
}
principal := s.tables.PrincipalFor(identityOf(r))
pctx := s.tables.PolicyContext(r)
// The catalog token continues the filtered page: ICEBERG buckets drop out
// of the listing, Lance and format-less legacy buckets stay.
out := &ListVectorBucketsResponse{NextToken: resp.ContinuationToken, VectorBuckets: []*VectorBucket{}}
for _, b := range resp.TableBuckets {
if b.Format != "" && b.Format != s3tables.FormatLance {
continue
}
owner, policy, err := s.vectorPolicyView(r.Context(), b.Name)
if err != nil {
// A bucket that vanished mid-list drops out of the page rather
// than failing the whole listing.
continue
}
arn := s.vectorBucketArn(b.Name)
if !s3tables.CheckPermissionWithContext("s3vectors:ListVectorBuckets", principal, owner, policy, arn, pctx) &&
!s3tables.CheckPermissionWithContext("s3vectors:GetVectorBucket", principal, owner, policy, arn, pctx) {
continue
}
created, _ := time.Parse(time.RFC3339, b.CreatedAt)
out.VectorBuckets = append(out.VectorBuckets, &VectorBucket{
VectorBucketArn: s.vectorBucketArn(b.Name),
VectorBucketName: b.Name,
CreationTime: created,
})
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleDeleteVectorBucket(w http.ResponseWriter, r *http.Request) {
var req DeleteVectorBucketRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.bucketARNs(req.VectorBucketName, req.VectorBucketArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:DeleteVectorBucket", arns.bucket, ""); err != nil {
catalogErr(w, err)
return
}
var resp map[string]interface{}
err = s.internalCatalog(r.Context(), "DeleteTableBucket",
&s3tables.DeleteTableBucketRequest{TableBucketARN: s.s3tablesArn(arns.bucket)}, &resp)
if err != nil && catalogErrCode(err) == s3tables.ErrCodeBucketNotEmpty {
// The implicit index namespace counts as bucket content even when it
// holds no indexes; drop it under the delete's own authorization and
// retry once. A namespace still holding indexes keeps the conflict.
var nsResp map[string]interface{}
nsErr := s.internalCatalog(context.WithoutCancel(r.Context()), "DeleteNamespace",
&s3tables.DeleteNamespaceRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
}, &nsResp)
if nsErr == nil {
err = s.internalCatalog(r.Context(), "DeleteTableBucket",
&s3tables.DeleteTableBucketRequest{TableBucketARN: s.s3tablesArn(arns.bucket)}, &resp)
} else if code := catalogErrCode(nsErr); code != s3tables.ErrCodeNoSuchNamespace {
glog.V(1).Infof("S3 Vectors: drop internal namespace in %s: %v", arns.bucket, nsErr)
}
}
if err != nil {
catalogErr(w, err)
return
}
writeJSON(w, http.StatusOK, &DeleteVectorBucketResponse{})
}
func distanceMetric(metric string) (vector_pb.DistanceMetric, bool) {
switch strings.ToLower(metric) {
case "", "euclidean":
return vector_pb.DistanceMetric_L2, true
case "cosine":
return vector_pb.DistanceMetric_COSINE, true
case "dotproduct":
return vector_pb.DistanceMetric_DOT_PRODUCT, true
}
return vector_pb.DistanceMetric_L2, false
}
func tableID(bucket, index string) *vector_pb.Table {
return &vector_pb.Table{Id: []string{bucket, indexNamespace, index}}
}
// vectorPolicyKey names the table-bucket attribute holding the bucket's
// s3vectors policy. The document stays in s3vectors form: the shared
// evaluator only honors catalog actions, so vector operations evaluate it
// themselves — against the s3vectors action and ARN the request names. A
// separate key keeps a vector grant from ever authorizing a catalog call.
// The constant lives in s3tables so the catalog asserts it in the update
// preconditions of its own whole-entry writes.
const vectorPolicyKey = s3tables.ExtendedKeyVectorPolicy
// errNoVectorPolicy reports a missing policy document; handlers map it to a
// 404 the same way the catalog maps a missing resource policy.
var errNoVectorPolicy = errors.New("vector bucket policy not found")
// validateVectorPolicy rejects a document the evaluator cannot honor: the
// shared Statement shape has no negated fields, so a rule relying on one would
// silently lose its exclusion; conditions are unsupported since the vector
// evaluation supplies no condition keys; and a rule naming another service is
// a dead rule the writer believes is enforced. The document must decode into
// the same typed form the evaluator uses.
func validateVectorPolicy(raw string) error {
var typed s3tables.PolicyDocument
if err := json.Unmarshal([]byte(raw), &typed); err != nil {
return fmt.Errorf("invalid policy document: %w", err)
}
var doc map[string]interface{}
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
return fmt.Errorf("invalid policy document: %w", err)
}
for _, stmt := range policyStatements(doc) {
for key := range stmt {
for _, field := range []string{"NotAction", "NotResource", "NotPrincipal", "Condition"} {
if strings.EqualFold(key, field) {
return fmt.Errorf("invalid policy document: %s is not supported", field)
}
}
}
}
for _, stmt := range typed.Statement {
for _, action := range statementActions(stmt.Action) {
if service, _, found := strings.Cut(action, ":"); found && service != "s3vectors" {
return fmt.Errorf("invalid policy document: action %q is not enforceable in a vector bucket policy", action)
}
}
for _, resource := range statementActions(stmt.Resource) {
if service := arnService(resource); service != "" && service != "s3vectors" {
return fmt.Errorf("invalid policy document: resource %q is not enforceable in a vector bucket policy", resource)
}
}
}
return nil
}
// arnService returns the service field of an ARN in any partition.
func arnService(resource string) string {
parts := strings.SplitN(resource, ":", 4)
if len(parts) == 4 && parts[0] == "arn" {
return parts[2]
}
return ""
}
func policyStatements(doc map[string]interface{}) []map[string]interface{} {
var stmts []map[string]interface{}
switch s := doc["Statement"].(type) {
case []interface{}:
for _, item := range s {
if stmt, ok := item.(map[string]interface{}); ok {
stmts = append(stmts, stmt)
}
}
case map[string]interface{}:
stmts = append(stmts, s)
}
return stmts
}
// policyHasVectorAction reports whether any statement names an s3vectors
// action — how a raw vector document in the shared policy slot is recognized.
// It reads the typed document so detection matches what the evaluator sees.
func policyHasVectorAction(raw string) bool {
var doc s3tables.PolicyDocument
if json.Unmarshal([]byte(raw), &doc) != nil {
return false
}
for _, stmt := range doc.Statement {
for _, action := range statementActions(stmt.Action) {
if strings.HasPrefix(action, "s3vectors:") {
return true
}
}
}
return false
}
func statementActions(spec interface{}) []string {
switch a := spec.(type) {
case string:
return []string{a}
case []interface{}:
var actions []string
for _, item := range a {
if str, ok := item.(string); ok {
actions = append(actions, str)
}
}
return actions
}
return nil
}
func splitEntryPath(p string) (dir, name string) {
i := strings.LastIndex(p, "/")
return p[:i], p[i+1:]
}
// vectorPolicyFromExt resolves the effective s3vectors policy from an entry's
// attributes: the dedicated key wins, else a shared-slot document that still
// names vector actions.
func vectorPolicyFromExt(ext map[string][]byte) string {
if doc := ext[vectorPolicyKey]; len(doc) > 0 {
return string(doc)
}
if doc := string(ext[s3tables.ExtendedKeyPolicy]); policyHasVectorAction(doc) {
return doc
}
return ""
}
// stripVectorActions removes s3vectors statements from a shared-slot
// document, keeping its catalog rules: a statement mixing both kinds keeps
// its catalog actions, and a now-empty statement drops out. It reports the
// document to write back — empty when nothing remains — and whether anything
// changed.
func stripVectorActions(raw string) (string, bool) {
var doc map[string]interface{}
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
return "", false
}
changed := false
var kept []map[string]interface{}
for _, stmt := range policyStatements(doc) {
dropped := false
for key, value := range stmt {
if !strings.EqualFold(key, "Action") {
continue
}
var actions []string
keyDropped := false
for _, a := range statementActions(value) {
if strings.HasPrefix(a, "s3vectors:") {
keyDropped = true
continue
}
actions = append(actions, a)
}
if !keyDropped {
continue
}
dropped = true
if len(actions) > 0 {
stmt[key] = actions
} else {
delete(stmt, key)
}
}
if !dropped {
kept = append(kept, stmt)
continue
}
changed = true
for key := range stmt {
if strings.EqualFold(key, "Action") {
kept = append(kept, stmt)
break
}
}
}
if !changed {
return "", false
}
if len(kept) == 0 {
return "", true
}
doc["Statement"] = kept
out, err := json.Marshal(doc)
if err != nil {
return "", false
}
return string(out), true
}
// vectorBucket fetches the bucket's catalog record under the trusted
// identity for the authorization lookup; nothing reaches the caller until
// the permission check below it passes.
func (s *Server) vectorBucket(ctx context.Context, bucket string) (*s3tables.GetTableBucketResponse, error) {
var resp s3tables.GetTableBucketResponse
if err := s.internalCatalog(ctx, "GetTableBucket",
&s3tables.GetTableBucketRequest{TableBucketARN: s.s3tablesArn(bucket)}, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// vectorPolicy reads the bucket's stored s3vectors policy. A bucket written
// before the dedicated key carried the raw document in the shared policy
// slot; one that still names vector actions keeps working until the next
// vector policy write migrates it out.
func (s *Server) vectorPolicy(ctx context.Context, bucket string) (string, error) {
dir, name := splitEntryPath(s3tables.GetTableBucketPath(bucket))
var policy string
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: name,
})
if err != nil {
return err
}
policy = vectorPolicyFromExt(resp.Entry.Extended)
return nil
})
return policy, err
}
// vectorPolicyView resolves a bucket's owner and stored s3vectors policy in
// one filer lookup — the per-bucket check the vector listing runs.
func (s *Server) vectorPolicyView(ctx context.Context, bucket string) (owner, policy string, err error) {
dir, name := splitEntryPath(s3tables.GetTableBucketPath(bucket))
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: name,
})
if err != nil {
return err
}
var meta struct {
OwnerAccountID string `json:"ownerAccountId"`
}
if raw := resp.Entry.Extended[s3tables.ExtendedKeyMetadata]; len(raw) > 0 {
_ = json.Unmarshal(raw, &meta)
}
owner = meta.OwnerAccountID
policy = vectorPolicyFromExt(resp.Entry.Extended)
return nil
})
return owner, policy, err
}
// mutateVectorPolicy applies a change to the bucket's vector-policy
// attribute under the entry's extended-attribute compare-and-swap, retrying
// a racing writer the way the catalog's own policy updates do.
func (s *Server) mutateVectorPolicy(ctx context.Context, bucket string, mutate func(ext map[string][]byte) error) error {
dir, name := splitEntryPath(s3tables.GetTableBucketPath(bucket))
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
for attempt := 0; attempt < 3; attempt++ {
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: name,
})
if err != nil {
return err
}
entry := resp.Entry
// Assert the vector policy key stays absent too, so a concurrent
// policy write trips the compare-and-swap instead of being
// silently reverted by the whole-entry update.
expected := s3tables.SnapshotExtended(entry.Extended, vectorPolicyKey)
if entry.Extended == nil {
entry.Extended = make(map[string][]byte)
}
if err := mutate(entry.Extended); err != nil {
return err
}
if _, err := client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
Directory: dir,
Entry: entry,
ExpectedExtended: expected,
}); err == nil {
return nil
} else if status.Code(err) != codes.FailedPrecondition {
return err
}
}
return &s3tables.S3TablesError{Type: s3tables.ErrCodeConflict,
Message: "vector bucket policy changed concurrently, retry the request"}
})
}
// isVectorBucket reports whether the catalog record describes a vector
// bucket: an explicit ICEBERG bucket is not one, while a bucket declared
// before formats existed reads as Lance.
func isVectorBucket(record *s3tables.GetTableBucketResponse) bool {
return record.Format == "" || record.Format == s3tables.FormatLance
}
// errVectorDenied marks a failed vector permission check inside a policy
// mutation; catalogErr renders it as AccessDenied.
var errVectorDenied = &s3tables.S3TablesError{Type: s3tables.ErrCodeAccessDenied, Message: "access denied"}
// checkVectorPolicyDoc evaluates the caller's vector action against the
// document the entry currently holds. Called inside a policy mutation's
// compare-and-swap, so a grant revoked between reads never authorizes the
// pending write.
func (s *Server) checkVectorPolicyDoc(r *http.Request, action, bucket, owner string, ext map[string][]byte) bool {
principal := s.tables.PrincipalFor(identityOf(r))
return s3tables.CheckPermissionWithContext(action, principal, owner,
vectorPolicyFromExt(ext), s.vectorBucketArn(bucket), s.tables.PolicyContext(r))
}
// authorizeVector grants the request's own vector operation — not a catalog
// stand-in — against the stored s3vectors policy and the ARN it actually
// targets, so an index-scoped rule reaches the index it names and a read
// grant never carries a write. Catalog bookkeeping below rides on the
// trusted internal path, the same pattern the implicit namespace uses.
// The returned record saves the handler a second bucket lookup.
func (s *Server) authorizeVector(ctx context.Context, r *http.Request, action, bucket, index string) (*s3tables.GetTableBucketResponse, error) {
record, err := s.vectorBucket(ctx, bucket)
if err != nil {
return nil, err
}
policy, err := s.vectorPolicy(ctx, bucket)
if err != nil {
return nil, err
}
resourceARN := s.vectorBucketArn(bucket)
if index != "" {
resourceARN = s.indexArn(bucket, index)
}
principal := s.tables.PrincipalFor(identityOf(r))
if !s3tables.CheckPermissionWithContext(action, principal, record.OwnerAccountID, policy, resourceARN, s.tables.PolicyContext(r)) {
return nil, &s3tables.S3TablesError{Type: s3tables.ErrCodeAccessDenied, Message: "access denied"}
}
if !isVectorBucket(record) {
return nil, &s3tables.S3TablesError{Type: s3tables.ErrCodeNoSuchBucket,
Message: fmt.Sprintf("vector bucket %s not found", bucket)}
}
return record, nil
}
func (s *Server) handlePutVectorBucketPolicy(w http.ResponseWriter, r *http.Request) {
var req PutVectorBucketPolicyRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.bucketARNs(req.VectorBucketName, req.VectorBucketArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if err := validateVectorPolicy(req.Policy); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
record, err := s.vectorBucket(r.Context(), arns.bucket)
if err != nil {
catalogErr(w, err)
return
}
if !isVectorBucket(record) {
writeError(w, http.StatusNotFound, "NotFoundException",
fmt.Sprintf("vector bucket %s not found", arns.bucket))
return
}
err = s.mutateVectorPolicy(r.Context(), arns.bucket, func(ext map[string][]byte) error {
if !s.checkVectorPolicyDoc(r, "s3vectors:PutVectorBucketPolicy", arns.bucket, record.OwnerAccountID, ext) {
return errVectorDenied
}
// A raw vector document still in the shared slot migrates out with
// this write; catalog rules in it stay untouched.
if stripped, changed := stripVectorActions(string(ext[s3tables.ExtendedKeyPolicy])); changed {
if stripped == "" {
delete(ext, s3tables.ExtendedKeyPolicy)
} else {
ext[s3tables.ExtendedKeyPolicy] = []byte(stripped)
}
}
ext[vectorPolicyKey] = []byte(req.Policy)
return nil
})
if err != nil {
catalogErr(w, err)
return
}
writeJSON(w, http.StatusOK, &PutVectorBucketPolicyResponse{})
}
func (s *Server) handleGetVectorBucketPolicy(w http.ResponseWriter, r *http.Request) {
var req GetVectorBucketPolicyRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.bucketARNs(req.VectorBucketName, req.VectorBucketArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:GetVectorBucketPolicy", arns.bucket, ""); err != nil {
catalogErr(w, err)
return
}
policy, err := s.vectorPolicy(r.Context(), arns.bucket)
if err != nil {
catalogErr(w, err)
return
}
if policy == "" {
writeError(w, http.StatusNotFound, "NotFoundException", errNoVectorPolicy.Error())
return
}
writeJSON(w, http.StatusOK, &GetVectorBucketPolicyResponse{Policy: policy})
}
func (s *Server) handleDeleteVectorBucketPolicy(w http.ResponseWriter, r *http.Request) {
var req DeleteVectorBucketPolicyRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.bucketARNs(req.VectorBucketName, req.VectorBucketArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
record, err := s.vectorBucket(r.Context(), arns.bucket)
if err != nil {
catalogErr(w, err)
return
}
if !isVectorBucket(record) {
writeError(w, http.StatusNotFound, "NotFoundException",
fmt.Sprintf("vector bucket %s not found", arns.bucket))
return
}
err = s.mutateVectorPolicy(r.Context(), arns.bucket, func(ext map[string][]byte) error {
if !s.checkVectorPolicyDoc(r, "s3vectors:DeleteVectorBucketPolicy", arns.bucket, record.OwnerAccountID, ext) {
return errVectorDenied
}
removed := false
if _, ok := ext[vectorPolicyKey]; ok {
delete(ext, vectorPolicyKey)
removed = true
}
// A legacy document in the shared slot loses its vector rules with
// the delete; catalog rules in it stay untouched.
if stripped, changed := stripVectorActions(string(ext[s3tables.ExtendedKeyPolicy])); changed {
removed = true
if stripped == "" {
delete(ext, s3tables.ExtendedKeyPolicy)
} else {
ext[s3tables.ExtendedKeyPolicy] = []byte(stripped)
}
}
if !removed {
return errNoVectorPolicy
}
return nil
})
if errors.Is(err, errNoVectorPolicy) {
writeError(w, http.StatusNotFound, "NotFoundException", errNoVectorPolicy.Error())
return
}
if err != nil {
catalogErr(w, err)
return
}
writeJSON(w, http.StatusOK, &DeleteVectorBucketPolicyResponse{})
}
func (s *Server) handleCreateIndex(w http.ResponseWriter, r *http.Request) {
var req CreateIndexRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if strings.ToLower(req.DataType) != "float32" {
writeError(w, http.StatusBadRequest, "ValidationException", "only float32 vectors are supported")
return
}
metric, ok := distanceMetric(req.DistanceMetric)
if !ok {
writeError(w, http.StatusBadRequest, "ValidationException", "unknown distanceMetric "+req.DistanceMetric)
return
}
arns, err := s.resolveIndexARNs(req.VectorBucketName, req.VectorBucketArn, req.IndexName, req.IndexArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if req.Dimension <= 0 || req.Dimension > 4096 {
writeError(w, http.StatusBadRequest, "ValidationException", "dimension must be 1..=4096")
return
}
// The create grant is exercised before any write: a denied caller must not
// leave even the implicit namespace behind.
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:CreateIndex", arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
// The namespace is implicit on AWS and internal bookkeeping here.
if err := s.ensureIndexNamespace(r.Context(), arns.bucket); err != nil {
catalogErr(w, err)
return
}
// Only a caller who passed the create check may learn an index exists.
var existing map[string]interface{}
getErr := s.internalCatalog(r.Context(), "GetTable", &s3tables.GetTableRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
Name: arns.index,
}, &existing)
switch {
case getErr == nil:
writeError(w, http.StatusConflict, "ConflictException",
fmt.Sprintf("index %s already exists", arns.index))
return
case catalogErrCode(getErr) != s3tables.ErrCodeNoSuchTable:
catalogErr(w, getErr)
return
}
metadata := map[string]string{
"dimension": fmt.Sprint(req.Dimension),
"distanceMetric": strings.ToLower(req.DistanceMetric),
// The nonce marks the record this request created, so a rollback after
// an engine failure can never erase a pre-existing index.
"createNonce": uuid.NewString(),
}
var nonFilterable []string
if req.MetadataConfiguration != nil {
nonFilterable = req.MetadataConfiguration.NonFilterableMetadataKeys
if keys, err := json.Marshal(nonFilterable); err == nil {
metadata["nonFilterableMetadataKeys"] = string(keys)
}
}
metadataJSON, _ := json.Marshal(metadata)
err = s.internalCatalog(r.Context(), "CreateTable", &s3tables.CreateTableRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
Name: arns.index,
Format: s3tables.FormatLance,
Metadata: &s3tables.TableMetadata{FullMetadata: metadataJSON},
}, nil)
if err != nil {
catalogErr(w, err)
return
}
_, err = s.vectors.CreateIndex(r.Context(), &vector_pb.CreateIndexRequest{
Table: tableID(arns.bucket, arns.index),
Dimension: uint32(req.Dimension),
DistanceMetric: metric,
NonFilterableMetadataKeys: nonFilterable,
})
if err != nil {
// Roll the index record back so a half-created index does not linger.
// The delete runs past a client disconnect and only removes the record
// this request created: the nonce must match, and the versionToken
// compare-and-swap keeps a racing create from being erased.
ctx := context.WithoutCancel(r.Context())
var rec map[string]interface{}
if gerr := s.internalCatalog(ctx, "GetTable", &s3tables.GetTableRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
Name: arns.index,
}, &rec); gerr == nil && recordNonce(rec) == metadata["createNonce"] {
var delResp map[string]interface{}
if derr := s.internalCatalog(ctx, "DeleteTable", &s3tables.DeleteTableRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
Name: arns.index,
VersionToken: recordVersionToken(rec),
}, &delResp); derr != nil {
glog.Errorf("S3 Vectors: rollback of catalog table %s/%s failed: %v", arns.bucket, arns.index, derr)
}
} else if gerr != nil {
glog.Errorf("S3 Vectors: rollback lookup for %s/%s failed: %v", arns.bucket, arns.index, gerr)
}
vectorErr(w, err)
return
}
writeJSON(w, http.StatusOK, &CreateIndexResponse{IndexArn: s.indexArn(arns.bucket, arns.index)})
}
// recordNonce and recordVersionToken read the catalog record's internal
// bookkeeping for the create-index rollback and DeleteIndex.
func recordNonce(record map[string]interface{}) string {
if meta, ok := record["metadata"].(map[string]interface{}); ok {
if fm, ok := meta["fullMetadata"].(map[string]interface{}); ok {
return fmt.Sprint(fm["createNonce"])
}
}
return ""
}
func recordVersionToken(record map[string]interface{}) string {
token, _ := record["versionToken"].(string)
return token
}
func indexFromRecord(s *Server, bucket, name string, m map[string]interface{}) *Index {
idx := &Index{
IndexArn: s.indexArn(bucket, name),
IndexName: name,
VectorBucketName: bucket,
DataType: "float32",
}
if created, ok := m["createdAt"].(string); ok {
idx.CreationTime, _ = time.Parse(time.RFC3339, created)
}
if meta, ok := m["metadata"].(map[string]interface{}); ok {
if fm, ok := meta["fullMetadata"].(map[string]interface{}); ok {
var dim float64
fmt.Sscan(fmt.Sprint(fm["dimension"]), &dim)
idx.Dimension = int32(dim)
idx.DistanceMetric = strings.ToLower(fmt.Sprint(fm["distanceMetric"]))
switch keys := fm["nonFilterableMetadataKeys"].(type) {
case string:
var list []string
if err := json.Unmarshal([]byte(keys), &list); err == nil && list != nil {
idx.MetadataConfiguration = &MetadataConfiguration{NonFilterableMetadataKeys: list}
}
case []interface{}:
var list []string
for _, k := range keys {
list = append(list, fmt.Sprint(k))
}
idx.MetadataConfiguration = &MetadataConfiguration{NonFilterableMetadataKeys: list}
}
}
}
if idx.DistanceMetric == "" || idx.DistanceMetric == "<nil>" {
idx.DistanceMetric = "euclidean"
}
return idx
}
func (s *Server) handleGetIndex(w http.ResponseWriter, r *http.Request) {
var req GetIndexRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.resolveIndexARNs(req.VectorBucketName, req.VectorBucketArn, req.IndexName, req.IndexArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:GetIndex", arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
var resp map[string]interface{}
err = s.internalCatalog(r.Context(), "GetTable", &s3tables.GetTableRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
Name: arns.index,
}, &resp)
if err != nil {
catalogErr(w, err)
return
}
writeJSON(w, http.StatusOK, &GetIndexResponse{Index: indexFromRecord(s, arns.bucket, arns.index, resp)})
}
func (s *Server) handleListIndexes(w http.ResponseWriter, r *http.Request) {
var req ListIndexesRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.bucketARNs(req.VectorBucketName, req.VectorBucketArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
maxResults := 0
if req.MaxResults != nil {
if *req.MaxResults < 1 || *req.MaxResults > vectorsMaxListPage {
writeError(w, http.StatusBadRequest, "ValidationException", fmt.Sprintf("maxResults must be between 1 and %d", vectorsMaxListPage))
return
}
maxResults = *req.MaxResults
}
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:ListIndexes", arns.bucket, ""); err != nil {
catalogErr(w, err)
return
}
var resp struct {
TableSummaries []struct {
Name string `json:"name"`
Format string `json:"format"`
CreatedAt string `json:"createdAt"`
} `json:"tables"`
ContinuationToken string `json:"continuationToken"`
}
err = s.internalCatalog(r.Context(), "ListTables", &s3tables.ListTablesRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
MaxTables: maxResults,
ContinuationToken: req.NextToken,
Prefix: req.Prefix,
}, &resp)
if err != nil {
catalogErr(w, err)
return
}
// Summaries carry only what the catalog listing knows; a summary that
// asserted a dimension or metric without a record lookup would lie.
out := &ListIndexesResponse{NextToken: resp.ContinuationToken, Indexes: []*IndexSummary{}}
for _, t := range resp.TableSummaries {
// A non-Lance table in the index namespace is not an index.
if t.Format != "" && t.Format != s3tables.FormatLance {
continue
}
created, _ := time.Parse(time.RFC3339, t.CreatedAt)
out.Indexes = append(out.Indexes, &IndexSummary{
IndexName: t.Name,
IndexArn: s.indexArn(arns.bucket, t.Name),
VectorBucketName: arns.bucket,
CreationTime: created,
})
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleDeleteIndex(w http.ResponseWriter, r *http.Request) {
var req DeleteIndexRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.resolveIndexARNs(req.VectorBucketName, req.VectorBucketArn, req.IndexName, req.IndexArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:DeleteIndex", arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
// A named table is an index only while it carries the Lance format —
// an Iceberg or format-less table under the same namespace is not an
// index and must not be deleted by this operation.
var record map[string]interface{}
if err := s.internalCatalog(r.Context(), "GetTable", &s3tables.GetTableRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
Name: arns.index,
}, &record); err != nil {
catalogErr(w, err)
return
}
if format, _ := record["format"].(string); format != s3tables.FormatLance {
writeError(w, http.StatusBadRequest, "ValidationException",
fmt.Sprintf("index %q is not a vector index", arns.index))
return
}
// Deleting the catalog record is the delete: the catalog purges the
// dataset under the index's location along with the entry.
var resp map[string]interface{}
// The token limits the delete to the record checked above.
err = s.internalCatalog(r.Context(), "DeleteTable", &s3tables.DeleteTableRequest{
TableBucketARN: s.s3tablesArn(arns.bucket),
Namespace: []string{indexNamespace},
Name: arns.index,
VersionToken: recordVersionToken(record),
}, &resp)
if err != nil {
catalogErr(w, err)
return
}
writeJSON(w, http.StatusOK, &DeleteIndexResponse{})
}
func metadataStruct(m map[string]interface{}) (*structpb.Struct, error) {
if m == nil {
return nil, nil
}
return structpb.NewStruct(m)
}
func structToMap(s *structpb.Struct) map[string]interface{} {
if s == nil {
return nil
}
return s.AsMap()
}
func (s *Server) handlePutVectors(w http.ResponseWriter, r *http.Request) {
var req PutVectorsRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.resolveIndexARNs(req.VectorBucketName, req.VectorBucketArn, req.IndexName, req.IndexArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if len(req.Vectors) == 0 || len(req.Vectors) > vectorsMaxPutBatch {
writeError(w, http.StatusBadRequest, "ValidationException",
fmt.Sprintf("vectors must contain between 1 and %d items", vectorsMaxPutBatch))
return
}
// A read grant on the index does not carry a write: mutations require the
// vector write grant, the same check credential vending applies.
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:PutVectors", arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
record, err := s.indexRecord(r.Context(), arns.bucket, arns.index)
if err != nil {
catalogErr(w, err)
return
}
dimension := indexFromRecord(s, arns.bucket, arns.index, record).Dimension
vectors := make([]*vector_pb.Vector, 0, len(req.Vectors))
for _, v := range req.Vectors {
pbVector, err := toPutVector(v, dimension)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
vectors = append(vectors, pbVector)
}
_, err = s.vectors.PutVectors(r.Context(), &vector_pb.PutVectorsRequest{
Table: tableID(arns.bucket, arns.index),
Vectors: vectors,
})
if err != nil {
vectorErr(w, err)
return
}
writeJSON(w, http.StatusOK, &PutVectorsResponse{})
}
func toPutVector(v *PutInputVector, dimension int32) (*vector_pb.Vector, error) {
if v == nil {
return nil, fmt.Errorf("vectors contains a null element")
}
if v.Key == "" {
return nil, fmt.Errorf("vector key must not be empty")
}
metadata, err := metadataStruct(v.Metadata)
if err != nil {
return nil, err
}
var data []float32
if v.Data != nil {
data = v.Data.Float32
}
if int32(len(data)) != dimension {
return nil, fmt.Errorf("vector %q has %d dimensions, the index declares %d", v.Key, len(data), dimension)
}
return &vector_pb.Vector{Key: v.Key, Data: data, Metadata: metadata}, nil
}
func toOutputVectors(vectors []*vector_pb.Vector, returnData, returnMetadata bool) []*OutputVector {
out := make([]*OutputVector, 0, len(vectors))
for _, v := range vectors {
o := &OutputVector{Key: v.Key}
if returnData {
o.Data = &VectorData{Float32: v.Data}
}
if returnMetadata {
o.Metadata = structToMap(v.Metadata)
}
out = append(out, o)
}
return out
}
// vectorKeysBounds enforces the AWS 1..max key list window; GetVectors is
// capped lower than a PutVectors or DeleteVectors batch.
func vectorKeysBounds(keys []string, max int) error {
if len(keys) == 0 || len(keys) > max {
return fmt.Errorf("keys must be between 1 and %d", max)
}
return nil
}
func (s *Server) handleGetVectors(w http.ResponseWriter, r *http.Request) {
var req GetVectorsRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if err := vectorKeysBounds(req.Keys, vectorsMaxGetKeys); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.resolveIndexARNs(req.VectorBucketName, req.VectorBucketArn, req.IndexName, req.IndexArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:GetVectors", arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
if _, err := s.indexRecord(r.Context(), arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
resp, err := s.vectors.GetVectors(r.Context(), &vector_pb.GetVectorsRequest{
Table: tableID(arns.bucket, arns.index),
Keys: req.Keys,
ReturnData: req.ReturnData,
ReturnMetadata: req.ReturnMetadata,
})
if err != nil {
vectorErr(w, err)
return
}
writeJSON(w, http.StatusOK, &GetVectorsResponse{
Vectors: toOutputVectors(resp.Vectors, req.ReturnData, req.ReturnMetadata),
})
}
func (s *Server) handleListVectors(w http.ResponseWriter, r *http.Request) {
var req ListVectorsRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if err := listVectorsBounds(&req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.resolveIndexARNs(req.VectorBucketName, req.VectorBucketArn, req.IndexName, req.IndexArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:ListVectors", arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
if _, err := s.indexRecord(r.Context(), arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
maxResults := 0
if req.MaxResults != nil {
maxResults = *req.MaxResults
}
// AWS exposes a static split: segmentIndex/segmentCount divides the full
// listing. The engine service does not know AWS segments, so page through
// the index here and keep only the caller's share; a call scans about
// segmentCount × maxResults rows. The engine's next_token is a row offset,
// so the caller's token is the offset to resume scanning from.
if req.SegmentCount != nil {
var offset uint64
if req.NextToken != "" {
var err error
if offset, err = strconv.ParseUint(req.NextToken, 10, 64); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", "invalid nextToken")
return
}
}
out, nextToken, err := s.listSegmentVectors(r.Context(), tableID(arns.bucket, arns.index),
offset, maxResults, uint64(*req.SegmentCount), uint64(*req.SegmentIndex), req.ReturnData, req.ReturnMetadata)
if err != nil {
vectorErr(w, err)
return
}
writeJSON(w, http.StatusOK, &ListVectorsResponse{
NextToken: nextToken,
Vectors: toOutputVectors(out, req.ReturnData, req.ReturnMetadata),
})
return
}
resp, err := s.vectors.ListVectors(r.Context(), &vector_pb.ListVectorsRequest{
Table: tableID(arns.bucket, arns.index),
MaxResults: uint32(maxResults),
NextToken: req.NextToken,
ReturnData: req.ReturnData,
ReturnMetadata: req.ReturnMetadata,
})
if err != nil {
vectorErr(w, err)
return
}
writeJSON(w, http.StatusOK, &ListVectorsResponse{
NextToken: resp.NextToken,
Vectors: toOutputVectors(resp.Vectors, req.ReturnData, req.ReturnMetadata),
})
}
// The page constants mirror the engine's page sizing so a segmented listing
// pages like a plain one does; vectorsMaxPage is also the AWS ListVectors
// maxResults cap, and vectorsMaxTopK the AWS QueryVectors limit.
const (
vectorsDefaultPage = 500
vectorsMaxGetKeys = 100
vectorsMaxPutBatch = 500
vectorsMaxPage = 1000
vectorsMaxListPage = 500
vectorsMaxTopK = 10000
vectorsMaxSegments = 16
)
// listVectorsBounds enforces the AWS ListVectors windows: maxResults 1..1000,
// segmentCount 1..16, and segmentIndex below it, the two given together.
func listVectorsBounds(req *ListVectorsRequest) error {
if req.MaxResults != nil && (*req.MaxResults < 1 || *req.MaxResults > vectorsMaxPage) {
return fmt.Errorf("maxResults must be between 1 and %d", vectorsMaxPage)
}
if (req.SegmentCount == nil) != (req.SegmentIndex == nil) {
return fmt.Errorf("segmentCount and segmentIndex must be specified together")
}
if req.SegmentCount == nil {
return nil
}
if *req.SegmentCount < 1 || *req.SegmentCount > vectorsMaxSegments {
return fmt.Errorf("segmentCount must be between 1 and %d", vectorsMaxSegments)
}
if *req.SegmentIndex < 0 || *req.SegmentIndex >= *req.SegmentCount {
return fmt.Errorf("segmentIndex must be in [0, segmentCount)")
}
return nil
}
// listSegmentVectors scans the index from offset, keeping the rows that fall
// in the caller's segment until limit are collected or the index runs out.
// The engine's next_token is a row offset, so the returned token is the offset
// scanning resumes from.
func (s *Server) listSegmentVectors(ctx context.Context, table *vector_pb.Table, offset uint64, maxResults int, segmentCount, segmentIndex uint64, returnData, returnMetadata bool) ([]*vector_pb.Vector, string, error) {
limit := maxResults
if limit <= 0 {
limit = vectorsDefaultPage
} else if limit > vectorsMaxPage {
limit = vectorsMaxPage
}
var out []*vector_pb.Vector
nextToken := ""
token := strconv.FormatUint(offset, 10)
for {
page, err := s.vectors.ListVectors(ctx, &vector_pb.ListVectorsRequest{
Table: table,
MaxResults: vectorsMaxPage,
NextToken: token,
ReturnData: returnData,
ReturnMetadata: returnMetadata,
})
if err != nil {
return nil, "", err
}
base := offset
offset += uint64(len(page.Vectors))
full := false
for j, v := range page.Vectors {
if (base+uint64(j))%segmentCount == segmentIndex {
out = append(out, v)
if len(out) == limit {
if j+1 < len(page.Vectors) || page.NextToken != "" {
nextToken = strconv.FormatUint(base+uint64(j)+1, 10)
}
full = true
break
}
}
}
if full || len(page.Vectors) == 0 || page.NextToken == "" {
break
}
token = page.NextToken
}
return out, nextToken, nil
}
func (s *Server) handleDeleteVectors(w http.ResponseWriter, r *http.Request) {
var req DeleteVectorsRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if err := vectorKeysBounds(req.Keys, vectorsMaxPutBatch); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.resolveIndexARNs(req.VectorBucketName, req.VectorBucketArn, req.IndexName, req.IndexArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:DeleteVectors", arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
if _, err := s.indexRecord(r.Context(), arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
_, err = s.vectors.DeleteVectors(r.Context(), &vector_pb.DeleteVectorsRequest{
Table: tableID(arns.bucket, arns.index),
Keys: req.Keys,
})
if err != nil {
vectorErr(w, err)
return
}
writeJSON(w, http.StatusOK, &DeleteVectorsResponse{})
}
// translateFilter renders an AWS S3 Vectors filter document as a Lance SQL
// predicate over the metadata JSON column. Only the documented operators are
// translated; anything else fails loudly instead of silently overmatching.
func translateFilter(filter map[string]interface{}) (string, error) {
if len(filter) == 0 {
return "", nil
}
return filterExpr(filter)
}
// nonFilterableField returns the first blocked metadata key a filter
// references; the bool reports whether any matched.
func nonFilterableField(cfg *MetadataConfiguration, filter map[string]interface{}) (string, bool) {
if cfg == nil {
return "", false
}
fields := map[string]bool{}
filterFields(filter, fields)
for _, k := range cfg.NonFilterableMetadataKeys {
if fields[k] {
return k, true
}
}
return "", false
}
// filterFields collects the field names a filter expression references —
// $-operators recurse; every other key is a field.
func filterFields(filter map[string]interface{}, out map[string]bool) {
for key, cond := range filter {
if strings.HasPrefix(key, "$") {
if sub, ok := cond.([]interface{}); ok {
for _, item := range sub {
if m, ok := item.(map[string]interface{}); ok {
filterFields(m, out)
}
}
}
continue
}
out[key] = true
}
}
func filterExpr(filter map[string]interface{}) (string, error) {
parts := make([]string, 0, len(filter))
for key, cond := range filter {
if strings.HasPrefix(key, "$") {
sub, ok := cond.([]interface{})
if !ok {
return "", fmt.Errorf("%s expects a list", key)
}
if len(sub) == 0 {
return "", fmt.Errorf("%s expects a non-empty list", key)
}
exprs := make([]string, 0, len(sub))
for _, item := range sub {
m, ok := item.(map[string]interface{})
if !ok {
return "", fmt.Errorf("%s expects objects", key)
}
e, err := filterExpr(m)
if err != nil {
return "", err
}
if e == "" {
return "", fmt.Errorf("%s expects non-empty objects", key)
}
exprs = append(exprs, e)
}
joiner := " AND "
if key == "$or" {
joiner = " OR "
}
if key != "$and" && key != "$or" {
return "", fmt.Errorf("unsupported operator %s", key)
}
parts = append(parts, "("+strings.Join(exprs, joiner)+")")
continue
}
clause, err := fieldClause(key, cond)
if err != nil {
return "", err
}
parts = append(parts, clause)
}
return strings.Join(parts, " AND "), nil
}
// fieldAccess renders the operand's comparison column over the serialized
// json_extract text. Strings and booleans compare the serialized form
// directly, which is exact-typed and never errors — booleans serialize as
// exactly 'true'/'false', and a cast would also accept the '1'/'0' a stored
// number produces. Numbers go through TRY_CAST, which is NULL on any other
// stored type. The json_get_* extractors are not used — they convert
// numeric-looking strings and booleans, and error on mismatched types.
func fieldAccess(field string, operand interface{}) (string, error) {
path := sqlEscape(existsPath(field))
switch operand.(type) {
case float64:
return fmt.Sprintf("TRY_CAST(json_extract(`metadata`, '%s') AS DOUBLE)", path), nil
case bool, string:
return fmt.Sprintf("json_extract(`metadata`, '%s')", path), nil
default:
return "", fmt.Errorf("unsupported filter operand %v", operand)
}
}
// fieldMatch renders "field <rhs>".
func fieldMatch(field string, operand interface{}, rhs string) (string, error) {
col, err := fieldAccess(field, operand)
if err != nil {
return "", err
}
return col + " " + rhs, nil
}
// existsPath renders the field as a JSONPath for the json_exists and
// json_extract parsers: bracket notation keeps dots, spaces, and other
// specials inside the key literal.
func existsPath(field string) string {
field = strings.ReplaceAll(field, `\`, `\\`)
field = strings.ReplaceAll(field, `"`, `\"`)
return `$["` + field + `"]`
}
func sqlEscape(s string) string {
return strings.ReplaceAll(s, "'", "''")
}
func fieldClause(field string, cond interface{}) (string, error) {
switch c := cond.(type) {
case map[string]interface{}:
parts := []string{}
for op, operand := range c {
switch op {
case "$exists":
if b, ok := operand.(bool); ok {
expr := fmt.Sprintf("json_exists(`metadata`, '%s')", sqlEscape(existsPath(field)))
if !b {
expr = "NOT " + expr
}
parts = append(parts, expr)
} else {
return "", fmt.Errorf("$exists expects a boolean")
}
case "$eq", "$ne":
lit, err := sqlLiteral(operand)
if err != nil {
return "", err
}
match, err := fieldMatch(field, operand, "= "+lit)
if err != nil {
return "", err
}
if op == "$ne" {
match = "NOT COALESCE(" + match + ", false)"
}
parts = append(parts, match)
case "$in", "$nin":
list, ok := operand.([]interface{})
if !ok {
return "", fmt.Errorf("%s expects a list", op)
}
if len(list) == 0 {
if op == "$in" {
parts = append(parts, "false")
} else {
parts = append(parts, "true")
}
continue
}
// Members may mix types; each extractor only sees its own. A
// member whose typed extractor returns NULL must not let NOT
// invert NULL into NULL under $nin, so every group coalesces.
var groups []string
for _, item := range list {
lit, err := sqlLiteral(item)
if err != nil {
return "", err
}
g, err := fieldMatch(field, item, "in ("+lit+")")
if err != nil {
return "", err
}
groups = append(groups, fmt.Sprintf("COALESCE(%s, false)", g))
}
expr := "(" + strings.Join(groups, " OR ") + ")"
if op == "$nin" {
expr = "NOT " + expr
}
parts = append(parts, expr)
case "$lt", "$lte", "$gt", "$gte":
if _, ok := operand.(float64); !ok {
return "", fmt.Errorf("%s expects a number", op)
}
lit, err := sqlLiteral(operand)
if err != nil {
return "", err
}
sym := map[string]string{"$lt": "<", "$lte": "<=", "$gt": ">", "$gte": ">="}[op]
col, err := fieldAccess(field, operand)
if err != nil {
return "", err
}
parts = append(parts, fmt.Sprintf("%s %s %s", col, sym, lit))
default:
return "", fmt.Errorf("unsupported operator %s", op)
}
}
if len(parts) == 0 {
return "", fmt.Errorf("%s expects at least one operator", field)
}
return strings.Join(parts, " AND "), nil
default:
lit, err := sqlLiteral(cond)
if err != nil {
return "", err
}
return fieldMatch(field, cond, "= "+lit)
}
}
// jsonbString renders s as the JSON text json_extract returns for a stored
// string: quoted, with the same escapes the jsonb serializer emits.
func jsonbString(s string) string {
var b strings.Builder
b.WriteByte('"')
for _, r := range s {
switch r {
case '"':
b.WriteString(`\"`)
case '\\':
b.WriteString(`\\`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
case 0:
b.WriteString(`\0`)
default:
if r < 0x20 || (r >= 0x7f && r <= 0x9f) {
fmt.Fprintf(&b, `\u{%x}`, r)
} else {
b.WriteRune(r)
}
}
}
b.WriteByte('"')
return b.String()
}
func sqlLiteral(v interface{}) (string, error) {
switch t := v.(type) {
case string:
return "'" + sqlEscape(jsonbString(t)) + "'", nil
case bool:
if t {
return "'true'", nil
}
return "'false'", nil
case float64:
return fmt.Sprintf("%v", t), nil
}
return "", fmt.Errorf("unsupported filter operand %v", v)
}
func (s *Server) handleQueryVectors(w http.ResponseWriter, r *http.Request) {
var req QueryVectorsRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
arns, err := s.resolveIndexARNs(req.VectorBucketName, req.VectorBucketArn, req.IndexName, req.IndexArn)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
var queryVector []float32
if req.QueryVector != nil {
queryVector = req.QueryVector.Float32
}
if len(queryVector) == 0 {
writeError(w, http.StatusBadRequest, "ValidationException", "queryVector is required")
return
}
if req.TopK <= 0 || req.TopK > vectorsMaxTopK {
writeError(w, http.StatusBadRequest, "ValidationException",
fmt.Sprintf("topK must be between 1 and %d", vectorsMaxTopK))
return
}
if _, err := s.authorizeVector(r.Context(), r, "s3vectors:QueryVectors", arns.bucket, arns.index); err != nil {
catalogErr(w, err)
return
}
record, err := s.indexRecord(r.Context(), arns.bucket, arns.index)
if err != nil {
catalogErr(w, err)
return
}
idx := indexFromRecord(s, arns.bucket, arns.index, record)
if int32(len(queryVector)) != idx.Dimension {
writeError(w, http.StatusBadRequest, "ValidationException",
fmt.Sprintf("queryVector has %d dimensions, the index declares %d", len(queryVector), idx.Dimension))
return
}
if key, blocked := nonFilterableField(idx.MetadataConfiguration, req.Filter); blocked {
writeError(w, http.StatusBadRequest, "ValidationException",
fmt.Sprintf("metadata key %q is not filterable", key))
return
}
metric := idx.DistanceMetric
filter, err := translateFilter(req.Filter)
if err != nil {
writeError(w, http.StatusBadRequest, "ValidationException", err.Error())
return
}
resp, err := s.vectors.QueryVectors(r.Context(), &vector_pb.QueryVectorsRequest{
Table: tableID(arns.bucket, arns.index),
QueryVector: queryVector,
TopK: uint32(req.TopK),
Filter: filter,
ReturnMetadata: req.ReturnMetadata,
ReturnDistance: req.ReturnDistance,
})
if err != nil {
vectorErr(w, err)
return
}
out := &QueryVectorsResponse{DistanceMetric: metric, Vectors: []*QueryOutputVector{}}
for _, v := range resp.Vectors {
o := &QueryOutputVector{Key: v.Key}
if req.ReturnDistance {
o.Distance = &v.Distance
}
if req.ReturnMetadata {
o.Metadata = structToMap(v.Metadata)
}
out.Vectors = append(out.Vectors, o)
}
writeJSON(w, http.StatusOK, out)
}