Files
seaweedfs/weed/s3api/s3tables/iam.go
T
Chris LuandDevin d15396fc4d s3tables: scope the anonymous identity's grant on the table catalogs (#12056)
* s3tables: scope the resolved anonymous identity instead of defaulting to allow

Catalog authorization fell back to the open default whenever the request
resolved to the configured anonymous identity, ignoring its grant. The
fallback now applies only when no principal was resolved (zero-config),
so anonymous requests are checked against the identity's configured
actions like any other principal. The S3 Tables API and the Iceberg and
Lance catalogs share this handler, so all three are covered.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: authenticate the catalog clients in the s3tables suites

Anonymous calls into the Lance and Iceberg catalogs are now scoped by
the anonymous identity's grant, so the suites that used to rely on the
anonymous pass authenticate like their real deployments do: x-api-key
for the Lance probes and REST drivers, client-credentials OAuth for
pyiceberg.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* worker(lance): authenticate namespace calls with the storage credential

Anonymous catalog calls are now scoped by the anonymous grant, so a
worker that holds an S3 credential sends it as x-api-key on the
namespace requests instead of relying on the anonymous pass.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: authenticate the pushdown catalog connection in catalog_lancedb

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* lance: follow catalog redirects only within the same host

reqwest forwards custom headers on a follow, so a redirect to another
host would carry the x-api-key credential to a server it was never
meant for. Keep same-host hops for proxies that normalize the path or
upgrade the scheme.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* lance: warn when the catalog api key travels in cleartext

The x-api-key pair is a real storage credential: over plain http to a
non-loopback host the network can read it. Warn at client build so the
operator sees it; https and loopback stay quiet, and the key still
sends since an http catalog is a valid deployment.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* lance: require the same origin on catalog redirects

A same-host hop can still downgrade https to http and put the api key on
the wire; matching scheme, host, and port keeps the credential on the
listener it was issued for.

* lance: bound catalog redirect hops so loops fail fast

The custom redirect policy drops reqwest's default 10-hop limit, so a
catalog that redirects back to itself would hold each call open until
the request timeout. Keep the bound alongside the origin check.

* lance: gofmt the redirect helpers

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 06:13:22 -07:00

286 lines
7.9 KiB
Go

package s3tables
import (
"context"
"fmt"
"net/http"
"reflect"
"strings"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
// IAMAuthorizer allows s3tables handlers to evaluate IAM policies without importing s3api.
type IAMAuthorizer interface {
IsActionAllowed(ctx context.Context, request *integration.ActionRequest) (bool, error)
}
// SetIAMAuthorizer injects the IAM authorizer for policy-based access checks.
func (h *S3TablesHandler) SetIAMAuthorizer(authorizer IAMAuthorizer) {
h.iamAuthorizer = authorizer
}
func (h *S3TablesHandler) shouldUseIAM(r *http.Request, identityActions, identityPolicyNames []string) bool {
if h.iamAuthorizer == nil || r == nil {
return false
}
if s3_constants.GetIdentityFromContext(r) == nil {
return false
}
// When default-allow is enabled, keep anonymous requests on the legacy path
// to preserve zero-config behavior (IAM policies are not available for anonymous).
if h.defaultAllow && isAnonymousIdentity(r) {
return false
}
// An empty inline `identityActions` slice doesn't mean the identity has no
// permissions—it just means authorization lives in IAM policies or session
// tokens instead of static action lists. We therefore prefer the IAM path
// whenever inline actions are absent and fall back to default policy names
// or session tokens.
if hasSessionToken(r) {
return true
}
if len(identityActions) == 0 {
return true
}
return len(identityPolicyNames) > 0
}
// defaultAllowFor reports whether the open-by-default fallback applies: only for
// trusted tooling or when no principal was resolved at all. A resolved identity
// — the configured anonymous identity included — is scoped by its grant and
// must pass an explicit check.
func (h *S3TablesHandler) defaultAllowFor(r *http.Request) bool {
if h.trusted {
return true
}
if !h.defaultAllow {
return false
}
// The Manager path forwards only the identity name, so a name alone (no
// identity object) still counts as an authenticated principal.
return s3_constants.GetIdentityFromContext(r) == nil && s3_constants.GetIdentityNameFromContext(r) == ""
}
func isAnonymousIdentity(r *http.Request) bool {
val, ok := getIdentityStructValue(r)
if !ok {
return false
}
if nameField := val.FieldByName("Name"); nameField.IsValid() && nameField.Kind() == reflect.String {
if nameField.String() == s3_constants.AccountAnonymousId {
return true
}
}
accountField := val.FieldByName("Account")
if accountField.IsValid() && !accountField.IsNil() {
if accountField.Kind() == reflect.Ptr {
accountField = accountField.Elem()
}
if accountField.Kind() == reflect.Struct {
if idField := accountField.FieldByName("Id"); idField.IsValid() && idField.Kind() == reflect.String {
if idField.String() == s3_constants.AccountAnonymousId {
return true
}
}
}
}
return false
}
func hasSessionToken(r *http.Request) bool {
return extractSessionToken(r) != ""
}
func extractSessionToken(r *http.Request) string {
if s3_constants.IsSessionTokenIgnored(r.Context()) {
return ""
}
if token := r.Header.Get(s3_constants.SeaweedFSSessionTokenHeader); token != "" {
return token
}
if token := r.Header.Get("X-Amz-Security-Token"); token != "" {
return token
}
return r.URL.Query().Get("X-Amz-Security-Token")
}
func (h *S3TablesHandler) authorizeIAMAction(r *http.Request, identityPolicyNames []string, action string, resources ...string) (bool, error) {
if h.iamAuthorizer == nil {
err := fmt.Errorf("nil iamAuthorizer in authorizeIAMAction")
glog.V(2).Infof("S3Tables: %v", err)
return false, err
}
principal := r.Header.Get(s3_constants.SeaweedFSPrincipalHeader)
if principal == "" {
principal = getIdentityPrincipalArn(r)
}
if principal == "" {
return false, fmt.Errorf("missing principal for IAM authorization")
}
if !strings.Contains(action, ":") {
action = "s3tables:" + action
}
sessionToken := extractSessionToken(r)
requestContext := buildIAMRequestContext(r, getIdentityClaims(r))
policyNames := identityPolicyNames
if len(policyNames) == 0 {
policyNames = getIdentityPolicyNames(r)
}
if len(resources) == 0 {
return false, fmt.Errorf("no resources provided to authorizeIAMAction")
}
checkedResource := false
for _, resource := range resources {
if resource == "" {
continue
}
checkedResource = true
allowed, err := h.iamAuthorizer.IsActionAllowed(r.Context(), &integration.ActionRequest{
Principal: principal,
Action: action,
Resource: resource,
SessionToken: sessionToken,
RequestContext: requestContext,
PolicyNames: policyNames,
})
if err != nil {
glog.V(2).Infof("S3Tables: IAM authorization error action=%s resource=%s principal=%s: %v", action, resource, principal, err)
return false, err
}
if !allowed {
err := fmt.Errorf("access denied by IAM for resource %s", resource)
return false, err
}
}
if !checkedResource {
return false, fmt.Errorf("no non-empty resources provided to authorizeIAMAction")
}
return true, nil
}
func getIdentityPrincipalArn(r *http.Request) string {
val, ok := getIdentityStructValue(r)
if !ok {
return ""
}
field := val.FieldByName("PrincipalArn")
if field.IsValid() && field.Kind() == reflect.String {
return field.String()
}
return ""
}
func getIdentityPolicyNames(r *http.Request) []string {
val, ok := getIdentityStructValue(r)
if !ok {
return nil
}
field := val.FieldByName("PolicyNames")
if !field.IsValid() || field.Kind() != reflect.Slice {
return nil
}
policies := make([]string, 0, field.Len())
for i := 0; i < field.Len(); i++ {
item := field.Index(i)
if item.Kind() == reflect.String {
policies = append(policies, item.String())
} else if item.CanInterface() {
policies = append(policies, fmt.Sprint(item.Interface()))
}
}
if len(policies) == 0 {
return nil
}
return policies
}
func getIdentityClaims(r *http.Request) map[string]interface{} {
val, ok := getIdentityStructValue(r)
if !ok {
return nil
}
field := val.FieldByName("Claims")
if !field.IsValid() || field.Kind() != reflect.Map || field.IsNil() {
return nil
}
if field.Type().Key().Kind() != reflect.String {
return nil
}
claims := make(map[string]interface{}, field.Len())
for _, key := range field.MapKeys() {
if key.Kind() != reflect.String {
continue
}
val := field.MapIndex(key)
if !val.IsValid() {
continue
}
claims[key.String()] = val.Interface()
}
if len(claims) == 0 {
return nil
}
return claims
}
func buildIAMRequestContext(r *http.Request, claims map[string]interface{}) map[string]interface{} {
ctx := make(map[string]interface{})
if ua := r.Header.Get("User-Agent"); ua != "" {
ctx["userAgent"] = ua
}
if referer := r.Header.Get("Referer"); referer != "" {
ctx["referer"] = referer
}
for k, v := range claims {
if strings.HasPrefix(k, "jwt:") {
if _, exists := ctx[k]; !exists {
ctx[k] = v
}
}
}
for k, v := range claims {
if strings.HasPrefix(k, "jwt:") {
continue
}
if _, exists := ctx[k]; !exists {
ctx[k] = v
}
jwtKey := "jwt:" + k
if _, exists := ctx[jwtKey]; !exists {
ctx[jwtKey] = v
}
}
if len(ctx) == 0 {
return nil
}
return ctx
}
// getIdentityStructValue fetches the identity struct held in the request context.
// The identity is expected to be a pointer to a struct with the fields used by
// the reflection helpers (PrincipalArn string, PolicyNames []string,
// Claims map[string]interface{}).
// This helper centralizes the nil-check and ptr-deref logic so callers focus on
// reading the specific fields they need.
func getIdentityStructValue(r *http.Request) (reflect.Value, bool) {
identityRaw := s3_constants.GetIdentityFromContext(r)
if identityRaw == nil {
return reflect.Value{}, false
}
val := reflect.ValueOf(identityRaw)
if val.Kind() == reflect.Ptr {
val = val.Elem()
}
if val.Kind() != reflect.Struct {
return reflect.Value{}, false
}
return val, true
}