diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 385b643..7fd6bd5 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -412,10 +412,9 @@ AUTH_OIDC_SCOPES=openid profile email groups ## Teams A team carries a list of permissions, an optional list of catalog services -(empty means every service; per-service filtering is enforced in a later -release) and optional OIDC group names (see -[Single Sign-On](#single-sign-on-openid-connect)). Users belong -to any number of teams and get the union of their rights. The built-in +(empty means every service, see [Service scope](#service-scope)) and optional +OIDC group names (see [Single Sign-On](#single-sign-on-openid-connect)). Users +belong to any number of teams and get the union of their rights. The built-in `Administrators` team cannot be renamed, deleted or stripped of permissions. | Endpoint | Permission | @@ -429,6 +428,127 @@ Deleting a team detaches its users and revokes its API keys. The last enabled member of `Administrators` cannot be disabled or removed from the team, and nobody can disable their own account. +## Service scope + +> **Warning: the scope only isolates teams once anonymous access is +> restricted.** The anonymous caller always has scope `all`. While +> `AUTH_ANONYMOUS_PERMISSIONS` keeps its transitional default (every +> permission except `access:manage`), a restricted user or API key can read +> and write outside its scope by simply not sending its credential. Set +> `AUTH_ANONYMOUS_PERMISSIONS=` (empty) to require authentication +> everywhere, or list read-only permissions if anonymous reads are wanted +> (anonymous reads then stay unscoped). See +> [Anonymous access](#anonymous-access). + +A team has a service scope: either `all` services, or a list of service +names. The scope restricts WHO sees WHAT; permissions are still required to +perform an operation. + +- A user gets the union of the scopes of their teams, and `all` as soon as + one team is `all`. A user without any team sees nothing. +- A team API key gets the scope of its team. The scope is re-read on every + request, so a change applies immediately to sessions and keys alike. +- A global API key, the anonymous caller and the built-in `Administrators` + team are always `all`. +- Names are compared exactly, case included, with the catalog `name` and with + the `service` of events and locks. A service does not need to exist in the + catalog when it is added to a scope. + +### What is scoped + +| Data | Field | Filtered (only objects of the scope are returned) | Checked (`403` outside the scope) | +|------|-------|---------------------------------------------------|-----------------------------------| +| Events | `attributes.service` | list, search, today, stats, monthly stats | get, create, update, delete, changelog (read and add), Slack id | +| Locks | `service` | list | get, create, update, unlock | +| Catalog | `name` | list, version compliance | get, create or update, delete, versions, dependencies | + +### Responses + +- A list returns only the objects of the scope. A search or a statistic on a + service outside the scope returns an empty result. +- An operation on a single object outside the scope is refused with + `403 Forbidden` (gRPC `PERMISSION_DENIED`), and the error names the service. + Existence is not hidden (there is no `404` masking): service names are not + treated as secrets. +- An update checks both the stored and the new service: an object cannot be + moved into or out of the scope. +- An object without a service is only visible and writable with scope `all`. + A user who belongs to no team has an empty scope and sees and writes + nothing. + +### Catalog + +An in-scope catalog entry is returned whole, so it also shows the names of its +dependencies that are outside the scope (names only, nothing else about them). +Version compliance only lists the projects of the scope and treats a +deliverable outside the scope as absent. To track a shared deliverable, add it +to the scope of the team. + +### Locks and events + +- Creating a deployment event takes the lock of the same service. +- A lock cannot be linked to an event outside the scope. An unknown `event_id` + is accepted. +- Unlocking an in-scope lock that is linked to an out-of-scope event succeeds + but writes nothing to the changelog of that event. +- Completing an in-scope event releases its lock, even when the service of + that lock is outside the scope of the caller. + +### Not scoped + +Custom links and Homer links (they have no service field), `/config.js`, +Swagger, and the AuthService (identity administration, guarded by +`access:manage`). + +### Known limits + +- On event creation, `related_id` may reference an event outside the scope + (only its creation time is used to compute a duration). This reveals that the + event exists and when it was created. +- On locks, an unknown `event_id` and an out-of-scope `event_id` are + distinguishable (the second is refused with `403`). +- The scope selector of the web UI ships in a later release. Until then the + team dialog shows the scope read-only and keeps it unchanged when a team is + edited: create and change restricted scopes through the API. + +### API + +`POST /api/v1alpha1/auth/teams` and `PUT /api/v1alpha1/auth/teams/{id}` accept +`scopeAll` and `scopeServices`. + +- No service means every service. +- `scopeAll: true` together with services is refused with `400`, and so is a + list of blank services. +- Names are trimmed and deduplicated, up to 500 services of 128 characters. +- The scope of `Administrators` cannot be restricted. + +```bash +curl -b jar -X POST http://localhost:8080/api/v1alpha1/auth/teams \ + -H 'Content-Type: application/json' \ + -d '{"name":"payments","permissions":["event:read","event:write","lock:read","lock:write","catalog:read"],"scopeServices":["payments-api","payments-worker"]}' +``` + +`GET /api/v1alpha1/auth/me` reports the effective scope of the caller: + +```json +{ + "authenticated": true, + "kind": "user", + "username": "alice", + "permissions": ["event:read", "event:write"], + "scopeAll": false, + "scopeServices": ["payments-api", "payments-worker"] +} +``` + +### Upgrading + +No action is required: existing teams keep their stored scope, `all` by +default. A team that was already created with a list of services (the field +existed but was not enforced) becomes restricted when you upgrade. Before +upgrading, list them with `GET /api/v1alpha1/auth/teams` and look for the teams +whose `scopeAll` is false. + ## API keys API keys are meant for automation (CI, the MCP server, scripts). A key @@ -475,7 +595,8 @@ the anonymous permissions. `tracker_auth_requests_total{principal,result}` counts authorization decisions, with `principal` in `anonymous`, `user`, `apikey` and `result` -in `allowed`, `unauthenticated`, `denied`. +in `allowed`, `unauthenticated`, `denied`, `scope_denied`. A request refused +for its service scope was first counted `allowed` for its permission. `tracker_auth_logins_total{method,result}` counts login attempts, with `method` in `local`, `oidc` and `result` in `success`, `failure`, diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f812844..bfc9c4a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -79,6 +79,8 @@ BUY_ME_COFFEE_URL=https://www.buymeacoffee.com/yourname | `AUTH_OIDC_TEAM_SYNC` | `true` | Synchronize teams from the groups claim at each login. | | `AUTH_OIDC_BUTTON_LABEL` | `Single Sign-On` | Label of the login button (64 characters max). | +Teams can be restricted to a list of catalog services, see [Service scope](./AUTHENTICATION.md#service-scope). The scope is managed through the teams API, not through environment variables. + When `AUTH_ANONYMOUS_PERMISSIONS` is set, its value is used as is, even when empty. When it is unset, the default is the read-only set `event:read,catalog:read,lock:read,links:read` if `DEMO_MODE=true`, otherwise every permission except `access:manage` (transitional default, with a startup warning). See [AUTHENTICATION.md](AUTHENTICATION.md) for permissions, teams and API keys, and [Single Sign-On](AUTHENTICATION.md#single-sign-on-openid-connect) for the OpenID Connect setup, redirect URI and identity provider recipes. diff --git a/internal/auth/authz/scope.go b/internal/auth/authz/scope.go new file mode 100644 index 0000000..9e9f8d2 --- /dev/null +++ b/internal/auth/authz/scope.go @@ -0,0 +1,54 @@ +package authz + +import ( + "context" + "log/slog" + + "github.com/bananaops/tracker/internal/auth" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// ResultScopeDenied is the result label of tracker_auth_requests_total for a +// request refused because its target service is outside the caller's scope. +const ResultScopeDenied = "scope_denied" + +// ScopeFromContext returns the service scope of the current principal. A +// context without principal gets an empty restricted scope: it sees nothing. +func ScopeFromContext(ctx context.Context) auth.Scope { + p, ok := auth.FromContext(ctx) + if !ok { + return auth.ScopeOf() + } + return p.Scope +} + +// RequireService refuses the request unless every given service is inside +// the scope of the current principal. Call it right after Authorize, with +// the service of the target object, and for an update with both the stored +// and the new service. An empty service is only allowed with an unrestricted +// scope. Service names are compared exactly, case included. +func RequireService(ctx context.Context, services ...string) error { + p, ok := auth.FromContext(ctx) + if !ok { + p = auth.Principal{Kind: auth.KindAnonymous, Username: "anonymous", Scope: auth.ScopeOf()} + } + if len(services) == 0 { + return denyScope(ctx, p, "") + } + for _, service := range services { + if !p.Scope.Allows(service) { + return denyScope(ctx, p, service) + } + } + return nil +} + +func denyScope(ctx context.Context, p auth.Principal, service string) error { + authRequests.WithLabelValues(string(p.Kind), ResultScopeDenied).Inc() + slog.Warn("authz denied", "method", MethodFromContext(ctx), "principal", p.Username, "kind", p.Kind, "reason", "service outside scope", "service", service) + if service == "" { + return status.Error(codes.PermissionDenied, "objects without a service require an unrestricted scope") + } + return status.Errorf(codes.PermissionDenied, "service %q is outside your scope", service) +} diff --git a/internal/auth/authz/scope_test.go b/internal/auth/authz/scope_test.go new file mode 100644 index 0000000..a6d2d69 --- /dev/null +++ b/internal/auth/authz/scope_test.go @@ -0,0 +1,85 @@ +package authz + +import ( + "context" + "testing" + + "github.com/bananaops/tracker/internal/auth" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func scopeTestContext() context.Context { + return grpc.NewContextWithServerTransportStream(context.Background(), fakeTransportStream{method: listEvents}) +} + +func withScope(scope auth.Scope) context.Context { + return auth.WithPrincipal(scopeTestContext(), auth.Principal{Kind: auth.KindUser, Username: "alice", Scope: scope}) +} + +func TestScopeFromContext(t *testing.T) { + empty := ScopeFromContext(scopeTestContext()) + assert.False(t, empty.All) + assert.Empty(t, empty.ServiceList()) + + anon := ScopeFromContext(auth.WithPrincipal(scopeTestContext(), auth.Anonymous(nil))) + assert.True(t, anon.All) + + scoped := ScopeFromContext(withScope(auth.ScopeOf("svc-a"))) + assert.True(t, scoped.Allows("svc-a")) + assert.False(t, scoped.Allows("svc-b")) +} + +func TestRequireService(t *testing.T) { + tests := []struct { + name string + ctx context.Context + services []string + denied bool + contains string + }{ + {"all, service", withScope(auth.ScopeAll()), []string{"svc-a"}, false, ""}, + {"all, empty service", withScope(auth.ScopeAll()), []string{""}, false, ""}, + {"scoped, inside", withScope(auth.ScopeOf("svc-a")), []string{"svc-a"}, false, ""}, + {"scoped, outside", withScope(auth.ScopeOf("svc-a")), []string{"svc-b"}, true, "svc-b"}, + {"scoped, empty service", withScope(auth.ScopeOf("svc-a")), []string{""}, true, ""}, + {"scoped, case sensitive", withScope(auth.ScopeOf("svc-a")), []string{"SVC-A"}, true, "SVC-A"}, + {"scoped, one of two outside", withScope(auth.ScopeOf("svc-a")), []string{"svc-a", "svc-b"}, true, "svc-b"}, + {"scoped, both inside", withScope(auth.ScopeOf("svc-a", "svc-b")), []string{"svc-a", "svc-b"}, false, ""}, + {"empty scope", withScope(auth.ScopeOf()), []string{"svc-a"}, true, "svc-a"}, + {"no principal", scopeTestContext(), []string{"svc-a"}, true, "svc-a"}, + {"all, no argument", withScope(auth.ScopeAll()), nil, true, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := RequireService(tt.ctx, tt.services...) + if !tt.denied { + assert.NoError(t, err) + return + } + assert.Equal(t, codes.PermissionDenied, status.Code(err)) + assert.Contains(t, err.Error(), tt.contains) + }) + } +} + +func TestRequireServiceCountsScopeDenials(t *testing.T) { + denied := func() float64 { + return testutil.ToFloat64(authRequests.WithLabelValues("user", ResultScopeDenied)) + } + allowed := func() float64 { + return testutil.ToFloat64(authRequests.WithLabelValues("user", "allowed")) + } + ctx := withScope(auth.ScopeOf("svc-a")) + + before, beforeAllowed := denied(), allowed() + assert.Error(t, RequireService(ctx, "svc-b")) + assert.Equal(t, before+1, denied()) + + assert.NoError(t, RequireService(ctx, "svc-a")) + assert.Equal(t, before+1, denied()) + assert.Equal(t, beforeAllowed, allowed()) +} diff --git a/internal/stores/catalog.go b/internal/stores/catalog.go index 09c38f6..37b5737 100644 --- a/internal/stores/catalog.go +++ b/internal/stores/catalog.go @@ -4,6 +4,7 @@ import ( "context" v1alpha1 "github.com/bananaops/tracker/generated/proto/catalog/v1alpha1" + "github.com/bananaops/tracker/internal/auth" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" @@ -20,9 +21,17 @@ func NewStoreCatalog(collection string) (c *CatalogStoreClient) { } } -// List takes label and field selectors, and returns the list of Catalogs that match those selectors. -func (c *CatalogStoreClient) List(ctx context.Context) (results []*v1alpha1.Catalog, err error) { - cursor, err := c.collection.Find(context.TODO(), bson.D{}) +// NewStoreCatalogFromCollection wraps an existing collection (tests, custom wiring). +func NewStoreCatalogFromCollection(coll *mongo.Collection) *CatalogStoreClient { + return &CatalogStoreClient{collection: coll} +} + +// catalogServiceField is the document field holding the service name of a catalog entry. +const catalogServiceField = "name" + +// List returns the Catalogs within scope. +func (c *CatalogStoreClient) List(ctx context.Context, scope auth.Scope) (results []*v1alpha1.Catalog, err error) { + cursor, err := c.collection.Find(context.TODO(), scopedFilter(bson.D{}, scope, catalogServiceField)) if err != nil { return nil, err } diff --git a/internal/stores/event.go b/internal/stores/event.go index 85badab..6d69bed 100644 --- a/internal/stores/event.go +++ b/internal/stores/event.go @@ -11,6 +11,7 @@ import ( "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" + "github.com/bananaops/tracker/internal/auth" "github.com/google/uuid" ) @@ -18,15 +19,23 @@ type EventStoreClient struct { collection *mongo.Collection } +// eventServiceField is the document field holding the service of an event. +const eventServiceField = "attributes.service" + func NewStoreEvent(collection string) (c *EventStoreClient) { return &EventStoreClient{ collection: NewClient(collection), } } +// NewStoreEventFromCollection wraps an existing collection (tests, custom wiring). +func NewStoreEventFromCollection(coll *mongo.Collection) *EventStoreClient { + return &EventStoreClient{collection: coll} +} + // List takes label and field selectors, and returns the list of Events that match those selectors. -func (c *EventStoreClient) List(ctx context.Context) (results []*v1alpha1.Event, err error) { - cursor, err := c.collection.Find(context.TODO(), bson.D{}) +func (c *EventStoreClient) List(ctx context.Context, scope auth.Scope) (results []*v1alpha1.Event, err error) { + cursor, err := c.collection.Find(context.TODO(), scopedFilter(bson.D{}, scope, eventServiceField)) if err != nil { return nil, err } @@ -73,9 +82,9 @@ func (c *MongoClient) Count(ctx context.Context) (count int64, err error) { } // Search and returns the list of Events that match those selectors. -func (c *EventStoreClient) Search(ctx context.Context, filter map[string]interface{}) (results []*v1alpha1.Event, err error) { +func (c *EventStoreClient) Search(ctx context.Context, scope auth.Scope, filter map[string]interface{}) (results []*v1alpha1.Event, err error) { - cursor, err := c.collection.Find(context.TODO(), filter) + cursor, err := c.collection.Find(context.TODO(), scopedFilter(filter, scope, eventServiceField)) if err != nil { return } @@ -100,8 +109,8 @@ func (c *EventStoreClient) Delete(ctx context.Context, filter map[string]interfa } // CountWithFilter counts events matching the given filter -func (c *EventStoreClient) CountWithFilter(ctx context.Context, filter bson.D) (int64, error) { - return c.collection.CountDocuments(ctx, filter) +func (c *EventStoreClient) CountWithFilter(ctx context.Context, scope auth.Scope, filter bson.D) (int64, error) { + return c.collection.CountDocuments(ctx, scopedFilter(filter, scope, eventServiceField)) } // MonthlyStatsResult represents a single month's statistics @@ -113,7 +122,7 @@ type MonthlyStatsResult struct { } // AggregateByMonth aggregates events by month with optional service grouping -func (c *EventStoreClient) AggregateByMonth(ctx context.Context, matchFilter bson.D, groupByService bool) ([]MonthlyStatsResult, error) { +func (c *EventStoreClient) AggregateByMonth(ctx context.Context, scope auth.Scope, matchFilter bson.D, groupByService bool) ([]MonthlyStatsResult, error) { // Build the group stage groupID := bson.D{ {Key: "year", Value: bson.D{{Key: "$year", Value: bson.D{{Key: "$toDate", Value: bson.D{{Key: "$multiply", Value: bson.A{"$metadata.createdat.seconds", 1000}}}}}}}}, @@ -125,7 +134,7 @@ func (c *EventStoreClient) AggregateByMonth(ctx context.Context, matchFilter bso } pipeline := mongo.Pipeline{ - {{Key: "$match", Value: matchFilter}}, + {{Key: "$match", Value: scopedFilter(matchFilter, scope, eventServiceField)}}, {{Key: "$group", Value: bson.D{ {Key: "_id", Value: groupID}, {Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}, diff --git a/internal/stores/indexes.go b/internal/stores/indexes.go index 970f648..2e45c4c 100644 --- a/internal/stores/indexes.go +++ b/internal/stores/indexes.go @@ -6,6 +6,7 @@ import ( "log/slog" "time" + "github.com/bananaops/tracker/internal/config" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" @@ -150,6 +151,11 @@ func ensureLockIndexes(ctx context.Context, db *mongo.Database, logger *slog.Log }, Options: options.Index().SetName("idx_lock_env_resource"), }, + // Index on service for scoped lists + { + Keys: bson.D{{Key: "service", Value: 1}}, + Options: options.Index().SetName("idx_lock_service"), + }, } return createIndexes(ctx, collection, indexes, logger, "locks") @@ -178,7 +184,21 @@ func ensureCatalogIndexes(ctx context.Context, db *mongo.Database, logger *slog. }, } - return createIndexes(ctx, collection, indexes, logger, "catalogs") + if err := createIndexes(ctx, collection, indexes, logger, "catalogs"); err != nil { + return err + } + + // The catalog store reads config.ConfigDatabase.CatalogCollection, whose + // entries are keyed by name: scoped lists and lookups filter on it. + entries := db.Collection(config.ConfigDatabase.CatalogCollection) + nameIndex := []mongo.IndexModel{{ + Keys: bson.D{{Key: "name", Value: 1}}, + Options: options.Index().SetName("idx_catalog_name"), + }} + if err := createIndexes(ctx, entries, nameIndex, logger, config.ConfigDatabase.CatalogCollection); err != nil { + return err + } + return nil } func ensureLinksIndexes(ctx context.Context, db *mongo.Database, logger *slog.Logger) error { diff --git a/internal/stores/lock.go b/internal/stores/lock.go index f97ed23..41a8a73 100644 --- a/internal/stores/lock.go +++ b/internal/stores/lock.go @@ -5,6 +5,7 @@ import ( "log" v1alpha1 "github.com/bananaops/tracker/generated/proto/lock/v1alpha1" + "github.com/bananaops/tracker/internal/auth" "google.golang.org/protobuf/types/known/timestamppb" "go.mongodb.org/mongo-driver/bson" @@ -23,9 +24,17 @@ func NewStoreLock(collection string) (c *LockStoreClient) { } } -// List takes label and field selectors, and returns the list of Locks that match those selectors. -func (c *LockStoreClient) List(ctx context.Context) (results []*v1alpha1.Lock, err error) { - cursor, err := c.collection.Find(context.TODO(), bson.D{}) +// NewStoreLockFromCollection wraps an existing collection (tests, custom wiring). +func NewStoreLockFromCollection(coll *mongo.Collection) *LockStoreClient { + return &LockStoreClient{collection: coll} +} + +// lockServiceField is the document field holding the service of a lock. +const lockServiceField = "service" + +// List returns the Locks of the services in scope. +func (c *LockStoreClient) List(ctx context.Context, scope auth.Scope) (results []*v1alpha1.Lock, err error) { + cursor, err := c.collection.Find(context.TODO(), scopedFilter(bson.D{}, scope, lockServiceField)) if err != nil { return nil, err } diff --git a/internal/stores/scope.go b/internal/stores/scope.go new file mode 100644 index 0000000..cb882b2 --- /dev/null +++ b/internal/stores/scope.go @@ -0,0 +1,28 @@ +package store + +import ( + "github.com/bananaops/tracker/internal/auth" + "go.mongodb.org/mongo-driver/bson" +) + +// ServiceFilter returns the condition restricting field to the services of +// scope. It is empty for an unrestricted scope and matches nothing for an +// empty restricted one. +func ServiceFilter(scope auth.Scope, field string) bson.D { + if scope.All { + return bson.D{} + } + return bson.D{{Key: field, Value: bson.D{{Key: "$in", Value: scope.ServiceList()}}}} +} + +// scopedFilter combines filter with the service restriction of scope. +// filter is returned untouched for an unrestricted scope. +func scopedFilter(filter any, scope auth.Scope, field string) any { + if scope.All { + return filter + } + if filter == nil { + filter = bson.D{} + } + return bson.D{{Key: "$and", Value: bson.A{filter, ServiceFilter(scope, field)}}} +} diff --git a/internal/stores/scope_mongo_test.go b/internal/stores/scope_mongo_test.go new file mode 100644 index 0000000..0c901c5 --- /dev/null +++ b/internal/stores/scope_mongo_test.go @@ -0,0 +1,220 @@ +package store + +import ( + "context" + "testing" + + catalogv1 "github.com/bananaops/tracker/generated/proto/catalog/v1alpha1" + eventv1 "github.com/bananaops/tracker/generated/proto/event/v1alpha1" + lockv1 "github.com/bananaops/tracker/generated/proto/lock/v1alpha1" + "github.com/bananaops/tracker/internal/auth" + "github.com/bananaops/tracker/internal/config" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +func insertEvent(t *testing.T, s *EventStoreClient, service, source string) string { + t.Helper() + e, err := s.Create(context.Background(), &eventv1.Event{ + Title: service, + Attributes: &eventv1.EventAttributes{Service: service, Source: source}, + Links: &eventv1.EventLinks{}, + Metadata: &eventv1.EventMetadata{}, + }) + require.NoError(t, err) + return e.Metadata.Id +} + +func seedScopeEvents(t *testing.T) *EventStoreClient { + t.Helper() + db := testDatabase(t) + s := NewStoreEventFromCollection(db.Collection("events")) + for _, svc := range []string{"svc-a", "svc-a", "svc-b", ""} { + insertEvent(t, s, svc, "scope-test") + } + return s +} + +func TestEventStoreListScoped(t *testing.T) { + s := seedScopeEvents(t) + ctx := context.Background() + + got, err := s.List(ctx, auth.ScopeAll()) + require.NoError(t, err) + require.Len(t, got, 4) + + got, err = s.List(ctx, auth.ScopeOf("svc-a")) + require.NoError(t, err) + require.Len(t, got, 2) + for _, e := range got { + require.Equal(t, "svc-a", e.Attributes.Service) + } + + got, err = s.List(ctx, auth.ScopeOf("svc-a", "svc-b")) + require.NoError(t, err) + require.Len(t, got, 3) + + got, err = s.List(ctx, auth.ScopeOf()) + require.NoError(t, err) + require.Empty(t, got) + + got, err = s.List(ctx, auth.Scope{}) + require.NoError(t, err) + require.Empty(t, got) + + got, err = s.List(ctx, auth.ScopeOf("SVC-A")) + require.NoError(t, err) + require.Empty(t, got) +} + +func TestEventStoreSearchScoped(t *testing.T) { + s := seedScopeEvents(t) + ctx := context.Background() + src := map[string]interface{}{"attributes.source": "scope-test"} + + got, err := s.Search(ctx, auth.ScopeAll(), src) + require.NoError(t, err) + require.Len(t, got, 4) + + got, err = s.Search(ctx, auth.ScopeOf("svc-a"), src) + require.NoError(t, err) + require.Len(t, got, 2) + + both := map[string]interface{}{"attributes.source": "scope-test", "attributes.service": "svc-b"} + got, err = s.Search(ctx, auth.ScopeOf("svc-a"), both) + require.NoError(t, err) + require.Empty(t, got) + + got, err = s.Search(ctx, auth.ScopeOf("svc-b"), both) + require.NoError(t, err) + require.Len(t, got, 1) + + got, err = s.Search(ctx, auth.ScopeOf("svc-a"), map[string]interface{}{"attributes.service": ""}) + require.NoError(t, err) + require.Empty(t, got) +} + +func TestEventStoreCountScoped(t *testing.T) { + s := seedScopeEvents(t) + ctx := context.Background() + f := bson.D{{Key: "attributes.source", Value: "scope-test"}} + + for scope, want := range map[string]int64{"all": 4, "a": 2, "none": 0} { + sc := map[string]auth.Scope{"all": auth.ScopeAll(), "a": auth.ScopeOf("svc-a"), "none": auth.ScopeOf()}[scope] + n, err := s.CountWithFilter(ctx, sc, f) + require.NoError(t, err, scope) + require.Equal(t, want, n, scope) + } +} + +func sumCounts(rows []MonthlyStatsResult) int64 { + var n int64 + for _, r := range rows { + n += r.Count + } + return n +} + +func TestEventStoreAggregateScoped(t *testing.T) { + s := seedScopeEvents(t) + ctx := context.Background() + f := bson.D{{Key: "attributes.source", Value: "scope-test"}} + + rows, err := s.AggregateByMonth(ctx, auth.ScopeAll(), f, true) + require.NoError(t, err) + require.Equal(t, int64(4), sumCounts(rows)) + + rows, err = s.AggregateByMonth(ctx, auth.ScopeOf("svc-a"), f, true) + require.NoError(t, err) + require.Len(t, rows, 1) + require.Equal(t, "svc-a", rows[0].Service) + require.Equal(t, int64(2), rows[0].Count) + + rows, err = s.AggregateByMonth(ctx, auth.ScopeOf(), f, true) + require.NoError(t, err) + require.Empty(t, rows) + + rows, err = s.AggregateByMonth(ctx, auth.ScopeOf("svc-b"), f, false) + require.NoError(t, err) + require.Equal(t, int64(1), sumCounts(rows)) +} + +func TestLockStoreListScoped(t *testing.T) { + db := testDatabase(t) + s := NewStoreLockFromCollection(db.Collection("locks")) + ctx := context.Background() + for _, svc := range []string{"svc-a", "svc-b", ""} { + _, err := s.Create(ctx, &lockv1.Lock{Service: svc, Environment: "production", Resource: "deployment", Who: "seed"}) + require.NoError(t, err) + } + + got, err := s.List(ctx, auth.ScopeAll()) + require.NoError(t, err) + require.Len(t, got, 3) + + got, err = s.List(ctx, auth.ScopeOf("svc-a")) + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, "svc-a", got[0].Service) + + got, err = s.List(ctx, auth.ScopeOf()) + require.NoError(t, err) + require.Empty(t, got) +} + +func TestScopeIndexes(t *testing.T) { + db := testDatabase(t) + ctx := context.Background() + cursor, err := db.Collection("locks").Indexes().List(ctx) + require.NoError(t, err) + var specs []bson.M + require.NoError(t, cursor.All(ctx, &specs)) + names := []string{} + for _, sp := range specs { + name := sp["name"].(string) + names = append(names, name) + if name == "idx_lock_service" { + require.NotContains(t, sp, "unique") + } + } + require.Contains(t, names, "idx_lock_service") + require.Contains(t, collectionIndexNames(t, db, config.ConfigDatabase.CatalogCollection), "idx_catalog_name") +} + +func collectionIndexNames(t *testing.T, db *mongo.Database, coll string) []string { + t.Helper() + ctx := context.Background() + cursor, err := db.Collection(coll).Indexes().List(ctx) + require.NoError(t, err) + var specs []bson.M + require.NoError(t, cursor.All(ctx, &specs)) + names := []string{} + for _, sp := range specs { + names = append(names, sp["name"].(string)) + } + return names +} + +func TestCatalogStoreListScoped(t *testing.T) { + db := testDatabase(t) + s := NewStoreCatalogFromCollection(db.Collection(config.ConfigDatabase.CatalogCollection)) + ctx := context.Background() + for _, n := range []string{"svc-a", "svc-b"} { + _, err := s.Update(ctx, map[string]interface{}{"name": n}, &catalogv1.Catalog{Name: n, Owner: "o", Version: "1"}) + require.NoError(t, err) + } + + got, err := s.List(ctx, auth.ScopeAll()) + require.NoError(t, err) + require.Len(t, got, 2) + + got, err = s.List(ctx, auth.ScopeOf("svc-a")) + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, "svc-a", got[0].Name) + + got, err = s.List(ctx, auth.ScopeOf()) + require.NoError(t, err) + require.Empty(t, got) +} diff --git a/internal/stores/scope_test.go b/internal/stores/scope_test.go new file mode 100644 index 0000000..afe07f1 --- /dev/null +++ b/internal/stores/scope_test.go @@ -0,0 +1,24 @@ +package store + +import ( + "testing" + + "github.com/bananaops/tracker/internal/auth" + "github.com/stretchr/testify/assert" + "go.mongodb.org/mongo-driver/bson" +) + +func TestServiceFilter(t *testing.T) { + all := ServiceFilter(auth.ScopeAll(), "service") + assert.NotNil(t, all) + assert.Len(t, all, 0) + + assert.Equal(t, + bson.D{{Key: "attributes.service", Value: bson.D{{Key: "$in", Value: []string{"a", "b"}}}}}, + ServiceFilter(auth.ScopeOf("b", "a"), "attributes.service")) + + // An empty scope must produce a non-nil empty slice: "$in: null" is rejected by MongoDB. + want := bson.D{{Key: "name", Value: bson.D{{Key: "$in", Value: []string{}}}}} + assert.Equal(t, want, ServiceFilter(auth.ScopeOf(), "name")) + assert.Equal(t, want, ServiceFilter(auth.Scope{}, "name")) +} diff --git a/server/auth_teams.go b/server/auth_teams.go index 2ce137e..70a7f73 100644 --- a/server/auth_teams.go +++ b/server/auth_teams.go @@ -4,6 +4,7 @@ import ( "context" "sort" "strings" + "unicode/utf8" authv1 "github.com/bananaops/tracker/generated/proto/auth/v1alpha1" "github.com/bananaops/tracker/internal/auth" @@ -15,6 +16,11 @@ import ( const teamNameMaxLength = 64 +const ( + teamScopeMaxServices = 500 + teamScopeServiceMaxLength = 128 +) + func (a *Auth) ListTeams(ctx context.Context, _ *authv1.ListTeamsRequest) (*authv1.ListTeamsResponse, error) { if err := authz.Authorize(ctx); err != nil { return nil, err @@ -51,10 +57,9 @@ func teamFromRequest(name, description string, perms []string, scopeAll bool, se } sort.Strings(cleanPerms) - cleanServices := dedupeTrimmed(services) - scope := store.TeamScope{All: scopeAll || len(cleanServices) == 0, Services: cleanServices} - if scope.All { - scope.Services = []string{} + scope, err := teamScopeFromRequest(scopeAll, services) + if err != nil { + return nil, err } return &store.Team{ Name: name, @@ -65,6 +70,31 @@ func teamFromRequest(name, description string, perms []string, scopeAll bool, se }, nil } +// teamScopeFromRequest validates the scope of a team. No service means every +// service, which keeps the default of a team created without a scope. A +// request that names services must name at least one usable service and +// must not also ask for every service: silently widening a restriction is +// the dangerous direction. Names are kept as given, case included, and are +// not checked against the catalog since a service may be created later. +func teamScopeFromRequest(scopeAll bool, services []string) (store.TeamScope, error) { + clean := dedupeTrimmed(services) + if len(services) > 0 && len(clean) == 0 { + return store.TeamScope{}, status.Error(codes.InvalidArgument, "scope services must not be blank") + } + if scopeAll && len(clean) > 0 { + return store.TeamScope{}, status.Error(codes.InvalidArgument, "scope_all and scope_services are mutually exclusive") + } + if len(clean) > teamScopeMaxServices { + return store.TeamScope{}, status.Errorf(codes.InvalidArgument, "a team scope holds at most %d services", teamScopeMaxServices) + } + for _, s := range clean { + if utf8.RuneCountInString(s) > teamScopeServiceMaxLength { + return store.TeamScope{}, status.Errorf(codes.InvalidArgument, "a scope service name is at most %d characters", teamScopeServiceMaxLength) + } + } + return store.TeamScope{All: len(clean) == 0, Services: clean}, nil +} + func dedupeTrimmed(in []string) []string { out := make([]string, 0, len(in)) seen := map[string]struct{}{} diff --git a/server/auth_teams_scope_test.go b/server/auth_teams_scope_test.go new file mode 100644 index 0000000..8af7531 --- /dev/null +++ b/server/auth_teams_scope_test.go @@ -0,0 +1,226 @@ +package server + +import ( + "context" + "fmt" + "strings" + "testing" + + authv1 "github.com/bananaops/tracker/generated/proto/auth/v1alpha1" + "github.com/bananaops/tracker/internal/auth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/bson/primitive" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func distinctServices(n int) []string { + out := make([]string, n) + for i := range out { + out[i] = fmt.Sprintf("svc-%d", i) + } + return out +} + +func TestTeamScopeValidation(t *testing.T) { + f := newAuthFixture(t) + svc := newAuthService(f) + admin := f.principalOf(t, f.admin) + + cases := []struct { + name string + scopeAll bool + services []string + wantCode codes.Code + wantAll bool + wantServices []string + }{ + {name: "default", wantAll: true, wantServices: []string{}}, + {name: "explicit-all", scopeAll: true, wantAll: true, wantServices: []string{}}, + {name: "clean-and-dedupe", services: []string{" svc-b ", "svc-a", "svc-a", ""}, wantServices: []string{"svc-a", "svc-b"}}, + {name: "all-with-services", scopeAll: true, services: []string{"svc-a"}, wantCode: codes.InvalidArgument}, + {name: "blank-only", services: []string{" ", ""}, wantCode: codes.InvalidArgument}, + {name: "too-many", services: distinctServices(501), wantCode: codes.InvalidArgument}, + {name: "max-services", services: distinctServices(500), wantServices: nil}, + {name: "name-too-long", services: []string{strings.Repeat("x", 129)}, wantCode: codes.InvalidArgument}, + {name: "name-max-length", services: []string{strings.Repeat("x", 128)}, wantServices: []string{strings.Repeat("x", 128)}}, + {name: "multibyte-max-length", services: []string{strings.Repeat("\u00e9", 128)}, wantServices: []string{strings.Repeat("\u00e9", 128)}}, + {name: "multibyte-too-long", services: []string{strings.Repeat("\u00e9", 129)}, wantCode: codes.InvalidArgument}, + {name: "case-sensitive", services: []string{"Svc-A", "svc-a"}, wantServices: []string{"Svc-A", "svc-a"}}, + {name: "unknown-service", services: []string{"does-not-exist-in-catalog"}, wantServices: []string{"does-not-exist-in-catalog"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp, err := svc.CreateTeam(rpcCtx(admin, "CreateTeam"), &authv1.CreateTeamRequest{ + Name: "team-" + tc.name, ScopeAll: tc.scopeAll, ScopeServices: tc.services, + }) + if tc.wantCode != codes.OK { + assert.Equal(t, tc.wantCode, status.Code(err)) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantAll, resp.Team.ScopeAll) + if tc.name == "max-services" { + assert.Len(t, resp.Team.ScopeServices, 500) + return + } + assert.ElementsMatch(t, tc.wantServices, resp.Team.ScopeServices) + }) + } +} + +func TestUpdateTeamScope(t *testing.T) { + f := newAuthFixture(t) + svc := newAuthService(f) + admin := f.principalOf(t, f.admin) + + created, err := svc.CreateTeam(rpcCtx(admin, "CreateTeam"), &authv1.CreateTeamRequest{Name: "Ops"}) + require.NoError(t, err) + require.True(t, created.Team.ScopeAll) + + updated, err := svc.UpdateTeam(rpcCtx(admin, "UpdateTeam"), &authv1.UpdateTeamRequest{ + Id: created.Team.Id, Name: "Ops", ScopeServices: []string{"svc-a"}, + }) + require.NoError(t, err) + assert.False(t, updated.Team.ScopeAll) + + updated, err = svc.UpdateTeam(rpcCtx(admin, "UpdateTeam"), &authv1.UpdateTeamRequest{ + Id: created.Team.Id, Name: "Ops", ScopeAll: true, + }) + require.NoError(t, err) + assert.True(t, updated.Team.ScopeAll) + + _, err = svc.UpdateTeam(rpcCtx(admin, "UpdateTeam"), &authv1.UpdateTeamRequest{ + Id: created.Team.Id, Name: "Ops", ScopeServices: []string{"svc-a"}, + }) + require.NoError(t, err) + _, err = svc.UpdateTeam(rpcCtx(admin, "UpdateTeam"), &authv1.UpdateTeamRequest{ + Id: created.Team.Id, Name: "Ops", ScopeAll: true, ScopeServices: []string{"svc-b"}, + }) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + + oid, err := primitive.ObjectIDFromHex(created.Team.Id) + require.NoError(t, err) + stored, err := f.teams.GetByID(context.Background(), oid) + require.NoError(t, err) + assert.False(t, stored.Scope.All) + assert.Equal(t, []string{"svc-a"}, stored.Scope.Services) +} + +func TestBuiltinTeamScopeIsImmutable(t *testing.T) { + f := newAuthFixture(t) + svc := newAuthService(f) + admin := f.principalOf(t, f.admin) + + for _, req := range []*authv1.UpdateTeamRequest{ + {Id: f.adminsID, Name: "Administrators", ScopeAll: false, ScopeServices: []string{"svc-a"}}, + {Id: f.adminsID, Name: "Administrators", ScopeAll: true, ScopeServices: []string{"svc-a"}}, + } { + resp, err := svc.UpdateTeam(rpcCtx(admin, "UpdateTeam"), req) + require.NoError(t, err) + assert.True(t, resp.Team.ScopeAll) + assert.Empty(t, resp.Team.ScopeServices) + + oid, err := primitive.ObjectIDFromHex(f.adminsID) + require.NoError(t, err) + stored, err := f.teams.GetByID(context.Background(), oid) + require.NoError(t, err) + assert.True(t, stored.Scope.All) + } +} + +func TestTeamScopeRequiresAccessManage(t *testing.T) { + f := newAuthFixture(t) + svc := newAuthService(f) + p := auth.Principal{ + Kind: auth.KindUser, UserID: f.admin.ID.Hex(), + Permissions: auth.NewPermissionSet(auth.PermEventWrite), Scope: auth.ScopeAll(), + } + + _, err := svc.CreateTeam(rpcCtx(p, "CreateTeam"), &authv1.CreateTeamRequest{Name: "X", ScopeServices: []string{"svc-a"}}) + assert.Equal(t, codes.PermissionDenied, status.Code(err)) + _, err = svc.UpdateTeam(rpcCtx(p, "UpdateTeam"), &authv1.UpdateTeamRequest{Id: f.adminsID, Name: "X"}) + assert.Equal(t, codes.PermissionDenied, status.Code(err)) +} + +func TestMeReturnsEffectiveScope(t *testing.T) { + f := newAuthFixture(t) + svc := newAuthService(f) + admin := f.principalOf(t, f.admin) + + mkTeam := func(name string, services ...string) string { + r, err := svc.CreateTeam(rpcCtx(admin, "CreateTeam"), &authv1.CreateTeamRequest{Name: name, ScopeServices: services}) + require.NoError(t, err) + return r.Team.Id + } + a := mkTeam("A", "svc-a") + b := mkTeam("B", "svc-c", "svc-b") + everyone := mkTeam("Everyone") + + created, err := svc.CreateUser(rpcCtx(admin, "CreateUser"), &authv1.CreateUserRequest{ + Username: "alice", Password: "alice-initial-pass-1", TeamIds: []string{a, b}, + }) + require.NoError(t, err) + alice, err := f.users.GetByUsername(context.Background(), "alice") + require.NoError(t, err) + + me, err := svc.Me(rpcCtx(f.principalOf(t, alice), "Me"), &authv1.MeRequest{}) + require.NoError(t, err) + assert.False(t, me.ScopeAll) + assert.Equal(t, []string{"svc-a", "svc-b", "svc-c"}, me.ScopeServices) + + _, err = svc.UpdateUser(rpcCtx(admin, "UpdateUser"), &authv1.UpdateUserRequest{ + Id: created.User.Id, Email: created.User.Email, DisplayName: created.User.DisplayName, + TeamIds: []string{a, b, everyone}, + }) + require.NoError(t, err) + alice, err = f.users.GetByUsername(context.Background(), "alice") + require.NoError(t, err) + me, err = svc.Me(rpcCtx(f.principalOf(t, alice), "Me"), &authv1.MeRequest{}) + require.NoError(t, err) + assert.True(t, me.ScopeAll) + assert.Empty(t, me.ScopeServices) + + _, err = svc.CreateUser(rpcCtx(admin, "CreateUser"), &authv1.CreateUserRequest{ + Username: "nobody", Password: "nobody-initial-pass-1", + }) + require.NoError(t, err) + nobody, err := f.users.GetByUsername(context.Background(), "nobody") + require.NoError(t, err) + me, err = svc.Me(rpcCtx(f.principalOf(t, nobody), "Me"), &authv1.MeRequest{}) + require.NoError(t, err) + assert.False(t, me.ScopeAll) + assert.Empty(t, me.ScopeServices) +} + +func TestTeamKeyFollowsTeamScope(t *testing.T) { + f := newAuthFixture(t) + svc := newAuthService(f) + admin := f.principalOf(t, f.admin) + bg := context.Background() + + team, err := svc.CreateTeam(rpcCtx(admin, "CreateTeam"), &authv1.CreateTeamRequest{ + Name: "A", Permissions: []string{"lock:write"}, ScopeServices: []string{"svc-a"}, + }) + require.NoError(t, err) + key, err := svc.CreateApiKey(rpcCtx(admin, "CreateApiKey"), &authv1.CreateApiKeyRequest{Name: "k", TeamId: team.Team.Id}) + require.NoError(t, err) + + p := f.resolver.Resolve(bg, auth.Credentials{APIKey: key.Secret}) + assert.True(t, p.Scope.Allows("svc-a")) + assert.False(t, p.Scope.Allows("svc-b")) + + _, err = svc.UpdateTeam(rpcCtx(admin, "UpdateTeam"), &authv1.UpdateTeamRequest{ + Id: team.Team.Id, Name: "A", Permissions: []string{"lock:write"}, ScopeServices: []string{"svc-b"}, + }) + require.NoError(t, err) + p = f.resolver.Resolve(bg, auth.Credentials{APIKey: key.Secret}) + assert.True(t, p.Scope.Allows("svc-b")) + assert.False(t, p.Scope.Allows("svc-a")) + + global, err := svc.CreateApiKey(rpcCtx(admin, "CreateApiKey"), &authv1.CreateApiKeyRequest{Name: "g"}) + require.NoError(t, err) + p = f.resolver.Resolve(bg, auth.Credentials{APIKey: global.Secret}) + assert.True(t, p.Scope.All) +} diff --git a/server/catalog.go b/server/catalog.go index 0ba1e35..e42888d 100644 --- a/server/catalog.go +++ b/server/catalog.go @@ -46,6 +46,10 @@ func (e *Catalog) CreateUpdateCatalog( return nil, fmt.Errorf("version is required") } + if err := authz.RequireService(ctx, i.Name); err != nil { + return nil, err + } + // Get existing catalog to preserve version fields if they exist existingCatalog, _ := e.store.Get(ctx, map[string]interface{}{"name": i.Name}) @@ -125,6 +129,10 @@ func (e *Catalog) GetCatalog( return nil, err } + if err := authz.RequireService(ctx, i.Name); err != nil { + return nil, err + } + var catalogResult = &v1alpha1.GetCatalogResponse{} var err error @@ -146,7 +154,7 @@ func (e *Catalog) ListCatalogs( var catalogsResult = &v1alpha1.ListCatalogsResponse{} var err error - catalogsResult.Catalogs, err = e.store.List(context.Background()) + catalogsResult.Catalogs, err = e.store.List(context.Background(), authz.ScopeFromContext(ctx)) if err != nil { return nil, err } @@ -163,6 +171,10 @@ func (e *Catalog) DeleteCatalog( return nil, err } + if err := authz.RequireService(ctx, i.Name); err != nil { + return nil, err + } + var catalogResult = &v1alpha1.DeleteCatalogResponse{} err := e.store.Delete(context.Background(), map[string]interface{}{"name": i.Name}) @@ -184,8 +196,10 @@ func (e *Catalog) GetVersionCompliance( var response = &v1alpha1.GetVersionComplianceResponse{} var projectCompliances []*v1alpha1.ProjectCompliance - // Get all catalogs - catalogs, err := e.store.List(context.Background()) + // The list is restricted to the caller's scope: only in-scope projects + // are reported, and a deliverable outside the scope is treated as absent + // from the catalog, so its versions never leak. + catalogs, err := e.store.List(context.Background(), authz.ScopeFromContext(ctx)) if err != nil { return nil, fmt.Errorf("failed to list catalogs: %w", err) } @@ -324,6 +338,10 @@ func (e *Catalog) UpdateVersions( return nil, err } + if err := authz.RequireService(ctx, i.Name); err != nil { + return nil, err + } + e.logger.Info("🔧 Updating versions for service", "name", i.Name, "available_versions", i.AvailableVersions, @@ -376,6 +394,10 @@ func (e *Catalog) UpdateDependencies( return nil, fmt.Errorf("name is required") } + if err := authz.RequireService(ctx, i.Name); err != nil { + return nil, err + } + // Get existing catalog existingCatalog, err := e.store.Get(ctx, map[string]interface{}{"name": i.Name}) if err != nil { diff --git a/server/catalog_scope_test.go b/server/catalog_scope_test.go new file mode 100644 index 0000000..bac4cff --- /dev/null +++ b/server/catalog_scope_test.go @@ -0,0 +1,229 @@ +package server + +import ( + "context" + "testing" + + catalogv1 "github.com/bananaops/tracker/generated/proto/catalog/v1alpha1" + "github.com/bananaops/tracker/internal/auth" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/mongo" + "google.golang.org/protobuf/encoding/protojson" +) + +const catalogSvc = "/tracker.catalog.v1alpha1.CatalogService/" + +type catalogScopeEnv struct { + s *scopeServices + pa, ponly, none, all auth.Principal +} + +func newCatalogScopeEnv(t *testing.T) *catalogScopeEnv { + t.Helper() + s := newScopeServices(t, scopeDB(t)) + seedCatalog(t, s, &catalogv1.Catalog{ + Name: "proj-a", Type: catalogv1.Type_project, + DependenciesOut: []string{"svc-b"}, DependenciesIn: []string{"svc-c"}, + UsedDeliverables: []*catalogv1.UsedDeliverable{ + {Name: "lib-a", VersionUsed: "1.0.0"}, {Name: "lib-b", VersionUsed: "0.9.0"}, + }, + }) + seedCatalog(t, s, &catalogv1.Catalog{ + Name: "proj-b", Type: catalogv1.Type_project, + UsedDeliverables: []*catalogv1.UsedDeliverable{{Name: "lib-b", VersionUsed: "0.9.0"}}, + }) + seedCatalog(t, s, &catalogv1.Catalog{Name: "lib-a", Type: catalogv1.Type_package, ReferenceVersion: "2.0.0", LatestVersion: "2.1.0"}) + seedCatalog(t, s, &catalogv1.Catalog{Name: "lib-b", Type: catalogv1.Type_package, ReferenceVersion: "7.7.7", LatestVersion: "8.8.8"}) + return &catalogScopeEnv{ + s: s, + pa: scopedPrincipal("proj-a", "lib-a"), + ponly: scopedPrincipal("proj-a"), + none: scopedPrincipal(), + all: allScopePrincipal(), + } +} + +func (x *catalogScopeEnv) ctx(p auth.Principal, method string) context.Context { + return scopeCtx(p, catalogSvc+method) +} + +func (x *catalogScopeEnv) stored(t *testing.T, name string) *catalogv1.Catalog { + t.Helper() + c, err := x.s.catalogs.store.Get(context.Background(), map[string]interface{}{"name": name}) + require.NoError(t, err) + return c +} + +func (x *catalogScopeEnv) exists(t *testing.T, name string) bool { + t.Helper() + _, err := x.s.catalogs.store.Get(context.Background(), map[string]interface{}{"name": name}) + if err == mongo.ErrNoDocuments { + return false + } + require.NoError(t, err) + return true +} + +func TestListCatalogsScope(t *testing.T) { + x := newCatalogScopeEnv(t) + list := func(p auth.Principal) *catalogv1.ListCatalogsResponse { + r, err := x.s.catalogs.ListCatalogs(x.ctx(p, "ListCatalogs"), &catalogv1.ListCatalogsRequest{}) + require.NoError(t, err) + return r + } + r := list(x.pa) + names := []string{} + for _, c := range r.Catalogs { + names = append(names, c.Name) + } + require.ElementsMatch(t, []string{"proj-a", "lib-a"}, names) + require.EqualValues(t, 2, r.TotalCount) + require.Empty(t, list(x.none).Catalogs) + require.Len(t, list(x.all).Catalogs, 4) +} + +func TestGetCatalogScope(t *testing.T) { + x := newCatalogScopeEnv(t) + get := func(p auth.Principal, name string) (*catalogv1.GetCatalogResponse, error) { + return x.s.catalogs.GetCatalog(x.ctx(p, "GetCatalog"), &catalogv1.GetCatalogRequest{Name: name}) + } + r, err := get(x.pa, "proj-a") + require.NoError(t, err) + require.Equal(t, []string{"svc-b"}, r.Catalog.DependenciesOut) + require.Equal(t, []string{"svc-c"}, r.Catalog.DependenciesIn) + require.Len(t, r.Catalog.UsedDeliverables, 2) + + _, err = get(x.pa, "proj-b") + requireDenied(t, err) + _, err = get(x.pa, "lib-b") + requireDenied(t, err) + _, err = get(x.pa, "ghost") + requireDenied(t, err) + _, err = get(x.none, "proj-a") + requireDenied(t, err) + _, err = get(x.all, "lib-b") + require.NoError(t, err) +} + +func TestCreateUpdateCatalogScope(t *testing.T) { + x := newCatalogScopeEnv(t) + put := func(p auth.Principal, name string) error { + _, err := x.s.catalogs.CreateUpdateCatalog(x.ctx(p, "CreateUpdateCatalog"), + &catalogv1.CreateUpdateCatalogRequest{Name: name, Owner: "o", Version: "1"}) + return err + } + require.NoError(t, put(x.pa, "proj-a")) + requireDenied(t, put(x.pa, "new-svc")) + require.False(t, x.exists(t, "new-svc")) + requireDenied(t, put(x.pa, "proj-b")) + require.Len(t, x.stored(t, "proj-b").UsedDeliverables, 1) + requireDenied(t, put(x.none, "proj-a")) + require.NoError(t, put(x.all, "new-svc")) + require.True(t, x.exists(t, "new-svc")) + + err := put(x.pa, "") + require.Error(t, err) + require.Contains(t, err.Error(), "name is required") +} + +func TestDeleteCatalogScope(t *testing.T) { + x := newCatalogScopeEnv(t) + del := func(p auth.Principal, name string) error { + _, err := x.s.catalogs.DeleteCatalog(x.ctx(p, "DeleteCatalog"), &catalogv1.DeleteCatalogRequest{Name: name}) + return err + } + requireDenied(t, del(x.pa, "proj-b")) + require.True(t, x.exists(t, "proj-b")) + require.NoError(t, del(x.pa, "lib-a")) + require.False(t, x.exists(t, "lib-a")) + requireDenied(t, del(x.none, "proj-a")) + require.True(t, x.exists(t, "proj-a")) + require.NoError(t, del(x.all, "proj-b")) + require.False(t, x.exists(t, "proj-b")) +} + +func TestUpdateVersionsScope(t *testing.T) { + x := newCatalogScopeEnv(t) + upd := func(p auth.Principal, name string) error { + _, err := x.s.catalogs.UpdateVersions(x.ctx(p, "UpdateVersions"), + &catalogv1.UpdateVersionsRequest{Name: name, ReferenceVersion: "3.0.0"}) + return err + } + require.NoError(t, upd(x.pa, "lib-a")) + require.Equal(t, "3.0.0", x.stored(t, "lib-a").ReferenceVersion) + requireDenied(t, upd(x.pa, "lib-b")) + require.Equal(t, "7.7.7", x.stored(t, "lib-b").ReferenceVersion) + requireDenied(t, upd(x.none, "lib-a")) +} + +func TestUpdateDependenciesScope(t *testing.T) { + x := newCatalogScopeEnv(t) + upd := func(p auth.Principal, name string) error { + _, err := x.s.catalogs.UpdateDependencies(x.ctx(p, "UpdateDependencies"), + &catalogv1.UpdateDependenciesRequest{Name: name, DependenciesOut: []string{"svc-z"}}) + return err + } + require.NoError(t, upd(x.pa, "proj-a")) + require.Equal(t, []string{"svc-z"}, x.stored(t, "proj-a").DependenciesOut) + require.Empty(t, x.stored(t, "proj-b").DependenciesOut) + requireDenied(t, upd(x.pa, "proj-b")) + require.Empty(t, x.stored(t, "proj-b").DependenciesOut) + requireDenied(t, upd(x.none, "proj-a")) +} + +func TestGetVersionComplianceScope(t *testing.T) { + x := newCatalogScopeEnv(t) + comp := func(p auth.Principal) *catalogv1.GetVersionComplianceResponse { + r, err := x.s.catalogs.GetVersionCompliance(x.ctx(p, "GetVersionCompliance"), &catalogv1.GetVersionComplianceRequest{}) + require.NoError(t, err) + return r + } + dump := func(r *catalogv1.GetVersionComplianceResponse) string { + b, err := protojson.Marshal(r) + require.NoError(t, err) + return string(b) + } + + r := comp(x.pa) + require.Len(t, r.Projects, 1) + require.Equal(t, "proj-a", r.Projects[0].ProjectName) + require.Len(t, r.Projects[0].Deliverables, 1) + d := r.Projects[0].Deliverables[0] + require.Equal(t, "lib-a", d.Name) + require.Equal(t, "1.0.0", d.CurrentVersion) + require.Equal(t, "2.0.0", d.ReferenceVersion) + require.True(t, d.IsOutdated) + require.EqualValues(t, 1, r.Projects[0].TotalCount) + require.EqualValues(t, 1, r.Projects[0].OutdatedCount) + require.EqualValues(t, 1, r.Summary.TotalProjects) + require.Len(t, r.Summary.DeliverableStats, 1) + require.Equal(t, "lib-a", r.Summary.DeliverableStats[0].Name) + require.EqualValues(t, 1, r.Summary.DeliverableStats[0].ProjectsUsing) + out := dump(r) + for _, leak := range []string{"lib-b", "proj-b", "7.7.7", "8.8.8"} { + require.NotContains(t, out, leak) + } + + r = comp(x.ponly) + require.Len(t, r.Projects, 1) + require.Equal(t, "proj-a", r.Projects[0].ProjectName) + require.Empty(t, r.Projects[0].Deliverables) + require.EqualValues(t, 0, r.Projects[0].TotalCount) + out = dump(r) + require.NotContains(t, out, "2.0.0") + require.NotContains(t, out, "7.7.7") + + r = comp(x.none) + require.Empty(t, r.Projects) + require.EqualValues(t, 0, r.Summary.TotalProjects) + + r = comp(x.all) + require.Len(t, r.Projects, 2) + var using int32 = -1 + for _, s := range r.Summary.DeliverableStats { + if s.Name == "lib-b" { + using = s.ProjectsUsing + } + } + require.EqualValues(t, 2, using) +} diff --git a/server/event.go b/server/event.go index b6b5709..56f5e4b 100644 --- a/server/event.go +++ b/server/event.go @@ -2,6 +2,7 @@ package server import ( "context" + "errors" "fmt" "log/slog" "os" @@ -15,6 +16,7 @@ import ( store "github.com/bananaops/tracker/internal/stores" "github.com/bananaops/tracker/internal/utils" "github.com/prometheus/client_golang/prometheus" + "go.mongodb.org/mongo-driver/mongo" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -107,6 +109,10 @@ func (e *Event) CreateEvent( return nil, err } + if err := authz.RequireService(ctx, i.GetAttributes().GetService()); err != nil { + return nil, err + } + var event = &v1alpha1.Event{ Title: i.Title, Attributes: &v1alpha1.EventAttributes{ @@ -289,6 +295,10 @@ func (e *Event) GetEvent( } } + if err := authz.RequireService(ctx, eventResult.Event.GetAttributes().GetService()); err != nil { + return nil, err + } + return eventResult, nil } @@ -306,7 +316,7 @@ func (e *Event) SearchEvents( } var eventsResult = &v1alpha1.SearchEventsResponse{} - eventsResult.Events, err = e.store.Search(context.Background(), filter) + eventsResult.Events, err = e.store.Search(context.Background(), authz.ScopeFromContext(ctx), filter) if err != nil { return nil, err } @@ -326,7 +336,7 @@ func (e *Event) ListEvents( var eventsResult = &v1alpha1.ListEventsResponse{} var err error - eventsResult.Events, err = e.store.List(context.Background()) + eventsResult.Events, err = e.store.List(context.Background(), authz.ScopeFromContext(ctx)) if err != nil { return nil, err } @@ -356,7 +366,7 @@ func (e *Event) TodayEvents( } var eventsResult = &v1alpha1.TodayEventsResponse{} - eventsResult.Events, err = e.store.Search(context.Background(), filter) + eventsResult.Events, err = e.store.Search(context.Background(), authz.ScopeFromContext(ctx), filter) if err != nil { return nil, err } @@ -394,6 +404,12 @@ func (e *Event) UpdateEvent( } } + // Both the stored and the new service must be in scope, so an event + // cannot be moved into or out of the caller's scope. + if err := authz.RequireService(ctx, eventDatabase.Event.GetAttributes().GetService(), i.GetAttributes().GetService()); err != nil { + return nil, err + } + var event = &v1alpha1.Event{ Title: i.Title, Attributes: &v1alpha1.EventAttributes{ @@ -572,6 +588,21 @@ func (e *Event) DeleteEvents( return nil, err } + // A restricted caller may only delete an event of its scope. An unknown + // id keeps today's answer: nothing is deleted and no error is returned. + if scope := authz.ScopeFromContext(ctx); !scope.All { + existing, err := e.store.Get(ctx, map[string]interface{}{"metadata.id": i.Id}) + switch { + case err == nil: + if err := authz.RequireService(ctx, existing.GetAttributes().GetService()); err != nil { + return nil, err + } + case errors.Is(err, mongo.ErrNoDocuments): + default: + return nil, fmt.Errorf("lookup event %s: %w", i.Id, err) + } + } + var eventResult = &v1alpha1.DeleteEventResponse{} err := e.store.Delete(context.Background(), map[string]interface{}{"metadata.id": i.Id}) @@ -598,6 +629,9 @@ func (e *Event) AddChangelogEntry( } return nil, err } + if err := authz.RequireService(ctx, eventDatabase.GetAttributes().GetService()); err != nil { + return nil, err + } // Validate the changelog entry if i.Entry == nil { @@ -648,6 +682,9 @@ func (e *Event) GetEventChangelog( } return nil, err } + if err := authz.RequireService(ctx, eventDatabase.GetAttributes().GetService()); err != nil { + return nil, err + } // Get pagination parameters with defaults perPage := uint32(50) // default @@ -711,6 +748,9 @@ func (e *Event) AddSlackId( } return nil, err } + if err := authz.RequireService(ctx, eventDatabase.GetAttributes().GetService()); err != nil { + return nil, err + } // Validate the Slack ID if i.SlackId == "" { @@ -823,7 +863,7 @@ func (e *Event) GetEventStats( return nil, fmt.Errorf("failed to create stats filter: %w", err) } - count, err := e.store.CountWithFilter(ctx, filter) + count, err := e.store.CountWithFilter(ctx, authz.ScopeFromContext(ctx), filter) if err != nil { return nil, fmt.Errorf("failed to count events: %w", err) } @@ -906,7 +946,7 @@ func (e *Event) GetEventStatsByMonth( return nil, fmt.Errorf("failed to create stats filter: %w", err) } - results, err := e.store.AggregateByMonth(ctx, filter, i.GroupByService) + results, err := e.store.AggregateByMonth(ctx, authz.ScopeFromContext(ctx), filter, i.GroupByService) if err != nil { return nil, fmt.Errorf("failed to aggregate events by month: %w", err) } diff --git a/server/event_scope_test.go b/server/event_scope_test.go new file mode 100644 index 0000000..efdd835 --- /dev/null +++ b/server/event_scope_test.go @@ -0,0 +1,325 @@ +package server + +import ( + "context" + "testing" + "time" + + eventv1 "github.com/bananaops/tracker/generated/proto/event/v1alpha1" + "github.com/bananaops/tracker/internal/auth" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const eventSvc = "/tracker.event.v1alpha1.EventService/" + +type scopeEnv struct { + s *scopeServices + a, b, e string + pa, pab auth.Principal + none, all auth.Principal +} + +func newScopeEnv(t *testing.T) *scopeEnv { + t.Helper() + s := newScopeServices(t, scopeDB(t)) + return &scopeEnv{ + s: s, a: seedEvent(t, s, "svc-a"), b: seedEvent(t, s, "svc-b"), e: seedEvent(t, s, ""), + pa: scopedPrincipal("svc-a"), pab: scopedPrincipal("svc-a", "svc-b"), + none: scopedPrincipal(), all: allScopePrincipal(), + } +} + +func (x *scopeEnv) ctx(p auth.Principal, method string) context.Context { + return scopeCtx(p, eventSvc+method) +} + +func requireDenied(t *testing.T, err error) { + t.Helper() + require.Error(t, err) + require.Equal(t, codes.PermissionDenied, status.Code(err), err.Error()) +} + +func scopeAttrs(service string) *eventv1.EventAttributes { + return &eventv1.EventAttributes{ + Service: service, Type: eventv1.Type_incident, Status: eventv1.Status_open, + Environment: eventv1.Environment_production, Priority: eventv1.Priority_P3, + Source: "scope-test", Owner: "alice", + } +} + +func createReq(service string) *eventv1.CreateEventRequest { + return &eventv1.CreateEventRequest{Title: "t", Attributes: scopeAttrs(service), Links: &eventv1.EventLinks{}} +} + +func updateReq(id, service string) *eventv1.UpdateEventRequest { + return &eventv1.UpdateEventRequest{Id: id, Title: "t2", Attributes: scopeAttrs(service), Links: &eventv1.EventLinks{}} +} + +func (x *scopeEnv) stored(t *testing.T, id string) *eventv1.Event { + t.Helper() + e, err := x.s.eventStore.Get(context.Background(), map[string]interface{}{"metadata.id": id}) + require.NoError(t, err) + return e +} + +func (x *scopeEnv) countService(t *testing.T, service string) int { + t.Helper() + all, err := x.s.eventStore.List(context.Background(), auth.ScopeAll()) + require.NoError(t, err) + n := 0 + for _, e := range all { + if e.Attributes.Service == service { + n++ + } + } + return n +} + +func eventIDs(events []*eventv1.Event) []string { + out := []string{} + for _, e := range events { + out = append(out, e.Metadata.Id) + } + return out +} + +func TestCreateEventScope(t *testing.T) { + x := newScopeEnv(t) + _, err := x.s.events.CreateEvent(x.ctx(x.pa, "CreateEvent"), createReq("svc-a")) + require.NoError(t, err) + + before := x.countService(t, "svc-b") + _, err = x.s.events.CreateEvent(x.ctx(x.pa, "CreateEvent"), createReq("svc-b")) + requireDenied(t, err) + require.Equal(t, before, x.countService(t, "svc-b")) + + _, err = x.s.events.CreateEvent(x.ctx(x.pa, "CreateEvent"), createReq("")) + requireDenied(t, err) + _, err = x.s.events.CreateEvent(x.ctx(x.none, "CreateEvent"), createReq("svc-a")) + requireDenied(t, err) + + _, err = x.s.events.CreateEvent(x.ctx(x.all, "CreateEvent"), createReq("")) + require.NoError(t, err) + _, err = x.s.events.CreateEvent(x.ctx(x.all, "CreateEvent"), createReq("svc-b")) + require.NoError(t, err) +} + +func TestGetEventScope(t *testing.T) { + x := newScopeEnv(t) + get := func(p auth.Principal, id string) error { + _, err := x.s.events.GetEvent(x.ctx(p, "GetEvent"), &eventv1.GetEventRequest{Id: id}) + return err + } + require.NoError(t, get(x.pa, x.a)) + requireDenied(t, get(x.pa, x.b)) + requireDenied(t, get(x.pa, x.e)) + requireDenied(t, get(x.none, x.a)) + require.NoError(t, get(x.all, x.b)) + require.NoError(t, get(x.all, x.e)) + + err := get(x.pa, "6f1c1c6e-2f0a-4b57-9d3a-1c2f3a4b5c6d") + require.Error(t, err) + require.NotEqual(t, codes.PermissionDenied, status.Code(err)) +} + +func TestListEventsScope(t *testing.T) { + x := newScopeEnv(t) + list := func(p auth.Principal) *eventv1.ListEventsResponse { + r, err := x.s.events.ListEvents(x.ctx(p, "ListEvents"), &eventv1.ListEventsRequest{}) + require.NoError(t, err) + return r + } + r := list(x.pa) + require.ElementsMatch(t, []string{x.a}, eventIDs(r.Events)) + require.EqualValues(t, 1, r.TotalCount) + require.ElementsMatch(t, []string{x.a, x.b}, eventIDs(list(x.pab).Events)) + require.Empty(t, list(x.none).Events) + require.Len(t, list(x.all).Events, 3) +} + +func TestSearchEventsScope(t *testing.T) { + x := newScopeEnv(t) + search := func(p auth.Principal, req *eventv1.SearchEventsRequest) []*eventv1.Event { + r, err := x.s.events.SearchEvents(x.ctx(p, "SearchEvents"), req) + require.NoError(t, err) + return r.Events + } + req := &eventv1.SearchEventsRequest{Source: "scope-test"} + require.ElementsMatch(t, []string{x.a}, eventIDs(search(x.pa, req))) + require.Empty(t, search(x.pa, &eventv1.SearchEventsRequest{Source: "scope-test", Service: "svc-b"})) + require.Empty(t, search(x.none, req)) + require.Len(t, search(x.all, req), 3) +} + +func TestTodayEventsScope(t *testing.T) { + x := newScopeEnv(t) + today := func(p auth.Principal) []*eventv1.Event { + r, err := x.s.events.TodayEvents(x.ctx(p, "TodayEvents"), &eventv1.TodayEventsRequest{}) + require.NoError(t, err) + return r.Events + } + require.ElementsMatch(t, []string{x.a}, eventIDs(today(x.pa))) + require.Empty(t, today(x.none)) + require.Len(t, today(x.all), 3) +} + +func TestUpdateEventScope(t *testing.T) { + x := newScopeEnv(t) + update := func(p auth.Principal, id, service string) error { + _, err := x.s.events.UpdateEvent(x.ctx(p, "UpdateEvent"), updateReq(id, service)) + return err + } + require.NoError(t, update(x.pa, x.a, "svc-a")) + + requireDenied(t, update(x.pa, x.b, "svc-b")) + require.Equal(t, "seed svc-b", x.stored(t, x.b).Title) + + requireDenied(t, update(x.pa, x.a, "svc-b")) + require.Equal(t, "svc-a", x.stored(t, x.a).Attributes.Service) + + requireDenied(t, update(x.pa, x.a, "")) + require.Equal(t, "svc-a", x.stored(t, x.a).Attributes.Service) + + require.NoError(t, update(x.pab, x.a, "svc-b")) + require.Equal(t, "svc-b", x.stored(t, x.a).Attributes.Service) + + requireDenied(t, update(x.pa, x.e, "svc-a")) + require.Equal(t, "", x.stored(t, x.e).Attributes.Service) + + requireDenied(t, update(x.none, x.b, "svc-b")) + + require.NoError(t, update(x.all, x.b, "svc-a")) + require.Equal(t, "svc-a", x.stored(t, x.b).Attributes.Service) +} + +func TestDeleteEventsScope(t *testing.T) { + x := newScopeEnv(t) + del := func(p auth.Principal, id string) error { + _, err := x.s.events.DeleteEvents(x.ctx(p, "DeleteEvents"), &eventv1.DeleteEventRequest{Id: id}) + return err + } + requireDenied(t, del(x.pa, x.b)) + require.Equal(t, "svc-b", x.stored(t, x.b).Attributes.Service) + requireDenied(t, del(x.pa, x.e)) + require.NoError(t, del(x.pa, x.a)) + _, err := x.s.eventStore.Get(context.Background(), map[string]interface{}{"metadata.id": x.a}) + require.Error(t, err) + require.NoError(t, del(x.pa, "6f1c1c6e-2f0a-4b57-9d3a-1c2f3a4b5c6d")) + requireDenied(t, del(x.none, x.b)) + require.NoError(t, del(x.all, x.b)) + _, err = x.s.eventStore.Get(context.Background(), map[string]interface{}{"metadata.id": x.b}) + require.Error(t, err) +} + +func TestAddChangelogEntryScope(t *testing.T) { + x := newScopeEnv(t) + add := func(p auth.Principal, id string) error { + _, err := x.s.events.AddChangelogEntry(x.ctx(p, "AddChangelogEntry"), &eventv1.AddChangelogEntryRequest{ + Id: id, + Entry: &eventv1.ChangelogEntry{User: "alice", ChangeType: eventv1.ChangeType_commented, Comment: "c"}, + }) + return err + } + require.NoError(t, add(x.pa, x.a)) + before := len(x.stored(t, x.b).Changelog) + requireDenied(t, add(x.pa, x.b)) + require.Len(t, x.stored(t, x.b).Changelog, before) + requireDenied(t, add(x.pa, x.e)) + requireDenied(t, add(x.none, x.a)) +} + +func TestGetEventChangelogScope(t *testing.T) { + x := newScopeEnv(t) + get := func(p auth.Principal, id string) error { + _, err := x.s.events.GetEventChangelog(x.ctx(p, "GetEventChangelog"), &eventv1.GetEventChangelogRequest{Id: id}) + return err + } + require.NoError(t, get(x.pa, x.a)) + requireDenied(t, get(x.pa, x.b)) + requireDenied(t, get(x.none, x.a)) + require.NoError(t, get(x.all, x.b)) +} + +func TestAddSlackIdScope(t *testing.T) { + x := newScopeEnv(t) + add := func(p auth.Principal, id string) error { + _, err := x.s.events.AddSlackId(x.ctx(p, "AddSlackId"), &eventv1.AddSlackIdRequest{Id: id, SlackId: "S1"}) + return err + } + requireDenied(t, add(x.pa, x.b)) + require.Empty(t, x.stored(t, x.b).Metadata.SlackId) + require.NoError(t, add(x.pa, x.a)) + requireDenied(t, add(x.none, x.e)) +} + +func statsDates() (string, string) { + now := time.Now() + return now.AddDate(0, 0, -1).Format("2006-01-02"), now.AddDate(0, 0, 1).Format("2006-01-02") +} + +func TestGetEventStatsScope(t *testing.T) { + x := newScopeEnv(t) + start, end := statsDates() + stats := func(p auth.Principal, service string) uint64 { + r, err := x.s.events.GetEventStats(x.ctx(p, "GetEventStats"), &eventv1.GetEventStatsRequest{ + StartDate: start, EndDate: end, Source: "scope-test", Service: service, + }) + require.NoError(t, err) + return r.TotalCount + } + require.EqualValues(t, 1, stats(x.pa, "")) + require.EqualValues(t, 2, stats(x.pab, "")) + require.EqualValues(t, 0, stats(x.none, "")) + require.EqualValues(t, 3, stats(x.all, "")) + require.EqualValues(t, 0, stats(x.pa, "svc-b")) +} + +func TestGetEventStatsByMonthScope(t *testing.T) { + x := newScopeEnv(t) + start, end := statsDates() + stats := func(p auth.Principal) *eventv1.GetEventStatsByMonthResponse { + r, err := x.s.events.GetEventStatsByMonth(x.ctx(p, "GetEventStatsByMonth"), &eventv1.GetEventStatsByMonthRequest{ + StartDate: start, EndDate: end, Source: "scope-test", GroupByService: true, + }) + require.NoError(t, err) + return r + } + r := stats(x.pa) + require.Len(t, r.Stats, 1) + require.Equal(t, "svc-a", r.Stats[0].Service) + require.EqualValues(t, 1, r.TotalCount) + require.Empty(t, stats(x.none).Stats) + require.EqualValues(t, 3, stats(x.all).TotalCount) +} + +func scopeDeniedCount(t *testing.T) float64 { + t.Helper() + families, err := prometheus.DefaultGatherer.Gather() + require.NoError(t, err) + for _, f := range families { + if f.GetName() != "tracker_auth_requests_total" { + continue + } + for _, m := range f.GetMetric() { + labels := map[string]string{} + for _, l := range m.GetLabel() { + labels[l.GetName()] = l.GetValue() + } + if labels["principal"] == "user" && labels["result"] == "scope_denied" { + return m.GetCounter().GetValue() + } + } + } + return 0 +} + +func TestScopeDenialIsCounted(t *testing.T) { + x := newScopeEnv(t) + before := scopeDeniedCount(t) + _, err := x.s.events.GetEvent(x.ctx(x.pa, "GetEvent"), &eventv1.GetEventRequest{Id: x.b}) + requireDenied(t, err) + require.Equal(t, before+1, scopeDeniedCount(t)) +} diff --git a/server/lock.go b/server/lock.go index 18b0492..716fa86 100644 --- a/server/lock.go +++ b/server/lock.go @@ -2,6 +2,7 @@ package server import ( "context" + "errors" "fmt" "log/slog" "os" @@ -11,6 +12,7 @@ import ( "github.com/bananaops/tracker/internal/auth/authz" "github.com/bananaops/tracker/internal/config" store "github.com/bananaops/tracker/internal/stores" + "go.mongodb.org/mongo-driver/mongo" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -30,6 +32,23 @@ func NewLock() *Lock { } } +// requireLinkedEvent refuses to link a lock to an event outside the scope of +// the caller: locking and unlocking write to the changelog of that event. +// An unknown event id is accepted, nothing is written to it. +func (e *Lock) requireLinkedEvent(ctx context.Context, eventID string) error { + if eventID == "" || authz.ScopeFromContext(ctx).All { + return nil + } + event, err := e.eventStore.Get(ctx, map[string]interface{}{"metadata.id": eventID}) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil + } + if err != nil { + return fmt.Errorf("lookup event %s: %w", eventID, err) + } + return authz.RequireService(ctx, event.GetAttributes().GetService()) +} + func (e *Lock) CreateLock( ctx context.Context, i *v1alpha1.CreateLockRequest, @@ -38,6 +57,13 @@ func (e *Lock) CreateLock( return nil, err } + if err := authz.RequireService(ctx, i.Service); err != nil { + return nil, err + } + if err := e.requireLinkedEvent(ctx, i.EventId); err != nil { + return nil, err + } + var lock = &v1alpha1.Lock{ Service: i.Service, Who: i.Who, @@ -133,6 +159,9 @@ func (e *Lock) GetLock( if err != nil { return nil, fmt.Errorf("no event found in tracker for id %s", i.Id) } + if err := authz.RequireService(ctx, lockResult.Lock.GetService()); err != nil { + return nil, err + } return lockResult, nil } @@ -150,6 +179,21 @@ func (e *Lock) UpdateLock( return nil, fmt.Errorf("no lock found in tracker for id %s", i.Id) } + // The stored service and, when it changes, the new one must be in scope. + if err := authz.RequireService(ctx, existing.GetService()); err != nil { + return nil, err + } + if i.Service != "" { + if err := authz.RequireService(ctx, i.Service); err != nil { + return nil, err + } + } + if i.EventId != existing.GetEventId() { + if err := e.requireLinkedEvent(ctx, i.EventId); err != nil { + return nil, err + } + } + // Update fields only if provided (non-empty) if i.Service != "" { existing.Service = i.Service @@ -201,10 +245,15 @@ func (e *Lock) UnLock( return nil, fmt.Errorf("no event found in tracker for id %s", i.Id) } + if err := authz.RequireService(ctx, lockResult.Lock.GetService()); err != nil { + return nil, err + } + // Si un event_id est fourni, ajouter une entrée dans le changelog de l'événement if lockResult.Lock.EventId != "" { event, err := e.eventStore.Get(context.Background(), map[string]interface{}{"metadata.id": lockResult.Lock.EventId}) - if err == nil { + // The unlock stays allowed, but nothing is written to an event outside the scope. + if err == nil && authz.ScopeFromContext(ctx).Allows(event.GetAttributes().GetService()) { // Ajouter l'entrée "unlocked" dans le changelog entry := &eventv1alpha1.ChangelogEntry{ Timestamp: timestamppb.Now(), @@ -260,7 +309,7 @@ func (e *Lock) ListLocks( var LocksResult = &v1alpha1.ListLocksResponse{} var err error - LocksResult.Locks, err = e.store.List(context.Background()) + LocksResult.Locks, err = e.store.List(context.Background(), authz.ScopeFromContext(ctx)) if err != nil { return nil, err } @@ -270,6 +319,7 @@ func (e *Lock) ListLocks( } // UnlockByEventId libère un lock associé à un event_id +// It is not an RPC and performs no scope check: UpdateEvent already checked the event. func (e *Lock) UnlockByEventId(ctx context.Context, eventId string) error { if eventId == "" { return nil // Pas de lock à libérer diff --git a/server/lock_scope_test.go b/server/lock_scope_test.go new file mode 100644 index 0000000..0feb7ab --- /dev/null +++ b/server/lock_scope_test.go @@ -0,0 +1,262 @@ +package server + +import ( + "context" + "testing" + + eventv1 "github.com/bananaops/tracker/generated/proto/event/v1alpha1" + lockv1 "github.com/bananaops/tracker/generated/proto/lock/v1alpha1" + "github.com/bananaops/tracker/internal/auth" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/mongo" +) + +const lockSvc = "/tracker.lock.v1alpha1.LockService/" + +type lockScopeEnv struct { + *scopeEnv + la, lb, le string +} + +func newLockScopeEnv(t *testing.T) *lockScopeEnv { + t.Helper() + x := newScopeEnv(t) + return &lockScopeEnv{ + scopeEnv: x, + la: seedLock(t, x.s, "svc-a"), + lb: seedLock(t, x.s, "svc-b"), + le: seedLock(t, x.s, ""), + } +} + +func (x *lockScopeEnv) lctx(p auth.Principal, method string) context.Context { + return scopeCtx(p, lockSvc+method) +} + +func (x *lockScopeEnv) lock(t *testing.T, id string) *lockv1.Lock { + t.Helper() + l, err := x.s.lockStore.Get(context.Background(), map[string]interface{}{"id": id}) + require.NoError(t, err) + return l +} + +func (x *lockScopeEnv) hasLock(t *testing.T, filter map[string]interface{}) bool { + t.Helper() + _, err := x.s.lockStore.Get(context.Background(), filter) + if err == mongo.ErrNoDocuments { + return false + } + require.NoError(t, err) + return true +} + +func countChange(e *eventv1.Event, ct eventv1.ChangeType) int { + n := 0 + for _, c := range e.Changelog { + if c.ChangeType == ct { + n++ + } + } + return n +} + +func createLockReq(service, env, eventID string) *lockv1.CreateLockRequest { + return &lockv1.CreateLockRequest{Service: service, Who: "alice", Environment: env, Resource: "deployment", EventId: eventID} +} + +func TestCreateLockScope(t *testing.T) { + x := newLockScopeEnv(t) + create := func(p auth.Principal, svc string) error { + _, err := x.s.locks.CreateLock(x.lctx(p, "CreateLock"), createLockReq(svc, "staging", "")) + return err + } + require.NoError(t, create(x.pa, "svc-a")) + requireDenied(t, create(x.pa, "svc-b")) + require.False(t, x.hasLock(t, map[string]interface{}{"service": "svc-b", "environment": "staging"})) + requireDenied(t, create(x.pa, "")) + requireDenied(t, create(x.none, "svc-a")) + require.NoError(t, create(x.all, "svc-b")) +} + +func TestCreateLockLinkedEventScope(t *testing.T) { + x := newLockScopeEnv(t) + create := func(p auth.Principal, svc, env, eventID string) error { + _, err := x.s.locks.CreateLock(x.lctx(p, "CreateLock"), createLockReq(svc, env, eventID)) + return err + } + before := len(x.stored(t, x.b).Changelog) + requireDenied(t, create(x.pa, "svc-a", "uat", x.b)) + require.False(t, x.hasLock(t, map[string]interface{}{"service": "svc-a", "environment": "uat"})) + require.Len(t, x.stored(t, x.b).Changelog, before) + + require.NoError(t, create(x.pa, "svc-a", "uat", x.a)) + require.Equal(t, 1, countChange(x.stored(t, x.a), eventv1.ChangeType_locked)) + + require.NoError(t, create(x.pa, "svc-a", "dev", "6f1c1c6e-2f0a-4b57-9d3a-1c2f3a4b5c6d")) + + require.NoError(t, create(x.all, "svc-b", "uat", x.b)) + require.Equal(t, 1, countChange(x.stored(t, x.b), eventv1.ChangeType_locked)) +} + +func TestGetLockScope(t *testing.T) { + x := newLockScopeEnv(t) + get := func(p auth.Principal, id string) error { + _, err := x.s.locks.GetLock(x.lctx(p, "GetLock"), &lockv1.GetLockRequest{Id: id}) + return err + } + require.NoError(t, get(x.pa, x.la)) + requireDenied(t, get(x.pa, x.lb)) + requireDenied(t, get(x.pa, x.le)) + requireDenied(t, get(x.none, x.la)) + require.NoError(t, get(x.all, x.le)) +} + +func TestUpdateLockScope(t *testing.T) { + x := newLockScopeEnv(t) + update := func(p auth.Principal, req *lockv1.UpdateLockRequest) error { + _, err := x.s.locks.UpdateLock(x.lctx(p, "UpdateLock"), req) + return err + } + require.NoError(t, update(x.pa, &lockv1.UpdateLockRequest{Id: x.la, Who: "bob"})) + require.Equal(t, "bob", x.lock(t, x.la).Who) + + requireDenied(t, update(x.pa, &lockv1.UpdateLockRequest{Id: x.lb, Who: "bob"})) + require.Equal(t, "seed", x.lock(t, x.lb).Who) + + requireDenied(t, update(x.pa, &lockv1.UpdateLockRequest{Id: x.la, Service: "svc-b"})) + require.Equal(t, "svc-a", x.lock(t, x.la).Service) + + requireDenied(t, update(x.none, &lockv1.UpdateLockRequest{Id: x.la, Who: "eve"})) + + before := len(x.stored(t, x.b).Changelog) + requireDenied(t, update(x.pa, &lockv1.UpdateLockRequest{Id: x.la, EventId: x.b})) + require.Empty(t, x.lock(t, x.la).EventId) + require.Len(t, x.stored(t, x.b).Changelog, before) + require.NoError(t, update(x.pa, &lockv1.UpdateLockRequest{Id: x.la, EventId: x.a})) + require.Equal(t, x.a, x.lock(t, x.la).EventId) + + require.NoError(t, update(x.pab, &lockv1.UpdateLockRequest{Id: x.la, Service: "svc-b"})) + require.Equal(t, "svc-b", x.lock(t, x.la).Service) +} + +func TestUnLockScope(t *testing.T) { + x := newLockScopeEnv(t) + unlock := func(p auth.Principal, id string) (*lockv1.UnLockResponse, error) { + return x.s.locks.UnLock(x.lctx(p, "UnLock"), &lockv1.UnLockRequest{Id: id}) + } + _, err := unlock(x.pa, x.lb) + requireDenied(t, err) + require.True(t, x.hasLock(t, map[string]interface{}{"id": x.lb})) + _, err = unlock(x.pa, x.le) + requireDenied(t, err) + require.True(t, x.hasLock(t, map[string]interface{}{"id": x.le})) + r, err := unlock(x.pa, x.la) + require.NoError(t, err) + require.EqualValues(t, 1, r.Count) + require.False(t, x.hasLock(t, map[string]interface{}{"id": x.la})) + _, err = unlock(x.none, x.lb) + requireDenied(t, err) + require.True(t, x.hasLock(t, map[string]interface{}{"id": x.lb})) + _, err = unlock(x.all, x.lb) + require.NoError(t, err) +} + +// UnLock of an in-scope lock stays allowed when the linked event is out of +// scope, but nothing is written to that event. +func TestUnLockLinkedOutOfScopeEvent(t *testing.T) { + x := newLockScopeEnv(t) + _, err := x.s.locks.CreateLock(x.lctx(x.all, "CreateLock"), createLockReq("svc-a", "uat", x.b)) + require.NoError(t, err) + l, err := x.s.lockStore.Get(context.Background(), map[string]interface{}{"service": "svc-a", "environment": "uat"}) + require.NoError(t, err) + locked := countChange(x.stored(t, x.b), eventv1.ChangeType_locked) + + r, err := x.s.locks.UnLock(x.lctx(x.pa, "UnLock"), &lockv1.UnLockRequest{Id: l.Id}) + require.NoError(t, err) + require.EqualValues(t, 1, r.Count) + require.False(t, x.hasLock(t, map[string]interface{}{"id": l.Id})) + require.Equal(t, 0, countChange(x.stored(t, x.b), eventv1.ChangeType_unlocked)) + require.Equal(t, locked, countChange(x.stored(t, x.b), eventv1.ChangeType_locked)) +} + +func TestUnLockLinkedInScopeEventWritesChangelog(t *testing.T) { + x := newLockScopeEnv(t) + _, err := x.s.locks.CreateLock(x.lctx(x.pa, "CreateLock"), createLockReq("svc-a", "uat", x.a)) + require.NoError(t, err) + l, err := x.s.lockStore.Get(context.Background(), map[string]interface{}{"service": "svc-a", "environment": "uat"}) + require.NoError(t, err) + _, err = x.s.locks.UnLock(x.lctx(x.pa, "UnLock"), &lockv1.UnLockRequest{Id: l.Id}) + require.NoError(t, err) + require.Equal(t, 1, countChange(x.stored(t, x.a), eventv1.ChangeType_unlocked)) +} + +// A principal holding both services moves a lock onto an event of the other +// service, then a user scoped to the lock service only releases it. +func TestUnLockNonAdminCrossLink(t *testing.T) { + x := newLockScopeEnv(t) + // lock on svc-a linked to the svc-b event: allowed for pab (both in scope) + _, err := x.s.locks.UpdateLock(x.lctx(x.pab, "UpdateLock"), &lockv1.UpdateLockRequest{Id: x.la, EventId: x.b}) + require.NoError(t, err) + + _, err = x.s.locks.UnLock(x.lctx(x.pa, "UnLock"), &lockv1.UnLockRequest{Id: x.la}) + require.NoError(t, err) + require.False(t, x.hasLock(t, map[string]interface{}{"id": x.la})) + require.Equal(t, 0, countChange(x.stored(t, x.b), eventv1.ChangeType_unlocked)) +} + +func TestUpdateLockEmptyServiceAndUnknownEvent(t *testing.T) { + x := newLockScopeEnv(t) + _, err := x.s.locks.UpdateLock(x.lctx(x.pa, "UpdateLock"), &lockv1.UpdateLockRequest{Id: x.le, Who: "bob", Service: "svc-a"}) + requireDenied(t, err) + l := x.lock(t, x.le) + require.Equal(t, "seed", l.Who) + require.Empty(t, l.Service) + + unknown := "6f1c1c6e-2f0a-4b57-9d3a-1c2f3a4b5c6d" + _, err = x.s.locks.UpdateLock(x.lctx(x.pa, "UpdateLock"), &lockv1.UpdateLockRequest{Id: x.la, EventId: unknown}) + require.NoError(t, err) + require.Equal(t, unknown, x.lock(t, x.la).EventId) +} + +func TestListLocksScope(t *testing.T) { + x := newLockScopeEnv(t) + list := func(p auth.Principal) *lockv1.ListLocksResponse { + r, err := x.s.locks.ListLocks(x.lctx(p, "ListLocks"), &lockv1.ListLocksRequest{}) + require.NoError(t, err) + return r + } + r := list(x.pa) + require.Len(t, r.Locks, 1) + require.Equal(t, x.la, r.Locks[0].Id) + require.EqualValues(t, 1, r.TotalCount) + require.Empty(t, list(x.none).Locks) + require.Len(t, list(x.all).Locks, 3) +} + +func TestCreateEventTakesLockWithinScope(t *testing.T) { + x := newScopeEnv(t) + attrs := func(svc string, st eventv1.Status) *eventv1.EventAttributes { + a := scopeAttrs(svc) + a.Type, a.Status = eventv1.Type_deployment, st + return a + } + ctx := x.ctx(x.pa, "CreateEvent") + r, err := x.s.events.CreateEvent(ctx, &eventv1.CreateEventRequest{Title: "d", Attributes: attrs("svc-a", eventv1.Status_start), Links: &eventv1.EventLinks{}}) + require.NoError(t, err) + id := r.Event.Metadata.Id + l, err := x.s.lockStore.Get(context.Background(), map[string]interface{}{"service": "svc-a"}) + require.NoError(t, err) + require.Equal(t, id, l.EventId) + + _, err = x.s.events.UpdateEvent(x.ctx(x.pa, "UpdateEvent"), &eventv1.UpdateEventRequest{ + Id: id, Title: "d", Attributes: attrs("svc-a", eventv1.Status_success), Links: &eventv1.EventLinks{}, + }) + require.NoError(t, err) + _, err = x.s.lockStore.Get(context.Background(), map[string]interface{}{"service": "svc-a"}) + require.ErrorIs(t, err, mongo.ErrNoDocuments) + + _, err = x.s.events.CreateEvent(ctx, &eventv1.CreateEventRequest{Title: "d", Attributes: attrs("svc-b", eventv1.Status_start), Links: &eventv1.EventLinks{}}) + requireDenied(t, err) + _, err = x.s.lockStore.Get(context.Background(), map[string]interface{}{"service": "svc-b"}) + require.ErrorIs(t, err, mongo.ErrNoDocuments) +} diff --git a/server/scope_e2e_test.go b/server/scope_e2e_test.go new file mode 100644 index 0000000..dcb5764 --- /dev/null +++ b/server/scope_e2e_test.go @@ -0,0 +1,539 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + authv1 "github.com/bananaops/tracker/generated/proto/auth/v1alpha1" + catalogv1 "github.com/bananaops/tracker/generated/proto/catalog/v1alpha1" + eventv1 "github.com/bananaops/tracker/generated/proto/event/v1alpha1" + lockv1 "github.com/bananaops/tracker/generated/proto/lock/v1alpha1" + "github.com/bananaops/tracker/internal/auth" + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/stretchr/testify/require" +) + +const ( + e2ePrefix = "/api/v1alpha1" + e2eTeamA = "team-a" + e2eTeamB = "team-b" +) + +var e2ePermissions = []string{ + "event:read", "event:write", "lock:read", "lock:write", "catalog:read", "catalog:write", +} + +type scopeE2E struct { + f *authFixture + s *scopeServices + handler http.Handler + + teamA, teamB string + alice, bob *http.Cookie + keyA, keyGlobal string + evA, evB, evNone string + lockA, lockB string +} + +// newScopeE2E serves the event, lock, catalog and auth gateways and the +// cookie endpoints on a real mux behind the real auth middleware. mutate +// runs on the fixture before the handler is built. +func newScopeE2E(t *testing.T, mutate func(f *authFixture)) *scopeE2E { + t.Helper() + f := newAuthFixture(t) + if mutate != nil { + mutate(f) + } + s := newScopeServices(t, scopeDB(t)) + + ctx := context.Background() + mux := runtime.NewServeMux() + require.NoError(t, eventv1.RegisterEventServiceHandlerServer(ctx, mux, s.events)) + require.NoError(t, lockv1.RegisterLockServiceHandlerServer(ctx, mux, s.locks)) + require.NoError(t, catalogv1.RegisterCatalogServiceHandlerServer(ctx, mux, s.catalogs)) + require.NoError(t, authv1.RegisterAuthServiceHandlerServer(ctx, mux, newAuthService(f))) + NewAuthHTTP(f.users, f.sessions, f.cfg).Register(mux) + return &scopeE2E{f: f, s: s, handler: auth.HTTPMiddleware(f.resolver, f.cfg)(mux)} +} + +// do sends a request through the middleware and the mux. +func (h *scopeE2E) do(method, path, body string, cookie *http.Cookie, apiKey string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + if cookie != nil { + req.AddCookie(cookie) + } + if apiKey != "" { + req.Header.Set(auth.APIKeyHeader, apiKey) + } + rec := httptest.NewRecorder() + h.handler.ServeHTTP(rec, req) + return rec +} + +func (h *scopeE2E) admin(t *testing.T) (*Auth, auth.Principal) { + t.Helper() + return newAuthService(h.f), h.f.principalOf(t, h.f.admin) +} + +func (h *scopeE2E) createTeam(t *testing.T, name string, all bool, services ...string) string { + t.Helper() + svc, admin := h.admin(t) + r, err := svc.CreateTeam(rpcCtx(admin, "CreateTeam"), &authv1.CreateTeamRequest{ + Name: name, Permissions: e2ePermissions, ScopeAll: all, ScopeServices: services, + }) + require.NoError(t, err) + return r.Team.Id +} + +func (h *scopeE2E) createUser(t *testing.T, name, password string, teams ...string) { + t.Helper() + svc, admin := h.admin(t) + _, err := svc.CreateUser(rpcCtx(admin, "CreateUser"), &authv1.CreateUserRequest{ + Username: name, Password: password, TeamIds: teams, + }) + require.NoError(t, err) +} + +func (h *scopeE2E) createKey(t *testing.T, name, teamID string) string { + t.Helper() + svc, admin := h.admin(t) + r, err := svc.CreateApiKey(rpcCtx(admin, "CreateApiKey"), &authv1.CreateApiKeyRequest{Name: name, TeamId: teamID}) + require.NoError(t, err) + return r.Secret +} + +func (h *scopeE2E) login(t *testing.T, user, password string) *http.Cookie { + t.Helper() + rec := post(h.handler, e2ePrefix+"/auth/login", `{"username":"`+user+`","password":"`+password+`"}`, nil) + require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String()) + return sessionCookie(t, rec) +} + +// setup creates two single-service teams, a user and a key for team A, a user +// for team B, a global key and seeds events, locks and catalog entries. +func (h *scopeE2E) setup(t *testing.T) { + t.Helper() + h.teamA = h.createTeam(t, e2eTeamA, false, "service-a") + h.teamB = h.createTeam(t, e2eTeamB, false, "service-b") + h.createUser(t, "alice", "alice-initial-pass-1", h.teamA) + h.createUser(t, "bob", "bob-initial-pass-12", h.teamB) + h.keyA = h.createKey(t, "key-a", h.teamA) + h.keyGlobal = h.createKey(t, "key-global", "") + h.alice = h.login(t, "alice", "alice-initial-pass-1") + h.bob = h.login(t, "bob", "bob-initial-pass-12") + + h.evA = seedEvent(t, h.s, "service-a") + h.evB = seedEvent(t, h.s, "service-b") + h.evNone = seedEvent(t, h.s, "") + h.lockA = seedLock(t, h.s, "service-a") + h.lockB = seedLock(t, h.s, "service-b") + seedCatalog(t, h.s, &catalogv1.Catalog{Name: "service-a", Type: catalogv1.Type_project}) + seedCatalog(t, h.s, &catalogv1.Catalog{Name: "service-b", Type: catalogv1.Type_project}) +} + +func bodyJSON(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + var out map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String()) + return out +} + +// listField returns the objects held under key in a 200 list response. +func listField(t *testing.T, rec *httptest.ResponseRecorder, key string) []map[string]any { + t.Helper() + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + out := []map[string]any{} + raw, _ := bodyJSON(t, rec)[key].([]any) + for _, r := range raw { + m, ok := r.(map[string]any) + require.True(t, ok) + out = append(out, m) + } + return out +} + +func eventServices(t *testing.T, rec *httptest.ResponseRecorder) []string { + t.Helper() + out := []string{} + for _, e := range listField(t, rec, "events") { + attrs, _ := e["attributes"].(map[string]any) + svc, _ := attrs["service"].(string) + out = append(out, svc) + } + return out +} + +func lockServices(t *testing.T, rec *httptest.ResponseRecorder) []string { + t.Helper() + out := []string{} + for _, l := range listField(t, rec, "locks") { + svc, _ := l["service"].(string) + out = append(out, svc) + } + return out +} + +func catalogNames(t *testing.T, rec *httptest.ResponseRecorder) []string { + t.Helper() + out := []string{} + for _, c := range listField(t, rec, "catalogs") { + n, _ := c["name"].(string) + out = append(out, n) + } + return out +} + +func strs(v any) []string { + out := []string{} + raw, _ := v.([]any) + for _, s := range raw { + str, _ := s.(string) + out = append(out, str) + } + return out +} + +func e2eEventBody(service string) string { + return `{"title":"t","attributes":{"service":"` + service + + `","type":"incident","status":"open","environment":"production","priority":"P3","source":"e2e","owner":"alice"},"links":{}}` +} + +func e2eUpdateBody(id, service string) string { + return `{"id":"` + id + `","title":"t2","attributes":{"service":"` + service + + `","type":"incident","status":"open","environment":"production","priority":"P3","source":"e2e","owner":"alice"},"links":{}}` +} + +// requireForbidden asserts a 403 carrying the gateway JSON error body. +func requireForbidden(t *testing.T, rec *httptest.ResponseRecorder) { + t.Helper() + require.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String()) + require.Contains(t, rec.Header().Get("Content-Type"), "application/json") + body := bodyJSON(t, rec) + require.EqualValues(t, 7, body["code"]) + require.NotEmpty(t, body["message"]) +} + +// eventCount counts the stored events of a service, bypassing the scope. +func (h *scopeE2E) eventCount(t *testing.T, service string) int { + t.Helper() + all, err := h.s.eventStore.List(context.Background(), auth.ScopeAll()) + require.NoError(t, err) + n := 0 + for _, e := range all { + if e.Attributes.Service == service { + n++ + } + } + return n +} + +func (h *scopeE2E) storedEvent(t *testing.T, id string) *eventv1.Event { + t.Helper() + e, err := h.s.eventStore.Get(context.Background(), map[string]interface{}{"metadata.id": id}) + require.NoError(t, err) + return e +} + +func (h *scopeE2E) eventExists(t *testing.T, id string) bool { + t.Helper() + return h.do(http.MethodGet, e2ePrefix+"/event/"+id, "", nil, h.keyGlobal).Code == http.StatusOK +} + +func TestE2EMeReturnsScope(t *testing.T) { + h := newScopeE2E(t, nil) + h.setup(t) + + rec := h.do(http.MethodGet, e2ePrefix+"/auth/me", "", h.alice, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + me := bodyJSON(t, rec) + require.Equal(t, "user", me["kind"]) + require.Equal(t, []string{"service-a"}, strs(me["scopeServices"])) + require.Equal(t, false, me["scopeAll"]) + + rec = h.do(http.MethodGet, e2ePrefix+"/auth/me", "", nil, h.keyA) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + me = bodyJSON(t, rec) + require.Equal(t, "apikey", me["kind"]) + require.Equal(t, []string{"service-a"}, strs(me["scopeServices"])) + require.Equal(t, false, me["scopeAll"]) + + rec = h.do(http.MethodGet, e2ePrefix+"/auth/me", "", nil, h.keyGlobal) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + me = bodyJSON(t, rec) + require.Equal(t, true, me["scopeAll"]) + require.Empty(t, strs(me["scopeServices"])) + + rec = h.do(http.MethodGet, e2ePrefix+"/auth/me", "", nil, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + me = bodyJSON(t, rec) + require.Equal(t, "anonymous", me["kind"]) + require.Equal(t, true, me["scopeAll"]) + require.Empty(t, strs(me["permissions"])) +} + +// scopedChecks runs the essential assertions for a caller scoped to service-a. +func (h *scopeE2E) scopedChecks(t *testing.T, cookie *http.Cookie, key string) { + t.Helper() + do := func(method, path, body string) *httptest.ResponseRecorder { + return h.do(method, e2ePrefix+path, body, cookie, key) + } + + require.Equal(t, []string{"service-a"}, eventServices(t, do(http.MethodGet, "/events/list", ""))) + + require.Equal(t, http.StatusOK, do(http.MethodGet, "/event/"+h.evA, "").Code) + requireForbidden(t, do(http.MethodGet, "/event/"+h.evB, "")) + requireForbidden(t, do(http.MethodGet, "/event/"+h.evNone, "")) + + rec := do(http.MethodPost, "/event", e2eEventBody("service-a")) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + before := h.eventCount(t, "service-b") + requireForbidden(t, do(http.MethodPost, "/event", e2eEventBody("service-b"))) + require.Equal(t, before, h.eventCount(t, "service-b"), "denied create stores nothing") + + prev := h.storedEvent(t, h.evA) + requireForbidden(t, do(http.MethodPut, "/event", e2eUpdateBody(h.evA, "service-b"))) + after := h.storedEvent(t, h.evA) + require.Equal(t, "service-a", after.Attributes.Service) + require.Equal(t, prev.Title, after.Title) + requireForbidden(t, do(http.MethodDelete, "/event/"+h.evB, "")) + require.True(t, h.eventExists(t, h.evB), "denied delete leaves the event") +} + +func TestE2EUserScope(t *testing.T) { + h := newScopeE2E(t, nil) + h.setup(t) + do := func(method, path, body string) *httptest.ResponseRecorder { + return h.do(method, e2ePrefix+path, body, h.alice, "") + } + + // Events. + h.scopedChecks(t, h.alice, "") + require.Equal(t, http.StatusOK, do(http.MethodDelete, "/event/"+h.evA, "").Code) + require.False(t, h.eventExists(t, h.evA)) + + // Locks. + require.Equal(t, []string{"service-a"}, lockServices(t, do(http.MethodGet, "/locks/list", ""))) + requireForbidden(t, do(http.MethodGet, "/unlock/"+h.lockB, "")) + _, err := h.s.lockStore.Get(context.Background(), map[string]interface{}{"id": h.lockB}) + require.NoError(t, err, "denied unlock keeps the lock") + requireForbidden(t, do(http.MethodGet, "/lock/"+h.lockB, "")) + require.Equal(t, http.StatusOK, do(http.MethodGet, "/lock/"+h.lockA, "").Code) + + // Catalog. + require.Equal(t, []string{"service-a"}, catalogNames(t, do(http.MethodGet, "/catalogs/list", ""))) + require.Equal(t, http.StatusOK, do(http.MethodGet, "/catalog?name=service-a", "").Code) + requireForbidden(t, do(http.MethodGet, "/catalog?name=service-b", "")) + prevB, err := h.s.catalogs.store.Get(context.Background(), map[string]interface{}{"name": "service-b"}) + require.NoError(t, err) + requireForbidden(t, do(http.MethodPut, "/catalog", `{"name":"service-b","owner":"o","version":"1"}`)) + requireForbidden(t, do(http.MethodDelete, "/catalog?name=service-b", "")) + gotB, err := h.s.catalogs.store.Get(context.Background(), map[string]interface{}{"name": "service-b"}) + require.NoError(t, err, "denied delete keeps the entry") + require.Equal(t, prevB.Owner, gotB.Owner) + require.Equal(t, prevB.Version, gotB.Version) + + // Statistics only count service-a (the created and deleted events balance + // out: one remains from the create above, the seeded one was deleted). + start, end := statsDates() + q := url.Values{"start_date": {start}, "end_date": {end}}.Encode() + rec := do(http.MethodGet, "/events/stats?"+q, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + require.EqualValues(t, "1", bodyJSON(t, rec)["totalCount"]) +} + +func TestE2ETeamAPIKeyScope(t *testing.T) { + h := newScopeE2E(t, nil) + h.setup(t) + h.scopedChecks(t, nil, h.keyA) +} + +func TestE2EOtherTeamSeesItsOwn(t *testing.T) { + h := newScopeE2E(t, nil) + h.setup(t) + require.Equal(t, []string{"service-b"}, eventServices(t, h.do(http.MethodGet, e2ePrefix+"/events/list", "", h.bob, ""))) + requireForbidden(t, h.do(http.MethodGet, e2ePrefix+"/event/"+h.evA, "", h.bob, "")) + require.Equal(t, http.StatusOK, h.do(http.MethodGet, e2ePrefix+"/event/"+h.evB, "", h.bob, "").Code) +} + +func TestE2EGlobalKeyAndAdminSeeEverything(t *testing.T) { + h := newScopeE2E(t, nil) + h.setup(t) + adminCookie := h.login(t, "admin", "admin-password-123") + + for name, c := range map[string]struct { + cookie *http.Cookie + key string + }{"global key": {nil, h.keyGlobal}, "admin": {adminCookie, ""}} { + t.Run(name, func(t *testing.T) { + rec := h.do(http.MethodGet, e2ePrefix+"/events/list", "", c.cookie, c.key) + require.ElementsMatch(t, []string{"service-a", "service-b", ""}, eventServices(t, rec)) + require.Equal(t, http.StatusOK, h.do(http.MethodGet, e2ePrefix+"/event/"+h.evNone, "", c.cookie, c.key).Code) + require.Len(t, lockServices(t, h.do(http.MethodGet, e2ePrefix+"/locks/list", "", c.cookie, c.key)), 2) + require.Len(t, catalogNames(t, h.do(http.MethodGet, e2ePrefix+"/catalogs/list", "", c.cookie, c.key)), 2) + }) + } +} + +func TestE2EScopeChangeIsImmediate(t *testing.T) { + h := newScopeE2E(t, nil) + h.setup(t) + list := func(cookie *http.Cookie, key string) []string { + return eventServices(t, h.do(http.MethodGet, e2ePrefix+"/events/list", "", cookie, key)) + } + require.Equal(t, []string{"service-a"}, list(h.alice, "")) + require.Equal(t, []string{"service-a"}, list(nil, h.keyA)) + + svc, admin := h.admin(t) + _, err := svc.UpdateTeam(rpcCtx(admin, "UpdateTeam"), &authv1.UpdateTeamRequest{ + Id: h.teamA, Name: e2eTeamA, Permissions: e2ePermissions, ScopeServices: []string{"service-b"}, + }) + require.NoError(t, err) + + // Same cookie, same key, no restart. + require.Equal(t, []string{"service-b"}, list(h.alice, "")) + require.Equal(t, []string{"service-b"}, list(nil, h.keyA)) + requireForbidden(t, h.do(http.MethodGet, e2ePrefix+"/event/"+h.evA, "", h.alice, "")) + requireForbidden(t, h.do(http.MethodGet, e2ePrefix+"/event/"+h.evA, "", nil, h.keyA)) + rec := h.do(http.MethodGet, e2ePrefix+"/auth/me", "", h.alice, "") + require.Equal(t, []string{"service-b"}, strs(bodyJSON(t, rec)["scopeServices"])) +} + +func TestE2EAnonymousWithReadPermissionSeesAll(t *testing.T) { + h := newScopeE2E(t, func(f *authFixture) { + perms := []auth.Permission{auth.PermEventRead} + f.cfg.AnonymousPermissions = perms + f.resolver.AnonymousPermissions = perms + }) + h.setup(t) + rec := h.do(http.MethodGet, e2ePrefix+"/events/list", "", nil, "") + require.ElementsMatch(t, []string{"service-a", "service-b", ""}, eventServices(t, rec)) +} + +func TestE2EForbiddenBodyHasNoObjectData(t *testing.T) { + h := newScopeE2E(t, nil) + h.setup(t) + secret := seedEvent(t, h.s, "service-b") + _, err := h.s.eventStore.Update(context.Background(), map[string]interface{}{"metadata.id": secret}, &eventv1.Event{ + Title: "secret-title-b", + Attributes: scopeAttrs("service-b"), + Links: &eventv1.EventLinks{}, + Metadata: &eventv1.EventMetadata{Id: secret}, + }) + require.NoError(t, err) + + rec := h.do(http.MethodGet, e2ePrefix+"/event/"+secret, "", h.alice, "") + requireForbidden(t, rec) + require.Contains(t, rec.Body.String(), "service-b") + require.NotContains(t, rec.Body.String(), "secret-title-b") + require.NotContains(t, rec.Body.String(), secret) +} + +func TestE2EInvalidAPIKeyStaysUnauthorized(t *testing.T) { + h := newScopeE2E(t, nil) + h.setup(t) + for _, path := range []string{"/events/list", "/event/" + h.evA, "/auth/me"} { + rec := h.do(http.MethodGet, e2ePrefix+path, "", nil, "trk_not-a-real-key") + require.Equal(t, http.StatusUnauthorized, rec.Code, path+" "+rec.Body.String()) + } +} + +func TestE2EUserInTwoTeamsGetsTheUnion(t *testing.T) { + h := newScopeE2E(t, nil) + h.setup(t) + h.createUser(t, "carol", "carol-initial-pass-1", h.teamA, h.teamB) + carol := h.login(t, "carol", "carol-initial-pass-1") + + rec := h.do(http.MethodGet, e2ePrefix+"/events/list", "", carol, "") + require.ElementsMatch(t, []string{"service-a", "service-b"}, eventServices(t, rec)) + require.Equal(t, http.StatusOK, h.do(http.MethodGet, e2ePrefix+"/event/"+h.evB, "", carol, "").Code) + requireForbidden(t, h.do(http.MethodGet, e2ePrefix+"/event/"+h.evNone, "", carol, "")) + me := bodyJSON(t, h.do(http.MethodGet, e2ePrefix+"/auth/me", "", carol, "")) + require.Equal(t, []string{"service-a", "service-b"}, strs(me["scopeServices"])) + + // A team with scope all turns the union into all. + everything := h.createTeam(t, "team-all", true) + svc, admin := h.admin(t) + carolUser, err := h.f.users.GetByUsername(context.Background(), "carol") + require.NoError(t, err) + _, err = svc.UpdateUser(rpcCtx(admin, "UpdateUser"), &authv1.UpdateUserRequest{ + Id: carolUser.ID.Hex(), TeamIds: []string{h.teamA, h.teamB, everything}, + }) + require.NoError(t, err) + + rec = h.do(http.MethodGet, e2ePrefix+"/events/list", "", carol, "") + require.ElementsMatch(t, []string{"service-a", "service-b", ""}, eventServices(t, rec)) + me = bodyJSON(t, h.do(http.MethodGet, e2ePrefix+"/auth/me", "", carol, "")) + require.Equal(t, true, me["scopeAll"]) +} + +func TestScopeSlackIDLookups(t *testing.T) { + x := newScopeEnv(t) + pa := x.pa + + // AddSlackId by a scoped (non empty) user on an out-of-scope event. + _, err := x.s.events.AddSlackId(x.ctx(pa, "AddSlackId"), &eventv1.AddSlackIdRequest{Id: x.b, SlackId: "SLACK-B"}) + requireDenied(t, err) + require.Empty(t, x.stored(t, x.b).Metadata.SlackId) + + for id, slack := range map[string]string{x.a: "SLACK-A", x.b: "SLACK-B"} { + _, err := x.s.events.AddSlackId(x.ctx(x.all, "AddSlackId"), &eventv1.AddSlackIdRequest{Id: id, SlackId: slack}) + require.NoError(t, err) + } + + // GetEvent through the slack id branch (non UUID id). + _, err = x.s.events.GetEvent(x.ctx(pa, "GetEvent"), &eventv1.GetEventRequest{Id: "SLACK-A"}) + require.NoError(t, err) + _, err = x.s.events.GetEvent(x.ctx(pa, "GetEvent"), &eventv1.GetEventRequest{Id: "SLACK-B"}) + requireDenied(t, err) + + // UpdateEvent through the slack id branch. + before := x.stored(t, x.b) + req := updateReq("", "service-b") + req.SlackId = "SLACK-B" + _, err = x.s.events.UpdateEvent(x.ctx(pa, "UpdateEvent"), req) + requireDenied(t, err) + req.Attributes = scopeAttrs("svc-a") + _, err = x.s.events.UpdateEvent(x.ctx(pa, "UpdateEvent"), req) + requireDenied(t, err) + after := x.stored(t, x.b) + require.Equal(t, before.Title, after.Title) + require.Equal(t, "svc-b", after.Attributes.Service) + + // GetEventChangelog on an out-of-scope event. + _, err = x.s.events.GetEventChangelog(x.ctx(pa, "GetEventChangelog"), &eventv1.GetEventChangelogRequest{Id: x.b}) + requireDenied(t, err) +} + +func TestScopeCatalogEmptyName(t *testing.T) { + x := newCatalogScopeEnv(t) + _, err := x.s.catalogs.GetCatalog(x.ctx(x.pa, "GetCatalog"), &catalogv1.GetCatalogRequest{}) + requireDenied(t, err) + _, err = x.s.catalogs.DeleteCatalog(x.ctx(x.pa, "DeleteCatalog"), &catalogv1.DeleteCatalogRequest{}) + requireDenied(t, err) + _, err = x.s.catalogs.UpdateVersions(x.ctx(x.pa, "UpdateVersions"), &catalogv1.UpdateVersionsRequest{ReferenceVersion: "9.9.9"}) + requireDenied(t, err) + + // Denials leave Mongo untouched. + require.Equal(t, "2.0.0", x.stored(t, "lib-a").ReferenceVersion) + require.Equal(t, "7.7.7", x.stored(t, "lib-b").ReferenceVersion) + for _, n := range []string{"proj-a", "proj-b", "lib-a", "lib-b"} { + require.True(t, x.exists(t, n)) + } + + _, err = x.s.catalogs.UpdateVersions(x.ctx(x.pa, "UpdateVersions"), + &catalogv1.UpdateVersionsRequest{Name: "lib-b", ReferenceVersion: "9.9.9"}) + requireDenied(t, err) + _, err = x.s.catalogs.UpdateDependencies(x.ctx(x.pa, "UpdateDependencies"), + &catalogv1.UpdateDependenciesRequest{Name: "proj-b", DependenciesOut: []string{"svc-z"}}) + requireDenied(t, err) + require.Equal(t, "7.7.7", x.stored(t, "lib-b").ReferenceVersion) + require.Empty(t, x.stored(t, "proj-b").DependenciesOut) +} diff --git a/server/scope_guard_test.go b/server/scope_guard_test.go new file mode 100644 index 0000000..5ab97e2 --- /dev/null +++ b/server/scope_guard_test.go @@ -0,0 +1,369 @@ +package server + +import ( + "context" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + authv1 "github.com/bananaops/tracker/generated/proto/auth/v1alpha1" + catalogv1 "github.com/bananaops/tracker/generated/proto/catalog/v1alpha1" + eventv1 "github.com/bananaops/tracker/generated/proto/event/v1alpha1" + lockv1 "github.com/bananaops/tracker/generated/proto/lock/v1alpha1" + "github.com/bananaops/tracker/internal/auth" + "github.com/bananaops/tracker/internal/auth/authz" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +type scopeStance int + +const ( + scopeFiltered scopeStance = iota // list style: restricted by a MongoDB filter + scopeChecked // unit operation: PermissionDenied outside the scope + scopeExempt // no service data: reason required +) + +type scopeProbe struct { + stance scopeStance + reason string + // request builds a request targeting the seeded svc-a objects. + request func(seed scopeSeed) any + // empty reports whether a filtered response holds no data. + empty func(resp any) bool +} + +type scopeSeed struct{ eventID, lockID, catalogName string } + +const ( + guardEventSvc = "/tracker.event.v1alpha1.EventService/" + guardLockSvc = "/tracker.lock.v1alpha1.LockService/" + guardCatalogSvc = "/tracker.catalog.v1alpha1.CatalogService/" + guardAuthSvc = "/tracker.auth.v1alpha1.AuthService/" +) + +func checked(request func(seed scopeSeed) any) scopeProbe { + return scopeProbe{stance: scopeChecked, request: request} +} + +func filtered(request func(seed scopeSeed) any, empty func(resp any) bool) scopeProbe { + return scopeProbe{stance: scopeFiltered, request: request, empty: empty} +} + +func exempt(reason string) scopeProbe { + return scopeProbe{stance: scopeExempt, reason: reason} +} + +const adminReason = "identity administration guarded by access:manage, no service data" + +var rpcScopeProbes = map[string]scopeProbe{ + // EventService + guardEventSvc + "CreateEvent": checked(func(scopeSeed) any { return createReq("svc-a") }), + guardEventSvc + "UpdateEvent": checked(func(s scopeSeed) any { return updateReq(s.eventID, "svc-a") }), + guardEventSvc + "DeleteEvents": checked(func(s scopeSeed) any { + return &eventv1.DeleteEventRequest{Id: s.eventID} + }), + guardEventSvc + "GetEvent": checked(func(s scopeSeed) any { return &eventv1.GetEventRequest{Id: s.eventID} }), + guardEventSvc + "AddChangelogEntry": checked(func(s scopeSeed) any { + return &eventv1.AddChangelogEntryRequest{ + Id: s.eventID, + Entry: &eventv1.ChangelogEntry{User: "alice", ChangeType: eventv1.ChangeType_commented, Comment: "c"}, + } + }), + guardEventSvc + "GetEventChangelog": checked(func(s scopeSeed) any { + return &eventv1.GetEventChangelogRequest{Id: s.eventID} + }), + guardEventSvc + "AddSlackId": checked(func(s scopeSeed) any { + return &eventv1.AddSlackIdRequest{Id: s.eventID, SlackId: "S1"} + }), + guardEventSvc + "SearchEvents": filtered( + func(scopeSeed) any { return &eventv1.SearchEventsRequest{Source: "scope-test"} }, + func(r any) bool { + resp := r.(*eventv1.SearchEventsResponse) + return len(resp.Events) == 0 && resp.TotalCount == 0 + }), + guardEventSvc + "ListEvents": filtered( + func(scopeSeed) any { return &eventv1.ListEventsRequest{} }, + func(r any) bool { + resp := r.(*eventv1.ListEventsResponse) + return len(resp.Events) == 0 && resp.TotalCount == 0 + }), + guardEventSvc + "TodayEvents": filtered( + func(scopeSeed) any { return &eventv1.TodayEventsRequest{} }, + func(r any) bool { + resp := r.(*eventv1.TodayEventsResponse) + return len(resp.Events) == 0 && resp.TotalCount == 0 + }), + guardEventSvc + "GetEventStats": filtered( + func(scopeSeed) any { + start, end := statsDates() + return &eventv1.GetEventStatsRequest{StartDate: start, EndDate: end, Source: "scope-test"} + }, + func(r any) bool { return r.(*eventv1.GetEventStatsResponse).TotalCount == 0 }), + guardEventSvc + "GetEventStatsByMonth": filtered( + func(scopeSeed) any { + start, end := statsDates() + return &eventv1.GetEventStatsByMonthRequest{StartDate: start, EndDate: end, Source: "scope-test", GroupByService: true} + }, + func(r any) bool { + resp := r.(*eventv1.GetEventStatsByMonthResponse) + return len(resp.Stats) == 0 && resp.TotalCount == 0 + }), + + // LockService + guardLockSvc + "CreateLock": checked(func(scopeSeed) any { + return &lockv1.CreateLockRequest{Service: "svc-a", Environment: "guard", Who: "x", Resource: "deployment"} + }), + guardLockSvc + "GetLock": checked(func(s scopeSeed) any { return &lockv1.GetLockRequest{Id: s.lockID} }), + guardLockSvc + "UpdateLock": checked(func(s scopeSeed) any { + return &lockv1.UpdateLockRequest{Id: s.lockID, Who: "x"} + }), + guardLockSvc + "UnLock": checked(func(s scopeSeed) any { return &lockv1.UnLockRequest{Id: s.lockID} }), + guardLockSvc + "ListLocks": filtered( + func(scopeSeed) any { return &lockv1.ListLocksRequest{} }, + func(r any) bool { + resp := r.(*lockv1.ListLocksResponse) + return len(resp.Locks) == 0 && resp.TotalCount == 0 + }), + + // CatalogService + guardCatalogSvc + "CreateUpdateCatalog": checked(func(s scopeSeed) any { + return &catalogv1.CreateUpdateCatalogRequest{Name: s.catalogName, Type: catalogv1.Type_project, Owner: "o", Version: "1"} + }), + guardCatalogSvc + "GetCatalog": checked(func(s scopeSeed) any { + return &catalogv1.GetCatalogRequest{Name: s.catalogName} + }), + guardCatalogSvc + "DeleteCatalog": checked(func(s scopeSeed) any { + return &catalogv1.DeleteCatalogRequest{Name: s.catalogName} + }), + guardCatalogSvc + "UpdateVersions": checked(func(s scopeSeed) any { + return &catalogv1.UpdateVersionsRequest{Name: s.catalogName, AvailableVersions: []string{"1"}, LatestVersion: "1"} + }), + guardCatalogSvc + "UpdateDependencies": checked(func(s scopeSeed) any { + return &catalogv1.UpdateDependenciesRequest{Name: s.catalogName, DependenciesOut: []string{"x"}} + }), + guardCatalogSvc + "ListCatalogs": filtered( + func(scopeSeed) any { return &catalogv1.ListCatalogsRequest{} }, + func(r any) bool { + resp := r.(*catalogv1.ListCatalogsResponse) + return len(resp.Catalogs) == 0 && resp.TotalCount == 0 + }), + guardCatalogSvc + "GetVersionCompliance": filtered( + func(scopeSeed) any { return &catalogv1.GetVersionComplianceRequest{} }, + func(r any) bool { + resp := r.(*catalogv1.GetVersionComplianceResponse) + return len(resp.Projects) == 0 && resp.GetSummary().GetTotalProjects() == 0 + }), + + // AuthService + guardAuthSvc + "Me": exempt("returns the caller's own principal"), + guardAuthSvc + "GetAuthConfig": exempt("public configuration, no service data"), + guardAuthSvc + "ListUsers": exempt(adminReason), + guardAuthSvc + "CreateUser": exempt(adminReason), + guardAuthSvc + "UpdateUser": exempt(adminReason), + guardAuthSvc + "ListTeams": exempt(adminReason), + guardAuthSvc + "CreateTeam": exempt(adminReason), + guardAuthSvc + "UpdateTeam": exempt(adminReason), + guardAuthSvc + "DeleteTeam": exempt(adminReason), + guardAuthSvc + "ListApiKeys": exempt(adminReason), + guardAuthSvc + "CreateApiKey": exempt(adminReason), + guardAuthSvc + "RevokeApiKey": exempt(adminReason), +} + +func TestEveryRPCDeclaresAScopeStance(t *testing.T) { + declared := map[string]bool{} + for _, desc := range []grpc.ServiceDesc{ + eventv1.EventService_ServiceDesc, + catalogv1.CatalogService_ServiceDesc, + lockv1.LockService_ServiceDesc, + authv1.AuthService_ServiceDesc, + } { + for _, m := range desc.Methods { + declared["/"+desc.ServiceName+"/"+m.MethodName] = true + } + } + + for name := range declared { + _, ok := rpcScopeProbes[name] + assert.True(t, ok, "RPC %s has no scope stance in rpcScopeProbes: decide whether it is filtered, checked or exempt", name) + } + for name, p := range rpcScopeProbes { + assert.True(t, declared[name], "rpcScopeProbes entry %s does not match any RPC (typo?)", name) + switch p.stance { + case scopeExempt: + assert.NotEmpty(t, p.reason, "exempt RPC %s needs a reason", name) + case scopeFiltered: + assert.NotNil(t, p.empty, "filtered RPC %s needs an empty predicate", name) + assert.NotNil(t, p.request, "RPC %s needs a request builder", name) + default: + assert.NotNil(t, p.request, "RPC %s needs a request builder", name) + } + _, ok := authz.MethodPermissions[name] + assert.True(t, ok, "rpcScopeProbes entry %s is missing from authz.MethodPermissions", name) + } + for name := range authz.MethodPermissions { + _, ok := rpcScopeProbes[name] + assert.True(t, ok, "authz.MethodPermissions entry %s has no scope stance in rpcScopeProbes", name) + } +} + +// scopeFingerprint serializes every stored event, lock and catalog entry so a +// probe can prove it wrote nothing. +func scopeFingerprint(t *testing.T, s *scopeServices) string { + t.Helper() + ctx := context.Background() + var b strings.Builder + events, err := s.eventStore.List(ctx, auth.ScopeAll()) + require.NoError(t, err) + locks, err := s.lockStore.List(ctx, auth.ScopeAll()) + require.NoError(t, err) + catalogs, err := s.catalogs.store.List(ctx, auth.ScopeAll()) + require.NoError(t, err) + var all []proto.Message + for _, e := range events { + all = append(all, e) + } + for _, l := range locks { + all = append(all, l) + } + for _, c := range catalogs { + all = append(all, c) + } + for _, m := range all { + b.WriteString(protojson.Format(m)) + } + return b.String() +} + +func seedGuardData(t *testing.T, s *scopeServices) scopeSeed { + t.Helper() + seed := scopeSeed{ + eventID: seedEvent(t, s, "svc-a"), + lockID: seedLock(t, s, "svc-a"), + catalogName: "svc-a", + } + seedCatalog(t, s, &catalogv1.Catalog{Name: seed.catalogName, Type: catalogv1.Type_project}) + return seed +} + +func callRPC(s *scopeServices, full string, p auth.Principal, req any) (any, error) { + var impl any + switch { + case strings.HasPrefix(full, guardEventSvc): + impl = s.events + case strings.HasPrefix(full, guardLockSvc): + impl = s.locks + default: + impl = s.catalogs + } + name := full[strings.LastIndex(full, "/")+1:] + method := reflect.ValueOf(impl).MethodByName(name) + if !method.IsValid() { + return nil, status.Errorf(codes.Unimplemented, "%s not implemented", name) + } + out := method.Call([]reflect.Value{reflect.ValueOf(scopeCtx(p, full)), reflect.ValueOf(req)}) + err, _ := out[1].Interface().(error) + return out[0].Interface(), err +} + +func TestScopedRPCsEnforceAnEmptyScope(t *testing.T) { + names := []string{} + for name, p := range rpcScopeProbes { + if p.stance != scopeExempt { + names = append(names, name) + } + } + sort.Strings(names) + + // Empty scope probes never write, so one database serves all of them. + shared := newScopeServices(t, scopeDB(t)) + sharedSeed := seedGuardData(t, shared) + destructive := map[string]bool{ + guardEventSvc + "DeleteEvents": true, + guardCatalogSvc + "DeleteCatalog": true, + guardLockSvc + "UnLock": true, + } + + for _, full := range names { + probe := rpcScopeProbes[full] + t.Run(full[strings.LastIndex(full, "/")+1:], func(t *testing.T) { + before := scopeFingerprint(t, shared) + resp, err := callRPC(shared, full, scopedPrincipal(), probe.request(sharedSeed)) + switch probe.stance { + case scopeChecked: + requireDenied(t, err) + case scopeFiltered: + require.NoError(t, err) + require.True(t, probe.empty(resp), "empty scope must see no data: %v", resp) + } + require.Equal(t, before, scopeFingerprint(t, shared), "an empty scope must not write") + + // Control: the same call with an unrestricted scope on the same + // kind of data proves the probe really observes something. + s, seed := shared, sharedSeed + if destructive[full] { + s = newScopeServices(t, scopeDB(t)) + seed = seedGuardData(t, s) + } + resp, err = callRPC(s, full, allScopePrincipal(), probe.request(seed)) + switch probe.stance { + case scopeChecked: + require.NotEqual(t, codes.PermissionDenied, status.Code(err), "unrestricted scope must pass: %v", err) + case scopeFiltered: + require.NoError(t, err) + require.False(t, probe.empty(resp), "control must observe seeded data: %v", resp) + } + }) + } +} + +var httpRouteSites = map[string]struct { + calls int + reason string +}{ + "links.go": {4, "custom links carry no service field"}, + "homer.go": {1, "proxy of an external Homer dashboard, no Tracker service data"}, + "auth_http.go": {1, "login, logout and password change act on the caller only"}, + "auth_oidc.go": {1, "OpenID Connect login flow, no service data"}, + "../cmd/serv.go": {3, "swagger.json, docs and config.js are static, no service data"}, +} + +func TestCustomHTTPRoutesDeclareAScopeStance(t *testing.T) { + found := map[string]bool{} + for _, dir := range []string{".", "../cmd"} { + entries, err := os.ReadDir(dir) + require.NoError(t, err) + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + path := filepath.ToSlash(filepath.Join(dir, name)) + if dir == "." { + path = name + } + content, err := os.ReadFile(filepath.Join(dir, name)) + require.NoError(t, err) + n := strings.Count(string(content), "HandlePath(") + if n == 0 { + continue + } + found[path] = true + site, ok := httpRouteSites[path] + assert.Equal(t, site.calls, n, "%s registers %d custom HTTP routes, the scope guard knows %d: declare whether the new route returns per-service data and scope it or add an exemption reason", path, n, site.calls) + assert.True(t, ok, "%s is not declared in httpRouteSites", path) + assert.NotEmpty(t, site.reason, "%s needs an exemption reason", path) + } + } + for path := range httpRouteSites { + assert.True(t, found[path], "httpRouteSites entry %s matches no file registering HandlePath routes", path) + } +} diff --git a/server/scope_testing_test.go b/server/scope_testing_test.go new file mode 100644 index 0000000..5857b2d --- /dev/null +++ b/server/scope_testing_test.go @@ -0,0 +1,135 @@ +package server + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "testing" + "time" + + catalogv1 "github.com/bananaops/tracker/generated/proto/catalog/v1alpha1" + eventv1 "github.com/bananaops/tracker/generated/proto/event/v1alpha1" + lockv1 "github.com/bananaops/tracker/generated/proto/lock/v1alpha1" + "github.com/bananaops/tracker/internal/auth" + "github.com/bananaops/tracker/internal/config" + store "github.com/bananaops/tracker/internal/stores" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// scopeDB connects to MONGO_TEST_URI and returns a throwaway database with +// all indexes, dropped at the end of the test. +func scopeDB(t *testing.T) *mongo.Database { + t.Helper() + uri := os.Getenv("MONGO_TEST_URI") + if uri == "" { + t.Skip("MONGO_TEST_URI not set") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri)) + require.NoError(t, err) + db := client.Database(fmt.Sprintf("tracker_test_%d", time.Now().UnixNano())) + t.Cleanup(func() { + c, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = db.Drop(c) + _ = client.Disconnect(c) + }) + require.NoError(t, store.EnsureIndexes(ctx, db)) + return db +} + +type scopeServices struct { + events *Event + locks *Lock + catalogs *Catalog + eventStore *store.EventStoreClient + lockStore *store.LockStoreClient +} + +// newScopeServices wires the services on db with a silent logger. +func newScopeServices(t *testing.T, db *mongo.Database) *scopeServices { + t.Helper() + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + eventStore := store.NewStoreEventFromCollection(db.Collection("events")) + lockStore := store.NewStoreLockFromCollection(db.Collection("locks")) + locks := &Lock{store: *lockStore, eventStore: eventStore, logger: logger} + events := &Event{store: eventStore, lockService: locks, logger: logger} + catalogs := &Catalog{store: store.NewStoreCatalogFromCollection(db.Collection(config.ConfigDatabase.CatalogCollection)), logger: logger} + return &scopeServices{events: events, locks: locks, catalogs: catalogs, eventStore: eventStore, lockStore: lockStore} +} + +// scopedPrincipal is a user holding every permission, restricted to services. +// No argument yields an empty restricted scope. +func scopedPrincipal(services ...string) auth.Principal { + return auth.Principal{ + Kind: auth.KindUser, + UserID: "000000000000000000000001", + Username: "scoped", + Permissions: auth.NewPermissionSet(auth.AllPermissions()...), + Scope: auth.ScopeOf(services...), + } +} + +// allScopePrincipal is the same user with an unrestricted scope. +func allScopePrincipal() auth.Principal { + p := scopedPrincipal() + p.Scope = auth.ScopeAll() + return p +} + +// scopeCtx builds a context as the gRPC server would for fullMethod, +// for example "/tracker.event.v1alpha1.EventService/GetEvent". +func scopeCtx(p auth.Principal, fullMethod string) context.Context { + ctx := grpc.NewContextWithServerTransportStream(context.Background(), fakeTransportStream{method: fullMethod}) + return auth.WithPrincipal(ctx, p) +} + +// seedEvent stores an incident of the given service and returns its id. +func seedEvent(t *testing.T, s *scopeServices, service string) string { + t.Helper() + d, err := time.Parse("2006-01-02", time.Now().Format("2006-01-02")) + require.NoError(t, err) + e, err := s.eventStore.Create(context.Background(), &eventv1.Event{ + Title: "seed " + service, + Attributes: &eventv1.EventAttributes{ + Service: service, + Source: "scope-test", + Type: eventv1.Type_incident, + Status: eventv1.Status_open, + Environment: eventv1.Environment_production, + Priority: eventv1.Priority_P3, + Owner: "seed", + StartDate: timestamppb.New(d.Add(12 * time.Hour)), + }, + Links: &eventv1.EventLinks{}, + Metadata: &eventv1.EventMetadata{}, + }) + require.NoError(t, err) + return e.Metadata.Id +} + +// seedLock stores a lock on service and returns its id. +func seedLock(t *testing.T, s *scopeServices, service string) string { + t.Helper() + l, err := s.lockStore.Create(context.Background(), &lockv1.Lock{ + Service: service, Environment: "production", Resource: "deployment", Who: "seed", + }) + require.NoError(t, err) + return l.Id +} + +// seedCatalog upserts a catalog entry directly through the store. +func seedCatalog(t *testing.T, s *scopeServices, entry *catalogv1.Catalog) { + t.Helper() + entry.Owner = "seed" + entry.Version = "1" + _, err := s.catalogs.store.Update(context.Background(), map[string]interface{}{"name": entry.Name}, entry) + require.NoError(t, err) +}