Files
seaweedfs/weed/s3api/s3tables/handler_identity_test.go
T
Chris LuandDevin 446e6f3444 s3tables: scope OIDC-derived account IDs to the token issuer (#11774)
* s3tables: scope OIDC-derived account IDs to the token issuer

Table ownership keyed off the bare sub claim, so two identity providers
issuing the same sub collided on one account - a caller could act on
tables owned by a different provider's user. OIDC bearer tokens now
carry the same issuer-namespaced (sub, iss) hash STS sessions already
expose as aws:userid, and getAccountID prefers it over the raw claims.

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

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

* s3tables: keep legacy owners working and scrub client-subject claims

- OIDC bearer tokens without a subject are rejected, and a token-supplied
  aws:userid claim is always overwritten with the verified (sub, iss)
  hash, so a forged claim can no longer stand in for the principal.
- Owners written before issuer scoping hold a bare subject; permission
  checks now re-anchor that subject under the caller's issuer, so the
  same (sub, iss) pair keeps its ownership while scoped owners remain
  issuer-isolated. The token issuer is asserted into claims alongside
  sub and carried through PolicyContext.

* s3tables: anchor legacy subjects to the configured issuer set

* s3: enumerate trusted issuers from registered providers too

* s3: fail closed when the OIDC provider store cannot be listed

trustedOIDCIssuers kept the registered issuers when ListOIDCProviders
failed, presenting a partial set as complete. A deployment trusting one
registered issuer A plus a stored issuer B would, on a listing error,
report only A — the single-issuer shape under which a legacy bare
subject re-anchors — letting a caller from A claim a stored subject
that belongs to B and gain owner access.

With no provider store the registered providers are provably complete,
so keep returning them; with a store, a failed listing means the real
set is unknown, so return none.

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-16 19:48:32 -07:00

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 TestDefaultAllowForOnlyAppliesToUnauthenticatedOrAnonymous(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}))
assert.True(t, h.defaultAllowFor(anon), "anonymous requests keep 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")
}