diff --git a/weed/s3api/s3tables/handler_principal_test.go b/weed/s3api/s3tables/handler_principal_test.go index 93014a78c3..eed0eb57ba 100644 --- a/weed/s3api/s3tables/handler_principal_test.go +++ b/weed/s3api/s3tables/handler_principal_test.go @@ -30,13 +30,28 @@ func TestPrincipalForMirrorsGetAccountID(t *testing.T) { m := NewManager() m.SetAccountID("000000000000") - assert.Equal(t, "000000000000", m.PrincipalFor("")) - assert.Equal(t, "alice", m.PrincipalFor("alice")) - assert.Equal(t, s3_constants.AccountAdminId, m.PrincipalFor(s3_constants.AccountAdminId), + named := func(name string) *http.Request { + req := httptest.NewRequest(http.MethodGet, "/", nil) + return req.WithContext(s3_constants.SetIdentityNameInContext(req.Context(), name)) + } + withIdentity := func(id *testIdentity) *http.Request { + req := named(id.Name) + return req.WithContext(s3_constants.SetIdentityInContext(req.Context(), id)) + } + + assert.Equal(t, "000000000000", m.PrincipalFor(nil)) + assert.Equal(t, "000000000000", m.PrincipalFor(named(""))) + assert.Equal(t, "alice", m.PrincipalFor(named("alice"))) + assert.Equal(t, s3_constants.AccountAdminId, m.PrincipalFor(named(s3_constants.AccountAdminId)), "the name branch of getAccountID keeps the admin name") - assert.Equal(t, "000000000000", m.PrincipalFor(" ")) - assert.Equal(t, "bob", m.PrincipalFor("arn:aws:iam::123456789012:user/bob"), + assert.Equal(t, "000000000000", m.PrincipalFor(named(" "))) + assert.Equal(t, "bob", m.PrincipalFor(named("arn:aws:iam::123456789012:user/bob")), "ARN identities collapse to the trailing segment like getAccountID") + assert.Equal(t, "team-acct", m.PrincipalFor(withIdentity(&testIdentity{Name: "carol", Account: &testIdentityAccount{Id: "team-acct"}})), + "an identity with an account is its account, not its name") + assert.Equal(t, "uid-alice", m.PrincipalFor(withIdentity(&testIdentity{Name: "alice-sub", Account: &testIdentityAccount{Id: "alice-sub"}, + Claims: map[string]interface{}{"aws:userid": "uid-alice"}})), + "a federated identity is its issuer-scoped user id") } func TestCheckPermissionDenyPolicyBindsNonAdmin(t *testing.T) { diff --git a/weed/s3api/s3tables/manager.go b/weed/s3api/s3tables/manager.go index c051a5ecb9..0d6cb441e1 100644 --- a/weed/s3api/s3tables/manager.go +++ b/weed/s3api/s3tables/manager.go @@ -46,24 +46,25 @@ func (m *Manager) SetTrusted(trusted bool) { m.handler.SetTrusted(trusted) } -// PrincipalFor maps a forwarded identity name onto the account the catalog -// would charge the request to: a synthesized request resolves its identity -// name through getAccountID's name branch — normalized like any principal — -// and an empty name falls back to the handler's account ID. -func (m *Manager) PrincipalFor(identity string) string { - if principal := normalizePrincipalID(identity); principal != "" { - return principal +// PrincipalFor resolves the caller of r the way the catalog's handlers do, +// so it matches the owner CreateTableBucket records. A nil request is the +// handler's account ID. +func (m *Manager) PrincipalFor(r *http.Request) string { + if r == nil { + return m.handler.accountID } - return m.handler.accountID + return m.handler.getAccountID(r) } // PolicyContext builds the evaluation context a Manager call's synthesized // request produces: the configured local-account predicate, the caller's own // identity actions so admin and action grants resolve on privileges rather // than the forwarded name, plus the open-by-default fallback only for a -// trusted caller or no resolved identity. -func (m *Manager) PolicyContext(r *http.Request) *PolicyContext { - ctx := &PolicyContext{IsLocalAccountID: m.handler.localAccountIDs, IdentityActions: getIdentityActions(r)} +// trusted caller or no resolved identity. A non-empty bucket lets an identity +// action scoped to that bucket apply, as the handlers do. +func (m *Manager) PolicyContext(r *http.Request, bucket string) *PolicyContext { + ctx := &PolicyContext{IsLocalAccountID: m.handler.localAccountIDs, IdentityActions: getIdentityActions(r), + TrustedIssuers: m.handler.trustedIssuers(r), TableBucketName: bucket} if m.handler.trusted || (m.handler.defaultAllow && s3_constants.GetIdentityNameFromContext(r) == "") { ctx.DefaultAllow = true } diff --git a/weed/s3api/s3vectors/handlers.go b/weed/s3api/s3vectors/handlers.go index 77213ba2f9..2dbe75eab3 100644 --- a/weed/s3api/s3vectors/handlers.go +++ b/weed/s3api/s3vectors/handlers.go @@ -277,12 +277,11 @@ func (s *Server) handleCreateVectorBucket(w http.ResponseWriter, r *http.Request } // No bucket exists yet, so check against the handler account as the // catalog does for CreateTableBucket. - principal := s.tables.PrincipalFor(identityOf(r)) - if !s3tables.CheckPermissionWithContext("s3vectors:CreateVectorBucket", principal, s.tables.PrincipalFor(""), "", "", s.tables.PolicyContext(r)) { + principal := s.tables.PrincipalFor(r) + if !s3tables.CheckPermissionWithContext("s3vectors:CreateVectorBucket", principal, s.tables.PrincipalFor(nil), "", "", s.tables.PolicyContext(r, "")) { catalogErr(w, errVectorDenied) return } - // The caller's name still makes it the owner of the record. var resp map[string]interface{} err := s.internal.Execute(r.Context(), s.filerClient, "CreateTableBucket", &s3tables.CreateTableBucketRequest{ @@ -360,8 +359,7 @@ func (s *Server) handleListVectorBuckets(w http.ResponseWriter, r *http.Request) catalogErr(w, err) return } - principal := s.tables.PrincipalFor(identityOf(r)) - pctx := s.tables.PolicyContext(r) + principal := s.tables.PrincipalFor(r) // The catalog token continues the filtered page: ICEBERG buckets drop out // of the listing, Lance and format-less legacy buckets stay. out := &ListVectorBucketsResponse{NextToken: resp.ContinuationToken, VectorBuckets: []*VectorBucket{}} @@ -376,6 +374,7 @@ func (s *Server) handleListVectorBuckets(w http.ResponseWriter, r *http.Request) continue } arn := s.vectorBucketArn(b.Name) + pctx := s.tables.PolicyContext(r, b.Name) if !s3tables.CheckPermissionWithContext("s3vectors:ListVectorBuckets", principal, owner, policy, arn, pctx) && !s3tables.CheckPermissionWithContext("s3vectors:GetVectorBucket", principal, owner, policy, arn, pctx) { continue @@ -753,9 +752,9 @@ var errVectorDenied = &s3tables.S3TablesError{Type: s3tables.ErrCodeAccessDenied // compare-and-swap, so a grant revoked between reads never authorizes the // pending write. func (s *Server) checkVectorPolicyDoc(r *http.Request, action, bucket, owner string, ext map[string][]byte) bool { - principal := s.tables.PrincipalFor(identityOf(r)) + principal := s.tables.PrincipalFor(r) return s3tables.CheckPermissionWithContext(action, principal, owner, - vectorPolicyFromExt(ext), s.vectorBucketArn(bucket), s.tables.PolicyContext(r)) + vectorPolicyFromExt(ext), s.vectorBucketArn(bucket), s.tables.PolicyContext(r, bucket)) } // authorizeVector grants the request's own vector operation — not a catalog @@ -777,8 +776,8 @@ func (s *Server) authorizeVector(ctx context.Context, r *http.Request, action, b if index != "" { resourceARN = s.indexArn(bucket, index) } - principal := s.tables.PrincipalFor(identityOf(r)) - if !s3tables.CheckPermissionWithContext(action, principal, record.OwnerAccountID, policy, resourceARN, s.tables.PolicyContext(r)) { + principal := s.tables.PrincipalFor(r) + if !s3tables.CheckPermissionWithContext(action, principal, record.OwnerAccountID, policy, resourceARN, s.tables.PolicyContext(r, bucket)) { return nil, &s3tables.S3TablesError{Type: s3tables.ErrCodeAccessDenied, Message: "access denied"} } if !isVectorBucket(record) { diff --git a/weed/s3api/s3vectors/principal_test.go b/weed/s3api/s3vectors/principal_test.go new file mode 100644 index 0000000000..7b3ee431a5 --- /dev/null +++ b/weed/s3api/s3vectors/principal_test.go @@ -0,0 +1,240 @@ +package s3vectors + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/seaweedfs/seaweedfs/weed/s3api" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest" +) + +// authTestEnv drives the vector API through Auth with a chosen identity and +// the table bucket API through the catalog with the same identity, so the +// two can be compared decision by decision. +type authTestEnv struct { + t *testing.T + s *Server + auth *fakeAuthenticator + tables *s3tables.Manager + admin *s3tables.Manager + client s3tables.FilerClient +} + +func newAuthTestEnv(t *testing.T) *authTestEnv { + fs := s3tablestest.Start(t) + client := s3tables.NewManagerClient(fs.Client) + auth := &fakeAuthenticator{} + admin := s3tables.NewManager() + admin.SetTrusted(true) + return &authTestEnv{t: t, s: NewServer(client, auth, newFakeVectors(0)), auth: auth, + tables: s3tables.NewManager(), admin: admin, client: client} +} + +func (e *authTestEnv) vector(ident *s3api.Identity, handler http.HandlerFunc, payload interface{}) int { + e.t.Helper() + return e.vectorResp(ident, handler, payload).Code +} + +func (e *authTestEnv) vectorResp(ident *s3api.Identity, handler http.HandlerFunc, payload interface{}) *httptest.ResponseRecorder { + e.t.Helper() + body, err := json.Marshal(payload) + if err != nil { + e.t.Fatalf("marshal request: %v", err) + } + e.auth.identity = ident + w := httptest.NewRecorder() + e.s.Auth(handler)(w, httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))) + return w +} + +func (e *authTestEnv) table(ident *s3api.Identity, operation string, req interface{}) error { + ctx := s3_constants.SetIdentityInContext(contextWithIdentity(context.Background(), ident.Name), ident) + var resp map[string]interface{} + return e.tables.Execute(ctx, e.client, operation, req, &resp, ident.Name) +} + +func tableARN(bucket string) string { + return "arn:aws:s3tables:us-east-1:000000000000:bucket/" + bucket +} + +func denyPolicy(service, principal string) string { + return fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":{"AWS":%q},"Action":"%s:*","Resource":"*"}]}`, + principal, service) +} + +func (e *authTestEnv) createBuckets(ident *s3api.Identity, name string) { + e.t.Helper() + if err := e.table(ident, "CreateTableBucket", &s3tables.CreateTableBucketRequest{Name: "t-" + name}); err != nil { + e.t.Fatalf("%s: create table bucket: %v", ident.Name, err) + } + if code := e.vector(ident, e.s.handleCreateVectorBucket, &CreateVectorBucketRequest{VectorBucketName: "v-" + name}); code != http.StatusOK { + e.t.Fatalf("%s: create vector bucket: %d", ident.Name, code) + } +} + +func (e *authTestEnv) deny(name, principal string) { + e.t.Helper() + var resp map[string]interface{} + if err := e.admin.Execute(context.Background(), e.client, "PutTableBucketPolicy", &s3tables.PutTableBucketPolicyRequest{ + TableBucketARN: tableARN("t-" + name), ResourcePolicy: denyPolicy("s3tables", principal), + }, &resp, ""); err != nil { + e.t.Fatalf("put table policy: %v", err) + } + if code := e.vector(adminIdentity(), e.s.handlePutVectorBucketPolicy, &PutVectorBucketPolicyRequest{ + VectorBucketName: "v-" + name, Policy: denyPolicy("s3vectors", principal), + }); code != http.StatusOK { + e.t.Fatalf("put vector policy: %d", code) + } +} + +// canGet reports whether ident may read the table bucket and the vector +// bucket created under name. +func (e *authTestEnv) canGet(ident *s3api.Identity, name string) (table, vector bool) { + e.t.Helper() + table = e.table(ident, "GetTableBucket", &s3tables.GetTableBucketRequest{TableBucketARN: tableARN("t-" + name)}) == nil + vector = e.vector(ident, e.s.handleGetVectorBucket, &GetVectorBucketRequest{VectorBucketName: "v-" + name}) == http.StatusOK + return table, vector +} + +func adminIdentity() *s3api.Identity { + return &s3api.Identity{Name: "admin", Account: &s3api.AccountAdmin, Actions: []s3api.Action{s3_constants.ACTION_ADMIN}} +} + +var createActions = []s3api.Action{"s3tables:CreateTableBucket", "s3vectors:CreateVectorBucket"} + +// A vector bucket resolves its caller the way a table bucket does: an +// identity with an account owns what it created and is bound by a Deny +// naming its account, a federated identity is its issuer-scoped user id, and +// only the admin account's admin bypasses a Deny. +func TestVectorPrincipalMatchesTableBuckets(t *testing.T) { + cases := []struct { + name string + ident *s3api.Identity + principal string + owns bool + denied bool + }{ + {name: "admin", ident: adminIdentity(), principal: "admin", owns: true, denied: false}, + {name: "admin-action", ident: &s3api.Identity{Name: "ops", Account: &s3api.Account{Id: "ops"}, + Actions: []s3api.Action{s3_constants.ACTION_ADMIN}}, principal: "ops", owns: true, denied: true}, + {name: "account", ident: &s3api.Identity{Name: "carol", Account: &s3api.Account{Id: "team-acct"}, + Actions: createActions}, principal: "team-acct", owns: true, denied: true}, + {name: "federated", ident: &s3api.Identity{Name: "alice-sub", Account: &s3api.Account{Id: "alice-sub"}, + Claims: map[string]interface{}{"aws:userid": "uid-alice", "sub": "alice-sub"}, Actions: createActions}, + principal: "uid-alice", owns: true, denied: true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + e := newAuthTestEnv(t) + e.createBuckets(c.ident, c.name) + table, vector := e.canGet(c.ident, c.name) + if table != c.owns || vector != c.owns { + t.Fatalf("creator access: table=%v vector=%v, want %v", table, vector, c.owns) + } + e.deny(c.name, c.principal) + table, vector = e.canGet(c.ident, c.name) + if table != !c.denied || vector != !c.denied { + t.Fatalf("under a deny on %q: table=%v vector=%v, want %v", c.principal, table, vector, !c.denied) + } + }) + } +} + +func (e *authTestEnv) createIndex(bucket string) { + e.t.Helper() + if code := e.vector(adminIdentity(), e.s.handleCreateIndex, &CreateIndexRequest{ + VectorBucketName: bucket, IndexName: "idx", DataType: "float32", Dimension: 3, DistanceMetric: "euclidean", + }); code != http.StatusOK { + e.t.Fatalf("create index in %s: %d", bucket, code) + } +} + +func (e *authTestEnv) listedVectorBuckets(ident *s3api.Identity) []string { + e.t.Helper() + w := e.vectorResp(ident, e.s.handleListVectorBuckets, &ListVectorBucketsRequest{}) + if w.Code != http.StatusOK { + e.t.Fatalf("list vector buckets: %d %s", w.Code, w.Body.String()) + } + var resp ListVectorBucketsResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + e.t.Fatalf("decode list: %v", err) + } + names := []string{} + for _, b := range resp.VectorBuckets { + names = append(names, b.VectorBucketName) + } + return names +} + +// An identity action scoped to a bucket, s3vectors::, grants that +// operation on that vector bucket and its indexes only, as +// s3tables:: does on a table bucket. A Deny still wins, and the +// scoped form never creates a bucket. +func TestBucketScopedIdentityActions(t *testing.T) { + e := newAuthTestEnv(t) + for _, name := range []string{"team-a", "team-b"} { + e.createBuckets(adminIdentity(), name) + e.createIndex("v-" + name) + } + scoped := &s3api.Identity{Name: "scoped", Account: &s3api.Account{Id: "scoped"}, Actions: []s3api.Action{ + "s3tables:GetTableBucket:t-team-a", "s3tables:CreateTableBucket:t-team-c", + "s3vectors:GetVectorBucket:v-team-a", "s3vectors:GetIndex:v-team-a", "s3vectors:CreateVectorBucket:v-team-c", + }} + + if table, vector := e.canGet(scoped, "team-a"); !table || !vector { + t.Fatalf("granted bucket: table=%v vector=%v, want both", table, vector) + } + if table, vector := e.canGet(scoped, "team-b"); table || vector { + t.Fatalf("other bucket: table=%v vector=%v, want neither", table, vector) + } + getIndex := func(ident *s3api.Identity, bucket string) int { + return e.vector(ident, e.s.handleGetIndex, &GetIndexRequest{VectorBucketName: bucket, IndexName: "idx"}) + } + getVectors := func(ident *s3api.Identity, bucket string) int { + return e.vector(ident, e.s.handleGetVectors, &GetVectorsRequest{VectorBucketName: bucket, IndexName: "idx", Keys: []string{"a"}}) + } + if code := getIndex(scoped, "v-team-a"); code != http.StatusOK { + t.Fatalf("GetIndex in granted bucket: %d", code) + } + if code := getIndex(scoped, "v-team-b"); code != http.StatusForbidden { + t.Fatalf("GetIndex in other bucket: %d, want 403", code) + } + if code := getVectors(scoped, "v-team-a"); code != http.StatusForbidden { + t.Fatalf("ungranted GetVectors in granted bucket: %d, want 403", code) + } + if got := e.listedVectorBuckets(scoped); len(got) != 1 || got[0] != "v-team-a" { + t.Fatalf("listing: %v, want [v-team-a]", got) + } + if err := e.table(scoped, "CreateTableBucket", &s3tables.CreateTableBucketRequest{Name: "t-team-c"}); err == nil { + t.Fatalf("scoped CreateTableBucket created a table bucket") + } + if code := e.vector(scoped, e.s.handleCreateVectorBucket, &CreateVectorBucketRequest{VectorBucketName: "v-team-c"}); code != http.StatusForbidden { + t.Fatalf("scoped CreateVectorBucket: %d, want 403", code) + } + + wild := &s3api.Identity{Name: "wild", Account: &s3api.Account{Id: "wild"}, Actions: []s3api.Action{"s3vectors:*:v-team-a"}} + if code := getVectors(wild, "v-team-a"); code != http.StatusOK { + t.Fatalf("wildcard grant in its bucket: %d", code) + } + if code := getIndex(wild, "v-team-b"); code != http.StatusForbidden { + t.Fatalf("wildcard grant in other bucket: %d, want 403", code) + } + if code := e.vector(wild, e.s.handleCreateVectorBucket, &CreateVectorBucketRequest{VectorBucketName: "v-team-c"}); code != http.StatusForbidden { + t.Fatalf("wildcard bucket grant created a bucket: %d, want 403", code) + } + + e.deny("team-a", "scoped") + if table, vector := e.canGet(scoped, "team-a"); table || vector { + t.Fatalf("granted bucket under a deny: table=%v vector=%v, want neither", table, vector) + } + if code := getIndex(scoped, "v-team-a"); code != http.StatusForbidden { + t.Fatalf("GetIndex under a deny: %d, want 403", code) + } +}