mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-05-21 09:11:29 +00:00
* fix(s3): stop S3 Tables routes from swallowing buckets named "buckets" or "get-table"
The S3 Tables REST endpoints share top-level paths with the regular S3
API (/buckets for ListTableBuckets/CreateTableBucket, /get-table for
GetTable). They are registered first on the same router as the bucket
subrouter, so a path-style request such as GET /buckets?list-type=2 on
a bucket actually named "buckets" matched ListTableBuckets and returned
JSON. AWS SDK V2 (and Hadoop s3a / Spark) then failed XML parsing with
"Unexpected character '{' (code 123) in prolog".
Disambiguate by requiring the AWS V4 credential scope to name the
s3tables service on the colliding routes. Regular S3 SDKs sign with
service=s3, S3 Tables SDKs sign with service=s3tables, and the scope is
present in both the Authorization header and the X-Amz-Credential query
parameter for presigned URLs, so the matcher works for both flavors.
ARN-bearing S3 Tables routes (/buckets/<arn>, /namespaces/<arn>, etc.)
already cannot collide because colons are not valid in bucket names, so
they are left untouched.
* fix(s3): accept AWS JSON RPC content type as S3 Tables intent signal
The Iceberg catalog integration tests send unsigned PUT /buckets with
Content-Type: application/x-amz-json-1.1 to create table buckets. With
only the credential-scope check, those requests fell through to the
regular S3 CreateBucket handler and the suite went red on this branch.
Extend the matcher so a request is recognized as S3 Tables when either:
- its AWS V4 credential scope names SERVICE=s3tables; or
- it carries the canonical AWS JSON RPC 1.1 content type and is
unsigned (a request explicitly signed for SERVICE=s3 still wins).
The regular S3 SDKs do not send application/x-amz-json-1.1, so the
signal is safe for the colliding paths (/buckets, /get-table).
Also add an AWS SDK V2 for Go integration test under
test/s3/sdk_v2_routing/ that drives the SDK's own XML deserializer
against a bucket literally named "buckets" and "get-table" — the SDK
errors before the test asserts if the server returns the wrong body
shape. Wired up via .github/workflows/s3-sdk-v2-routing-tests.yml,
mirroring the etag/acl workflow.
* s3api: extend service matcher to all S3 Tables routes; simplify scope check
- Apply serviceMatcher to every S3 Tables route, not just the bare-path
ones. ARN-bearing paths could otherwise be hit by an S3 object key
that starts with arn:aws:s3tables:..., inside a bucket named
"buckets", "namespaces", "tables", or "tag". One matcher everywhere
closes both collision classes.
- Replace strings.Split + index lookup with strings.Contains for the
credential-scope check. The scope shape is fixed at
AK/DATE/REGION/SERVICE/aws4_request, slashes only delimit components,
and access keys are alphanumeric — so /s3tables/ matches iff SERVICE
is exactly s3tables. Existing unit cases (including the
access-key-substring case) still pass.
- Read the GetObject body in the SDK v2 routing test with io.ReadAll;
the single Read could return short and make the equality check flaky.
* s3api: drop content-type fallback; sign s3 tables harness traffic instead
The content-type fallback in isS3TablesSignedRequest let an anonymous
regular-S3 request whose body type is application/x-amz-json-1.1 hit
an S3 Tables route when the path-style object key happened to be
shaped like an S3 Tables ARN (e.g. PutObject on bucket "buckets"
with key arn:aws:s3tables:.../bucket/foo/policy). Narrow the matcher
back to the AWS V4 credential scope so only requests signed for
SERVICE=s3tables match the S3 Tables routes.
Update the Iceberg catalog test harness — the only caller still
sending unsigned PUT /buckets — to sign with SERVICE=s3tables. The
mini instance runs in default-allow mode, so the signature itself is
not verified; only the credential scope matters for the route match.
Drop the stale unit cases for the JSON-RPC content-type signal and
the routing test that exercised unsigned harness traffic.
717 lines
26 KiB
Go
717 lines
26 KiB
Go
package s3api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"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/s3err"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
|
)
|
|
|
|
// S3TablesApiServer wraps the S3 Tables handler with S3ApiServer's filer access
|
|
type S3TablesApiServer struct {
|
|
s3a *S3ApiServer
|
|
handler *s3tables.S3TablesHandler
|
|
}
|
|
|
|
// NewS3TablesApiServer creates a new S3 Tables API server
|
|
func NewS3TablesApiServer(s3a *S3ApiServer) *S3TablesApiServer {
|
|
return &S3TablesApiServer{
|
|
s3a: s3a,
|
|
handler: s3tables.NewS3TablesHandler(),
|
|
}
|
|
}
|
|
|
|
// SetRegion sets the AWS region for ARN generation
|
|
func (st *S3TablesApiServer) SetRegion(region string) {
|
|
st.handler.SetRegion(region)
|
|
}
|
|
|
|
// SetAccountID sets the AWS account ID for ARN generation
|
|
func (st *S3TablesApiServer) SetAccountID(accountID string) {
|
|
st.handler.SetAccountID(accountID)
|
|
}
|
|
|
|
// SetDefaultAllow sets whether to allow access by default
|
|
func (st *S3TablesApiServer) SetDefaultAllow(allow bool) {
|
|
st.handler.SetDefaultAllow(allow)
|
|
}
|
|
|
|
// SetIAMAuthorizer injects the IAM authorizer for S3 Tables IAM checks.
|
|
func (st *S3TablesApiServer) SetIAMAuthorizer(authorizer s3tables.IAMAuthorizer) {
|
|
st.handler.SetIAMAuthorizer(authorizer)
|
|
}
|
|
|
|
// S3TablesHandler handles S3 Tables API requests
|
|
func (st *S3TablesApiServer) S3TablesHandler(w http.ResponseWriter, r *http.Request) {
|
|
st.handler.HandleRequest(w, r, st)
|
|
}
|
|
|
|
// WithFilerClient implements the s3tables.FilerClient interface
|
|
func (st *S3TablesApiServer) WithFilerClient(streamingMode bool, fn func(filer_pb.SeaweedFilerClient) error) error {
|
|
return st.s3a.WithFilerClient(streamingMode, fn)
|
|
}
|
|
|
|
// registerS3TablesRoutes registers S3 Tables API routes
|
|
func (s3a *S3ApiServer) registerS3TablesRoutes(router *mux.Router) {
|
|
// Create S3 Tables handler
|
|
s3TablesApi := NewS3TablesApiServer(s3a)
|
|
if s3a.iam != nil && s3a.iam.iamIntegration != nil {
|
|
s3TablesApi.SetDefaultAllow(s3a.iam.iamIntegration.DefaultAllow())
|
|
if s3Integration, ok := s3a.iam.iamIntegration.(*S3IAMIntegration); ok && s3Integration.iamManager != nil {
|
|
s3TablesApi.SetIAMAuthorizer(s3Integration.iamManager)
|
|
}
|
|
} else {
|
|
// If IAM is not configured, allow all access by default
|
|
s3TablesApi.SetDefaultAllow(true)
|
|
}
|
|
|
|
// Regex for S3 Tables Bucket ARN
|
|
const tableBucketARNRegex = "arn:aws:s3tables:[^/:]*:[^/:]*:bucket/[^/]+"
|
|
|
|
// REST-style S3 Tables API routes (used by AWS CLI)
|
|
targetMatcher := func(r *http.Request, rm *mux.RouteMatch) bool {
|
|
return strings.HasPrefix(r.Header.Get("X-Amz-Target"), "S3Tables.")
|
|
}
|
|
// serviceMatcher gates every S3 Tables route so it only matches when the
|
|
// request is genuinely targeting the s3tables service. The bare paths
|
|
// (/buckets, /get-table) collide with regular S3 buckets of the same
|
|
// name; the ARN-bearing paths (/buckets/<arn>, /namespaces/<arn>, ...)
|
|
// could collide with object keys that look like S3 Tables ARNs inside a
|
|
// bucket named "buckets", "namespaces", "tables", or "tag". A single
|
|
// matcher applied to every route closes both classes of collision.
|
|
serviceMatcher := func(r *http.Request, rm *mux.RouteMatch) bool {
|
|
return isS3TablesSignedRequest(r)
|
|
}
|
|
router.Methods(http.MethodPost).Path("/").MatcherFunc(targetMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.S3TablesHandler), "S3Tables-Target"))
|
|
router.Methods(http.MethodPut).Path("/buckets").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("CreateTableBucket", buildCreateTableBucketRequest)), "S3Tables-CreateTableBucket"))
|
|
router.Methods(http.MethodGet).Path("/buckets").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("ListTableBuckets", buildListTableBucketsRequest)), "S3Tables-ListTableBuckets"))
|
|
router.Methods(http.MethodGet).Path("/buckets/{tableBucketARN:" + tableBucketARNRegex + "}").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("GetTableBucket", buildTableBucketArnRequest)), "S3Tables-GetTableBucket"))
|
|
router.Methods(http.MethodDelete).Path("/buckets/{tableBucketARN:" + tableBucketARNRegex + "}").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("DeleteTableBucket", buildDeleteTableBucketRequest)), "S3Tables-DeleteTableBucket"))
|
|
router.Methods(http.MethodPut).Path("/buckets/{tableBucketARN:" + tableBucketARNRegex + "}/policy").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("PutTableBucketPolicy", buildPutTableBucketPolicyRequest)), "S3Tables-PutTableBucketPolicy"))
|
|
router.Methods(http.MethodGet).Path("/buckets/{tableBucketARN:" + tableBucketARNRegex + "}/policy").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("GetTableBucketPolicy", buildGetTableBucketPolicyRequest)), "S3Tables-GetTableBucketPolicy"))
|
|
router.Methods(http.MethodDelete).Path("/buckets/{tableBucketARN:" + tableBucketARNRegex + "}/policy").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("DeleteTableBucketPolicy", buildDeleteTableBucketPolicyRequest)), "S3Tables-DeleteTableBucketPolicy"))
|
|
|
|
router.Methods(http.MethodPut).Path("/namespaces/{tableBucketARN:" + tableBucketARNRegex + "}").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("CreateNamespace", buildCreateNamespaceRequest)), "S3Tables-CreateNamespace"))
|
|
router.Methods(http.MethodGet).Path("/namespaces/{tableBucketARN:" + tableBucketARNRegex + "}").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("ListNamespaces", buildListNamespacesRequest)), "S3Tables-ListNamespaces"))
|
|
router.Methods(http.MethodGet).Path("/namespaces/{tableBucketARN:" + tableBucketARNRegex + "}/{namespace}").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("GetNamespace", buildGetNamespaceRequest)), "S3Tables-GetNamespace"))
|
|
router.Methods(http.MethodDelete).Path("/namespaces/{tableBucketARN:" + tableBucketARNRegex + "}/{namespace}").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("DeleteNamespace", buildDeleteNamespaceRequest)), "S3Tables-DeleteNamespace"))
|
|
|
|
router.Methods(http.MethodPut).Path("/tables/{tableBucketARN:" + tableBucketARNRegex + "}/{namespace}").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("CreateTable", buildCreateTableRequest)), "S3Tables-CreateTable"))
|
|
router.Methods(http.MethodGet).Path("/tables/{tableBucketARN:" + tableBucketARNRegex + "}").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("ListTables", buildListTablesRequest)), "S3Tables-ListTables"))
|
|
router.Methods(http.MethodDelete).Path("/tables/{tableBucketARN:" + tableBucketARNRegex + "}/{namespace}/{name}").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("DeleteTable", buildDeleteTableRequest)), "S3Tables-DeleteTable"))
|
|
|
|
router.Methods(http.MethodPut).Path("/tables/{tableBucketARN:" + tableBucketARNRegex + "}/{namespace}/{name}/policy").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("PutTablePolicy", buildPutTablePolicyRequest)), "S3Tables-PutTablePolicy"))
|
|
router.Methods(http.MethodGet).Path("/tables/{tableBucketARN:" + tableBucketARNRegex + "}/{namespace}/{name}/policy").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("GetTablePolicy", buildGetTablePolicyRequest)), "S3Tables-GetTablePolicy"))
|
|
router.Methods(http.MethodDelete).Path("/tables/{tableBucketARN:" + tableBucketARNRegex + "}/{namespace}/{name}/policy").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("DeleteTablePolicy", buildDeleteTablePolicyRequest)), "S3Tables-DeleteTablePolicy"))
|
|
|
|
router.Methods(http.MethodPost).Path("/tag/{resourceArn:arn:aws:s3tables:.*}").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("TagResource", buildTagResourceRequest)), "S3Tables-TagResource"))
|
|
router.Methods(http.MethodGet).Path("/tag/{resourceArn:arn:aws:s3tables:.*}").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("ListTagsForResource", buildListTagsForResourceRequest)), "S3Tables-ListTagsForResource"))
|
|
router.Methods(http.MethodDelete).Path("/tag/{resourceArn:arn:aws:s3tables:.*}").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("UntagResource", buildUntagResourceRequest)), "S3Tables-UntagResource"))
|
|
|
|
router.Methods(http.MethodGet).Path("/get-table").MatcherFunc(serviceMatcher).
|
|
HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("GetTable", buildGetTableRequest)), "S3Tables-GetTable"))
|
|
|
|
glog.V(1).Infof("S3 Tables API enabled")
|
|
}
|
|
|
|
type s3tablesRequestBuilder func(r *http.Request) (interface{}, error)
|
|
|
|
func (st *S3TablesApiServer) handleRestOperation(operation string, builder s3tablesRequestBuilder) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
payload, err := builder(r)
|
|
if err != nil {
|
|
writeS3TablesError(w, http.StatusBadRequest, s3tables.ErrCodeInvalidRequest, err.Error())
|
|
return
|
|
}
|
|
if err := setS3TablesRequestBody(r, payload); err != nil {
|
|
writeS3TablesError(w, http.StatusInternalServerError, s3tables.ErrCodeInternalError, err.Error())
|
|
return
|
|
}
|
|
r.Header.Set("X-Amz-Target", "S3Tables."+operation)
|
|
st.S3TablesHandler(w, r)
|
|
}
|
|
}
|
|
|
|
func setS3TablesRequestBody(r *http.Request, payload interface{}) error {
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r.Body = io.NopCloser(bytes.NewReader(body))
|
|
r.ContentLength = int64(len(body))
|
|
r.Header.Set("Content-Type", "application/x-amz-json-1.1")
|
|
return nil
|
|
}
|
|
|
|
func readS3TablesJSONBody(r *http.Request, v interface{}) error {
|
|
if r.Body == nil {
|
|
return nil
|
|
}
|
|
defer r.Body.Close()
|
|
const maxRequestBodySize = 10 * 1024 * 1024
|
|
if r.ContentLength > maxRequestBodySize {
|
|
return fmt.Errorf("request body too large: exceeds maximum size of %d bytes", maxRequestBodySize)
|
|
}
|
|
limitedReader := io.LimitReader(r.Body, maxRequestBodySize+1)
|
|
body, err := io.ReadAll(limitedReader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(body) > maxRequestBodySize {
|
|
return fmt.Errorf("request body too large: exceeds maximum size of %d bytes", maxRequestBodySize)
|
|
}
|
|
if len(bytes.TrimSpace(body)) == 0 {
|
|
return nil
|
|
}
|
|
return json.Unmarshal(body, v)
|
|
}
|
|
|
|
func writeS3TablesError(w http.ResponseWriter, status int, code, message string) {
|
|
w.Header().Set("Content-Type", "application/x-amz-json-1.1")
|
|
w.WriteHeader(status)
|
|
errorResponse := map[string]interface{}{
|
|
"__type": code,
|
|
"message": message,
|
|
}
|
|
if err := json.NewEncoder(w).Encode(errorResponse); err != nil {
|
|
glog.Errorf("failed to encode S3Tables error response (status=%d, code=%s, message=%q): %v", status, code, message, err)
|
|
}
|
|
}
|
|
|
|
func getDecodedPathParam(r *http.Request, name string) (string, error) {
|
|
value := mux.Vars(r)[name]
|
|
if value == "" {
|
|
return "", nil
|
|
}
|
|
decoded, err := url.PathUnescape(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if decoded == ".." || strings.Contains(decoded, "../") || strings.Contains(decoded, `..\`) || strings.Contains(decoded, "\x00") {
|
|
return "", fmt.Errorf("invalid path parameter %s", name)
|
|
}
|
|
return decoded, nil
|
|
}
|
|
|
|
func buildTableBucketRequestWithARN(r *http.Request, constructor func(string) interface{}) (interface{}, error) {
|
|
arn, err := getDecodedPathParam(r, "tableBucketARN")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if arn == "" {
|
|
return nil, fmt.Errorf("tableBucketARN is required")
|
|
}
|
|
if _, err := s3tables.ParseBucketNameFromARN(arn); err != nil {
|
|
return nil, err
|
|
}
|
|
return constructor(arn), nil
|
|
}
|
|
|
|
func parseOptionalIntParam(r *http.Request, name string) (int, error) {
|
|
value := r.URL.Query().Get(name)
|
|
if value == "" {
|
|
return 0, nil
|
|
}
|
|
parsed, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s must be an integer", name)
|
|
}
|
|
if parsed <= 0 {
|
|
return 0, fmt.Errorf("%s must be a positive integer", name)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func parseOptionalNamespace(r *http.Request, name string) ([]string, error) {
|
|
value := r.URL.Query().Get(name)
|
|
if value == "" {
|
|
return nil, nil
|
|
}
|
|
parts, err := s3tables.ParseNamespace(value)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid %s: %w", name, err)
|
|
}
|
|
return parts, nil
|
|
}
|
|
|
|
func parseRequiredNamespacePathParam(r *http.Request, name string) ([]string, error) {
|
|
value, err := getDecodedPathParam(r, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if value == "" {
|
|
return nil, fmt.Errorf("%s is required", name)
|
|
}
|
|
return s3tables.ParseNamespace(value)
|
|
}
|
|
|
|
// parseTagKeys handles tag key parsing from query parameters.
|
|
// If a single value contains commas, it is split into multiple keys (e.g., "key1,key2,key3").
|
|
// Otherwise, multiple query values are returned as-is.
|
|
func parseTagKeys(values []string) []string {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
for _, part := range strings.Split(value, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part != "" {
|
|
out = append(out, part)
|
|
}
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func buildCreateTableBucketRequest(r *http.Request) (interface{}, error) {
|
|
var req s3tables.CreateTableBucketRequest
|
|
if err := readS3TablesJSONBody(r, &req); err != nil {
|
|
return nil, err
|
|
}
|
|
return &req, nil
|
|
}
|
|
|
|
func buildListTableBucketsRequest(r *http.Request) (interface{}, error) {
|
|
maxBuckets, err := parseOptionalIntParam(r, "maxBuckets")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &s3tables.ListTableBucketsRequest{
|
|
Prefix: r.URL.Query().Get("prefix"),
|
|
ContinuationToken: r.URL.Query().Get("continuationToken"),
|
|
MaxBuckets: maxBuckets,
|
|
}, nil
|
|
}
|
|
|
|
func buildTableBucketArnRequest(r *http.Request) (interface{}, error) {
|
|
return buildTableBucketRequestWithARN(r, func(arn string) interface{} {
|
|
return &s3tables.GetTableBucketRequest{TableBucketARN: arn}
|
|
})
|
|
}
|
|
|
|
func buildDeleteTableBucketRequest(r *http.Request) (interface{}, error) {
|
|
return buildTableBucketRequestWithARN(r, func(arn string) interface{} {
|
|
return &s3tables.DeleteTableBucketRequest{TableBucketARN: arn}
|
|
})
|
|
}
|
|
|
|
func buildPutTableBucketPolicyRequest(r *http.Request) (interface{}, error) {
|
|
var req s3tables.PutTableBucketPolicyRequest
|
|
if err := readS3TablesJSONBody(r, &req); err != nil {
|
|
return nil, err
|
|
}
|
|
tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.TableBucketARN = tableBucketARN
|
|
return &req, nil
|
|
}
|
|
|
|
func buildGetTableBucketPolicyRequest(r *http.Request) (interface{}, error) {
|
|
return buildTableBucketRequestWithARN(r, func(arn string) interface{} {
|
|
return &s3tables.GetTableBucketPolicyRequest{TableBucketARN: arn}
|
|
})
|
|
}
|
|
|
|
func buildDeleteTableBucketPolicyRequest(r *http.Request) (interface{}, error) {
|
|
return buildTableBucketRequestWithARN(r, func(arn string) interface{} {
|
|
return &s3tables.DeleteTableBucketPolicyRequest{TableBucketARN: arn}
|
|
})
|
|
}
|
|
|
|
func buildCreateNamespaceRequest(r *http.Request) (interface{}, error) {
|
|
var req s3tables.CreateNamespaceRequest
|
|
if err := readS3TablesJSONBody(r, &req); err != nil {
|
|
return nil, err
|
|
}
|
|
tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.TableBucketARN = tableBucketARN
|
|
return &req, nil
|
|
}
|
|
|
|
func buildListNamespacesRequest(r *http.Request) (interface{}, error) {
|
|
tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
maxNamespaces, err := parseOptionalIntParam(r, "maxNamespaces")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &s3tables.ListNamespacesRequest{
|
|
TableBucketARN: tableBucketARN,
|
|
Prefix: r.URL.Query().Get("prefix"),
|
|
ContinuationToken: r.URL.Query().Get("continuationToken"),
|
|
MaxNamespaces: maxNamespaces,
|
|
}, nil
|
|
}
|
|
|
|
func buildGetNamespaceRequest(r *http.Request) (interface{}, error) {
|
|
tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
namespace, err := parseRequiredNamespacePathParam(r, "namespace")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &s3tables.GetNamespaceRequest{
|
|
TableBucketARN: tableBucketARN,
|
|
Namespace: namespace,
|
|
}, nil
|
|
}
|
|
|
|
func buildDeleteNamespaceRequest(r *http.Request) (interface{}, error) {
|
|
tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
namespace, err := parseRequiredNamespacePathParam(r, "namespace")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &s3tables.DeleteNamespaceRequest{
|
|
TableBucketARN: tableBucketARN,
|
|
Namespace: namespace,
|
|
}, nil
|
|
}
|
|
|
|
func buildCreateTableRequest(r *http.Request) (interface{}, error) {
|
|
var req s3tables.CreateTableRequest
|
|
if err := readS3TablesJSONBody(r, &req); err != nil {
|
|
return nil, err
|
|
}
|
|
tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
namespace, err := parseRequiredNamespacePathParam(r, "namespace")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.TableBucketARN = tableBucketARN
|
|
req.Namespace = namespace
|
|
return &req, nil
|
|
}
|
|
|
|
func buildListTablesRequest(r *http.Request) (interface{}, error) {
|
|
tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
namespace, err := parseOptionalNamespace(r, "namespace")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
maxTables, err := parseOptionalIntParam(r, "maxTables")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &s3tables.ListTablesRequest{
|
|
TableBucketARN: tableBucketARN,
|
|
Namespace: namespace,
|
|
Prefix: r.URL.Query().Get("prefix"),
|
|
ContinuationToken: r.URL.Query().Get("continuationToken"),
|
|
MaxTables: maxTables,
|
|
}, nil
|
|
}
|
|
|
|
func buildGetTableRequest(r *http.Request) (interface{}, error) {
|
|
query := r.URL.Query()
|
|
tableARN := query.Get("tableArn")
|
|
req := &s3tables.GetTableRequest{
|
|
TableARN: tableARN,
|
|
}
|
|
if tableARN == "" {
|
|
req.TableBucketARN = query.Get("tableBucketARN")
|
|
namespace, err := parseOptionalNamespace(r, "namespace")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Namespace = namespace
|
|
req.Name = query.Get("name")
|
|
if req.TableBucketARN == "" || len(req.Namespace) == 0 || req.Name == "" {
|
|
return nil, fmt.Errorf("either tableArn or (tableBucketARN, namespace, name) must be provided")
|
|
}
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
func buildDeleteTableRequest(r *http.Request) (interface{}, error) {
|
|
tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
namespace, err := parseRequiredNamespacePathParam(r, "namespace")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
name, err := getDecodedPathParam(r, "name")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if name == "" {
|
|
return nil, fmt.Errorf("name is required")
|
|
}
|
|
if _, err := s3tables.ValidateTableName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
return &s3tables.DeleteTableRequest{
|
|
TableBucketARN: tableBucketARN,
|
|
Namespace: namespace,
|
|
Name: name,
|
|
VersionToken: r.URL.Query().Get("versionToken"),
|
|
}, nil
|
|
}
|
|
|
|
func buildPutTablePolicyRequest(r *http.Request) (interface{}, error) {
|
|
var req s3tables.PutTablePolicyRequest
|
|
if err := readS3TablesJSONBody(r, &req); err != nil {
|
|
return nil, err
|
|
}
|
|
tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
namespace, err := parseRequiredNamespacePathParam(r, "namespace")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
name, err := getDecodedPathParam(r, "name")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if name == "" {
|
|
return nil, fmt.Errorf("name is required")
|
|
}
|
|
if _, err := s3tables.ValidateTableName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
req.TableBucketARN = tableBucketARN
|
|
req.Namespace = namespace
|
|
req.Name = name
|
|
return &req, nil
|
|
}
|
|
|
|
func buildGetTablePolicyRequest(r *http.Request) (interface{}, error) {
|
|
tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
namespace, err := parseRequiredNamespacePathParam(r, "namespace")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
name, err := getDecodedPathParam(r, "name")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if name == "" {
|
|
return nil, fmt.Errorf("name is required")
|
|
}
|
|
if _, err := s3tables.ValidateTableName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
return &s3tables.GetTablePolicyRequest{
|
|
TableBucketARN: tableBucketARN,
|
|
Namespace: namespace,
|
|
Name: name,
|
|
}, nil
|
|
}
|
|
|
|
func buildDeleteTablePolicyRequest(r *http.Request) (interface{}, error) {
|
|
tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
namespace, err := parseRequiredNamespacePathParam(r, "namespace")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
name, err := getDecodedPathParam(r, "name")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if name == "" {
|
|
return nil, fmt.Errorf("name is required")
|
|
}
|
|
if _, err := s3tables.ValidateTableName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
return &s3tables.DeleteTablePolicyRequest{
|
|
TableBucketARN: tableBucketARN,
|
|
Namespace: namespace,
|
|
Name: name,
|
|
}, nil
|
|
}
|
|
|
|
func buildTagResourceRequest(r *http.Request) (interface{}, error) {
|
|
var req s3tables.TagResourceRequest
|
|
if err := readS3TablesJSONBody(r, &req); err != nil {
|
|
return nil, err
|
|
}
|
|
resourceARN, err := getDecodedPathParam(r, "resourceArn")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resourceARN == "" {
|
|
return nil, fmt.Errorf("resourceArn is required")
|
|
}
|
|
req.ResourceARN = resourceARN
|
|
return &req, nil
|
|
}
|
|
|
|
func buildListTagsForResourceRequest(r *http.Request) (interface{}, error) {
|
|
resourceARN, err := getDecodedPathParam(r, "resourceArn")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resourceARN == "" {
|
|
return nil, fmt.Errorf("resourceArn is required")
|
|
}
|
|
return &s3tables.ListTagsForResourceRequest{
|
|
ResourceARN: resourceARN,
|
|
}, nil
|
|
}
|
|
|
|
func buildUntagResourceRequest(r *http.Request) (interface{}, error) {
|
|
resourceARN, err := getDecodedPathParam(r, "resourceArn")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resourceARN == "" {
|
|
return nil, fmt.Errorf("resourceArn is required")
|
|
}
|
|
tagKeys := parseTagKeys(r.URL.Query()["tagKeys"])
|
|
if len(tagKeys) == 0 {
|
|
return nil, fmt.Errorf("tagKeys is required for %s", resourceARN)
|
|
}
|
|
return &s3tables.UntagResourceRequest{
|
|
ResourceARN: resourceARN,
|
|
TagKeys: tagKeys,
|
|
}, nil
|
|
}
|
|
|
|
// isS3TablesSignedRequest reports whether the request is targeting the
|
|
// S3 Tables service. The signal is the AWS V4 credential scope, which
|
|
// names SERVICE=s3tables for S3 Tables SDKs and SERVICE=s3 for regular
|
|
// S3 SDKs. The credential scope appears in the Authorization header
|
|
// (Credential=AK/DATE/REGION/SERVICE/aws4_request) for signed requests
|
|
// and in the X-Amz-Credential query parameter for presigned requests.
|
|
//
|
|
// The credential scope is the only acceptable signal: a content-type-
|
|
// based fallback would let an anonymous regular-S3 request (e.g. a
|
|
// PutObject with body type application/x-amz-json-1.1) sneak through
|
|
// to an S3 Tables route whenever the object key is shaped like an
|
|
// S3 Tables ARN. Clients that genuinely target S3 Tables — including
|
|
// internal test harnesses running against a default-allow server —
|
|
// must sign with SERVICE=s3tables.
|
|
func isS3TablesSignedRequest(r *http.Request) bool {
|
|
scope := extractCredentialScope(r)
|
|
// Credential scope is AK/DATE/REGION/SERVICE/aws4_request. Slashes
|
|
// do not appear inside any other component (access keys are
|
|
// alphanumeric), so /s3tables/ matches iff SERVICE is exactly
|
|
// s3tables.
|
|
return scope != "" && strings.Contains(scope, "/s3tables/")
|
|
}
|
|
|
|
// extractCredentialScope returns the raw credential value from either the
|
|
// Authorization header or the X-Amz-Credential query parameter, without the
|
|
// "Credential=" prefix. Returns the empty string when neither is present.
|
|
func extractCredentialScope(r *http.Request) string {
|
|
if auth := r.Header.Get("Authorization"); auth != "" {
|
|
idx := strings.Index(auth, "Credential=")
|
|
if idx >= 0 {
|
|
tail := auth[idx+len("Credential="):]
|
|
if comma := strings.IndexByte(tail, ','); comma >= 0 {
|
|
tail = tail[:comma]
|
|
}
|
|
return strings.TrimSpace(tail)
|
|
}
|
|
}
|
|
if cred := r.URL.Query().Get("X-Amz-Credential"); cred != "" {
|
|
return cred
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// authenticateS3Tables wraps the handler with IAM authentication using AuthSignatureOnly
|
|
// This authenticates the request but delegates authorization to the S3 Tables handler
|
|
// which performs granular permission checks based on the specific operation.
|
|
func (s3a *S3ApiServer) authenticateS3Tables(f http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
glog.V(2).Infof("S3Tables: authenticateS3Tables called, iam.isEnabled()=%t", s3a.iam.isEnabled())
|
|
if !s3a.iam.isEnabled() {
|
|
f(w, r)
|
|
return
|
|
}
|
|
|
|
// Use AuthSignatureOnly to authenticate the request without authorizing specific actions
|
|
identity, errCode := s3a.iam.AuthSignatureOnly(r)
|
|
if errCode != s3err.ErrNone {
|
|
// If IAM is enabled but DefaultAllow is true, we can proceed even if unauthenticated
|
|
// authorization checks in handlers will then use DefaultAllow logic.
|
|
if s3a.iam.iamIntegration != nil && s3a.iam.iamIntegration.DefaultAllow() {
|
|
glog.V(2).Infof("S3Tables: AuthSignatureOnly failed (%v), but DefaultAllow is true, proceeding", errCode)
|
|
} else {
|
|
glog.Errorf("S3Tables: AuthSignatureOnly failed: %v", errCode)
|
|
s3err.WriteErrorResponse(w, r, errCode)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Store the authenticated identity in request context
|
|
if identity != nil && identity.Name != "" {
|
|
glog.V(2).Infof("S3Tables: authenticated identity Name=%s Account.Id=%s", identity.Name, identity.Account.Id)
|
|
ctx := s3_constants.SetIdentityNameInContext(r.Context(), identity.Name)
|
|
ctx = s3_constants.SetIdentityInContext(ctx, identity)
|
|
r = r.WithContext(ctx)
|
|
} else {
|
|
glog.V(2).Infof("S3Tables: authenticated identity is nil or empty name")
|
|
}
|
|
|
|
f(w, r)
|
|
}
|
|
}
|