s3vectors: support TagResource, UntagResource and ListTagsForResource (#12335)

* s3vectors: tag vector buckets and indexes

TagResource, UntagResource and ListTagsForResource answer on
/tags/{resourceArn} (POST, DELETE, GET) and by name. Each authorizes its
own s3vectors action on the bucket or index ARN, then reads or writes the
catalog record's tags. A resource holds at most 50 tags.

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

* s3vectors: apply the tags passed to CreateVectorBucket and CreateIndex

The tags field was accepted and dropped. It is now stored on the new
bucket or index, and creating with tags needs s3vectors:TagResource in
addition to the create action.

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

* test/s3vectors: cover resource tagging end to end

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

* s3tables: keep tag operations to a bound table bucket

TagResource, UntagResource and ListTagsForResource run through Execute
with WithTableBucketCreatedAt now answer not found when the bucket name
holds another bucket, as the bound creates and deletes already do.

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

* s3vectors: bind tagging to the authorized bucket and to vector indexes

The catalog tag calls carry the creation time of the bucket authorizeVector
approved, so a bucket created again under the name is not touched. An index
ARN must name a Lance table: another table in the vector namespace of a
format-less bucket is refused, as DeleteIndex does.

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

* s3vectors: match a TagResource grant scoped to the bucket being created

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

* s3tables: merge tag writes into the tags the record holds at the write

TagResource and UntagResource merged into the tags they read before the
compare-and-swap and wrote that map on every attempt, so a tag write that
landed in between was undone. The merge now runs inside the mutation.
WithMaxTags lets an in-process caller cap the merged set there.

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

* s3vectors: check the 50-tag limit inside the catalog's tag write

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

* s3tables: check a tag operation's bound bucket after its reads

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

* s3vectors: validate each tag key UntagResource removes

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

* s3vectors: validate untag keys one at a time

UntagResource sized a map by the number of submitted tag keys before
validating them, so a large body of repeated keys allocated far more than
the request itself, before authorization ran.

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

* s3vectors: authorize UntagResource before validating its keys

Validating a near-32 MiB body of tag keys costs about 13x its JSON
decode, so an unauthorized caller could make the gateway spend that
work on a request it then refuses.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chris LuandClaude Opus 5.5 authored and GitHub committed 2026-11-02 10:33:26 -08:00
1 parent 690aabda0e
commit ec968aa016
9 files changed
+835 -39

No files matched your search

+14 -7
View File
@@ -69,17 +69,24 @@ func (s *Server) SetAccountID(id string) { s.accountID = id }
// apply the AES256 a bucket declares.
func (s *Server) SetEncryptionChecker(fn func() bool) { s.internal.SetEncryptionChecker(fn) }
// RegisterRoutes mounts POST / dispatching on the X-Amz-Target header and
// POST /{Operation} for clients that route per-path. Metrics wrap Auth like the
// S3 track middleware so rejected requests still count toward the operation.
// RegisterRoutes mounts POST / dispatching on the X-Amz-Target header,
// POST /{Operation} for clients that route per-path, and the tagging calls on
// /tags/{resourceArn}. Metrics wrap Auth like the S3 track middleware so
// rejected requests still count toward the operation.
func (s *Server) RegisterRoutes(router *mux.Router) {
router.HandleFunc("/", s.instrument(s.Auth(s.dispatch))).Methods(http.MethodPost)
router.HandleFunc("/{op}", s.instrument(s.Auth(s.dispatch))).Methods(http.MethodPost)
handler := s.instrument(s.Auth(s.dispatch))
router.HandleFunc("/", handler).Methods(http.MethodPost)
router.HandleFunc(tagsPathPrefix+"{resourceArn:.+}", handler).Methods(http.MethodGet, http.MethodPost, http.MethodDelete)
router.HandleFunc("/{op}", handler).Methods(http.MethodPost)
}
// operationFromRequest resolves the operation the way dispatch does: the last
// path segment, or the X-Amz-Target suffix when the path is bare.
// operationFromRequest resolves the operation the way dispatch does: the
// method on a /tags/ path, else the last path segment, or the X-Amz-Target
// suffix when the path is bare.
func operationFromRequest(r *http.Request) string {
if strings.HasPrefix(r.URL.Path, tagsPathPrefix) {
return tagOperation(r.Method)
}
op := strings.Trim(r.URL.Path, "/")
if i := strings.LastIndex(op, "/"); i >= 0 {
op = op[i+1:]