mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-25 22:40:44 +01:00
* 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>
189 lines
7.1 KiB
Go
189 lines
7.1 KiB
Go
package s3tables
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
// testIdentity/testIdentityAccount mirror the production identity shape used via reflection.
|
|
// Keep these field names in sync with getAccountID to avoid silent breaks.
|
|
type testIdentityAccount struct {
|
|
Id string
|
|
}
|
|
|
|
type testIdentity struct {
|
|
Name string
|
|
Account *testIdentityAccount
|
|
Actions []string
|
|
Claims map[string]interface{}
|
|
}
|
|
|
|
func TestGetAccountIDPrefersClaimsOverAccountID(t *testing.T) {
|
|
h := NewS3TablesHandler()
|
|
id := &testIdentity{
|
|
Account: &testIdentityAccount{Id: s3_constants.AccountAdminId},
|
|
Claims: map[string]interface{}{
|
|
"preferred_username": "alice",
|
|
"sub": "alice-sub",
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req = req.WithContext(s3_constants.SetIdentityInContext(req.Context(), id))
|
|
|
|
got := h.getAccountID(req)
|
|
assert.Equal(t, "alice-sub", got, "expected sub claim to be used before preferred_username")
|
|
assert.NotEqual(t, DefaultAccountID, got, "claims should override default handler account")
|
|
}
|
|
|
|
func TestGetAccountIDUsesSubWhenPreferredUsernameMissing(t *testing.T) {
|
|
h := NewS3TablesHandler()
|
|
id := &testIdentity{
|
|
Account: &testIdentityAccount{Id: s3_constants.AccountAdminId},
|
|
Claims: map[string]interface{}{
|
|
"sub": "user-123",
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req = req.WithContext(s3_constants.SetIdentityInContext(req.Context(), id))
|
|
|
|
got := h.getAccountID(req)
|
|
assert.Equal(t, "user-123", got, "expected sub claim to be used when preferred_username missing")
|
|
assert.NotEqual(t, DefaultAccountID, got, "claims should override default handler account")
|
|
}
|
|
|
|
func TestGetAccountIDFallsBackToHandlerDefaultAccount(t *testing.T) {
|
|
h := NewS3TablesHandler()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
|
|
assert.Equal(t, DefaultAccountID, h.getAccountID(req), "expected handler default account to be returned when no identity is set")
|
|
}
|
|
|
|
func TestGetAccountIDIgnoresEmptyClaimValues(t *testing.T) {
|
|
h := NewS3TablesHandler()
|
|
id := &testIdentity{
|
|
Account: &testIdentityAccount{Id: s3_constants.AccountAdminId},
|
|
Claims: map[string]interface{}{
|
|
"preferred_username": " ",
|
|
"sub": "user-123",
|
|
},
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req = req.WithContext(s3_constants.SetIdentityInContext(req.Context(), id))
|
|
|
|
assert.Equal(t, "user-123", h.getAccountID(req), "expected whitespace preferred_username to be ignored")
|
|
}
|
|
|
|
func TestGetAccountIDFallsBackToIdentityName(t *testing.T) {
|
|
h := NewS3TablesHandler()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req = req.WithContext(s3_constants.SetIdentityNameInContext(req.Context(), "arn:aws:sts::123456789012:assumed-role/S3UserRole/alice-session"))
|
|
|
|
assert.Equal(t, "alice-session", h.getAccountID(req), "expected ARN session suffix to be extracted")
|
|
}
|
|
|
|
func TestGetAccountIDFallsBackToARNColonSegment(t *testing.T) {
|
|
h := NewS3TablesHandler()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req = req.WithContext(s3_constants.SetIdentityNameInContext(req.Context(), "arn:aws:iam::123456789012:root"))
|
|
|
|
assert.Equal(t, "root", h.getAccountID(req), "expected ARN colon segment to be returned as principal")
|
|
}
|
|
|
|
func TestGetAccountIDFallsBackToAmzAccountIdHeader(t *testing.T) {
|
|
h := NewS3TablesHandler()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set(s3_constants.AmzAccountId, "header-account")
|
|
|
|
assert.Equal(t, "header-account", h.getAccountID(req), "expected header value to be used when no identity is present")
|
|
}
|
|
|
|
func TestGetAccountIDFallsBackToAccountID(t *testing.T) {
|
|
h := NewS3TablesHandler()
|
|
id := &testIdentity{
|
|
Account: &testIdentityAccount{Id: "my-account-id"},
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req = req.WithContext(s3_constants.SetIdentityInContext(req.Context(), id))
|
|
|
|
assert.Equal(t, "my-account-id", h.getAccountID(req), "expected Account.Id to be returned when claims are missing")
|
|
}
|
|
|
|
func TestGetAccountIDNonAdminDoesNotInheritAdminAccount(t *testing.T) {
|
|
h := NewS3TablesHandler()
|
|
id := &testIdentity{
|
|
Account: &testIdentityAccount{Id: s3_constants.AccountAdminId},
|
|
Actions: []string{"Read", "List"},
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req = req.WithContext(s3_constants.SetIdentityInContext(req.Context(), id))
|
|
req = req.WithContext(s3_constants.SetIdentityNameInContext(req.Context(), "readonly"))
|
|
|
|
assert.Equal(t, "readonly", h.getAccountID(req), "a non-admin identity must not inherit the shared admin account")
|
|
}
|
|
|
|
func TestGetAccountIDAdminActionKeepsAdminAccount(t *testing.T) {
|
|
h := NewS3TablesHandler()
|
|
id := &testIdentity{
|
|
Account: &testIdentityAccount{Id: s3_constants.AccountAdminId},
|
|
Actions: []string{s3_constants.ACTION_ADMIN},
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req = req.WithContext(s3_constants.SetIdentityInContext(req.Context(), id))
|
|
req = req.WithContext(s3_constants.SetIdentityNameInContext(req.Context(), "root"))
|
|
|
|
assert.Equal(t, s3_constants.AccountAdminId, h.getAccountID(req), "an admin identity keeps the admin account as principal")
|
|
}
|
|
|
|
func TestDefaultAllowForOnlyAppliesWithoutResolvedIdentity(t *testing.T) {
|
|
h := NewS3TablesHandler()
|
|
h.SetDefaultAllow(true)
|
|
|
|
noIdentity := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
assert.True(t, h.defaultAllowFor(noIdentity), "zero-config requests with no identity keep the open default")
|
|
|
|
anon := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
anon = anon.WithContext(s3_constants.SetIdentityInContext(anon.Context(),
|
|
&testIdentity{Name: s3_constants.AccountAnonymousId, Actions: []string{"Read:pub", "List:pub"}}))
|
|
assert.False(t, h.defaultAllowFor(anon), "a resolved anonymous identity is scoped by its grant, not the open default")
|
|
|
|
authed := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
authed = authed.WithContext(s3_constants.SetIdentityInContext(authed.Context(),
|
|
&testIdentity{Name: "readonly", Account: &testIdentityAccount{Id: s3_constants.AccountAdminId}, Actions: []string{"Read"}}))
|
|
assert.False(t, h.defaultAllowFor(authed), "an authenticated identity must not benefit from the open default")
|
|
|
|
h.SetDefaultAllow(false)
|
|
assert.False(t, h.defaultAllowFor(noIdentity), "default-allow disabled is never open")
|
|
}
|
|
|
|
func TestGetAccountIDNormalizesAccountIDARN(t *testing.T) {
|
|
h := NewS3TablesHandler()
|
|
id := &testIdentity{
|
|
Account: &testIdentityAccount{Id: "arn:aws:iam::123456789012:user/bob"},
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req = req.WithContext(s3_constants.SetIdentityInContext(req.Context(), id))
|
|
|
|
assert.Equal(t, "bob", h.getAccountID(req), "expected ARN account ID to be normalized to the suffix")
|
|
}
|
|
|
|
func TestGetAccountIDPrefersIssuerScopedUserID(t *testing.T) {
|
|
h := NewS3TablesHandler()
|
|
id := &testIdentity{
|
|
Claims: map[string]interface{}{
|
|
"aws:userid": "issuerA-hash",
|
|
"sub": "alice-sub",
|
|
"preferred_username": "alice",
|
|
},
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req = req.WithContext(s3_constants.SetIdentityInContext(req.Context(), id))
|
|
|
|
assert.Equal(t, "issuerA-hash", h.getAccountID(req), "aws:userid should win over a bare sub")
|
|
}
|