From 2d917b06bbb20baa6f0ec640dd699d5834655c96 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 3 Oct 2026 00:45:36 +0200 Subject: [PATCH 01/13] feat(auth): add service scope helpers authz.ScopeFromContext and authz.RequireService give RPC methods one place to read and enforce the service scope of the caller. store.ServiceFilter builds the matching MongoDB condition. A context without principal, an empty restricted scope and an empty service all fail closed. Scope denials are counted in tracker_auth_requests_total with result=scope_denied. Refs #196 --- internal/auth/authz/scope.go | 54 ++++++++++++++++++++ internal/auth/authz/scope_test.go | 85 +++++++++++++++++++++++++++++++ internal/stores/scope.go | 16 ++++++ internal/stores/scope_test.go | 24 +++++++++ 4 files changed, 179 insertions(+) create mode 100644 internal/auth/authz/scope.go create mode 100644 internal/auth/authz/scope_test.go create mode 100644 internal/stores/scope.go create mode 100644 internal/stores/scope_test.go 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/scope.go b/internal/stores/scope.go new file mode 100644 index 0000000..0dadd7b --- /dev/null +++ b/internal/stores/scope.go @@ -0,0 +1,16 @@ +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()}}}} +} 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")) +} From df95501593bd94a39a2f56e6874df71cb51b9b20 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 3 Oct 2026 00:48:27 +0200 Subject: [PATCH 02/13] feat(auth): validate team scope payloads A team scope that sets scope_all together with services, or whose services are all blank, is now rejected instead of silently becoming every service. Scopes are bounded to 500 services of 128 characters. No service still means every service, and the built-in Administrators team keeps an immutable unrestricted scope. Tests pin Me and team API keys returning the effective scope. Refs #196 --- server/auth_teams.go | 37 +++++- server/auth_teams_scope_test.go | 224 ++++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 server/auth_teams_scope_test.go diff --git a/server/auth_teams.go b/server/auth_teams.go index 2ce137e..683bb94 100644 --- a/server/auth_teams.go +++ b/server/auth_teams.go @@ -15,6 +15,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 +56,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 +69,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 len(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..94a7162 --- /dev/null +++ b/server/auth_teams_scope_test.go @@ -0,0 +1,224 @@ +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: "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) +} From 6957424a5765a30ac0f3e4ac0ba1aea05d0ce4fa Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 3 Oct 2026 08:40:49 +0200 Subject: [PATCH 03/13] feat(auth): scope events by service Event lists, searches and statistics are filtered on attributes.service in MongoDB when the caller's scope is restricted. Reading, updating, deleting, commenting or linking a single event is refused with PermissionDenied when its service is outside the scope, and an update checks both the stored and the new service. Events without a service require an unrestricted scope. Nothing changes for callers whose scope is all. Refs #196 --- internal/stores/event.go | 25 ++- internal/stores/scope.go | 12 + internal/stores/scope_mongo_test.go | 137 ++++++++++++ server/event.go | 50 ++++- server/event_scope_test.go | 325 ++++++++++++++++++++++++++++ server/scope_testing_test.go | 111 ++++++++++ 6 files changed, 647 insertions(+), 13 deletions(-) create mode 100644 internal/stores/scope_mongo_test.go create mode 100644 server/event_scope_test.go create mode 100644 server/scope_testing_test.go 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/scope.go b/internal/stores/scope.go index 0dadd7b..cb882b2 100644 --- a/internal/stores/scope.go +++ b/internal/stores/scope.go @@ -14,3 +14,15 @@ func ServiceFilter(scope auth.Scope, field string) 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..b4b989d --- /dev/null +++ b/internal/stores/scope_mongo_test.go @@ -0,0 +1,137 @@ +package store + +import ( + "context" + "testing" + + eventv1 "github.com/bananaops/tracker/generated/proto/event/v1alpha1" + "github.com/bananaops/tracker/internal/auth" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/bson" +) + +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)) +} 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/scope_testing_test.go b/server/scope_testing_test.go new file mode 100644 index 0000000..9686fbe --- /dev/null +++ b/server/scope_testing_test.go @@ -0,0 +1,111 @@ +package server + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "testing" + "time" + + eventv1 "github.com/bananaops/tracker/generated/proto/event/v1alpha1" + "github.com/bananaops/tracker/internal/auth" + 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 // set in Task 5 + eventStore *store.EventStoreClient + lockStore *store.LockStoreClient // set in Task 4 +} + +// 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")) + locks := &Lock{eventStore: eventStore, logger: logger} + events := &Event{store: eventStore, lockService: locks, logger: logger} + return &scopeServices{events: events, locks: locks, eventStore: eventStore} +} + +// 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 +} From 11d0c11081370c01922597f518a839410f7e5147 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 3 Oct 2026 08:45:52 +0200 Subject: [PATCH 04/13] feat(auth): scope locks by service ListLocks is filtered on the lock service for restricted callers. CreateLock, GetLock, UpdateLock and UnLock are refused with PermissionDenied outside the scope, UpdateLock checks both the stored and the new service, and a lock cannot be linked to an event outside the scope. The locks taken and released by CreateEvent and UpdateEvent stay covered by the event checks. Adds the idx_lock_service index. Refs #196 --- internal/stores/indexes.go | 5 + internal/stores/lock.go | 15 +- internal/stores/scope_mongo_test.go | 38 +++++ server/lock.go | 51 ++++++- server/lock_scope_test.go | 218 ++++++++++++++++++++++++++++ server/scope_testing_test.go | 16 +- 6 files changed, 337 insertions(+), 6 deletions(-) create mode 100644 server/lock_scope_test.go diff --git a/internal/stores/indexes.go b/internal/stores/indexes.go index 970f648..d71c6b2 100644 --- a/internal/stores/indexes.go +++ b/internal/stores/indexes.go @@ -150,6 +150,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") 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_mongo_test.go b/internal/stores/scope_mongo_test.go index b4b989d..9c17177 100644 --- a/internal/stores/scope_mongo_test.go +++ b/internal/stores/scope_mongo_test.go @@ -5,6 +5,7 @@ import ( "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/bson" @@ -135,3 +136,40 @@ func TestEventStoreAggregateScoped(t *testing.T) { 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 { + names = append(names, sp["name"].(string)) + } + require.Contains(t, names, "idx_lock_service") +} diff --git a/server/lock.go b/server/lock.go index 18b0492..413eca5 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,6 +245,10 @@ 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}) @@ -260,7 +308,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 +318,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..7b9a02b --- /dev/null +++ b/server/lock_scope_test.go @@ -0,0 +1,218 @@ +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) + r, err := unlock(x.pa, x.la) + require.NoError(t, err) + require.EqualValues(t, 1, r.Count) + _, err = unlock(x.none, x.lb) + requireDenied(t, err) + _, err = unlock(x.all, x.lb) + require.NoError(t, err) +} + +// A lock of an in-scope service may point to an out-of-scope event only when +// an unrestricted caller linked it. Releasing it is then allowed and appends +// the "unlocked" entry to that event: UnLock checks the lock, not the 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) + + 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.Equal(t, 1, countChange(x.stored(t, x.b), eventv1.ChangeType_unlocked)) +} + +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_testing_test.go b/server/scope_testing_test.go index 9686fbe..af90003 100644 --- a/server/scope_testing_test.go +++ b/server/scope_testing_test.go @@ -10,6 +10,7 @@ import ( "time" eventv1 "github.com/bananaops/tracker/generated/proto/event/v1alpha1" + lockv1 "github.com/bananaops/tracker/generated/proto/lock/v1alpha1" "github.com/bananaops/tracker/internal/auth" store "github.com/bananaops/tracker/internal/stores" "github.com/stretchr/testify/require" @@ -55,9 +56,10 @@ 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")) - locks := &Lock{eventStore: eventStore, logger: logger} + lockStore := store.NewStoreLockFromCollection(db.Collection("locks")) + locks := &Lock{store: *lockStore, eventStore: eventStore, logger: logger} events := &Event{store: eventStore, lockService: locks, logger: logger} - return &scopeServices{events: events, locks: locks, eventStore: eventStore} + return &scopeServices{events: events, locks: locks, eventStore: eventStore, lockStore: lockStore} } // scopedPrincipal is a user holding every permission, restricted to services. @@ -109,3 +111,13 @@ func seedEvent(t *testing.T, s *scopeServices, service string) string { 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 +} From 834ca07a62112ae46753f9aaa89378e02e1333f3 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 3 Oct 2026 08:51:29 +0200 Subject: [PATCH 05/13] fix(auth): do not write to out-of-scope events when unlocking UnLock of a lock in scope stays allowed, but the unlocked changelog entry is no longer written on a linked event outside the caller scope. Adds tests for the cross link, empty stored service, unknown event id and the non-unique idx_lock_service index. Refs #196 --- internal/stores/scope_mongo_test.go | 6 +++- server/lock.go | 3 +- server/lock_scope_test.go | 52 ++++++++++++++++++++++++++--- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/internal/stores/scope_mongo_test.go b/internal/stores/scope_mongo_test.go index 9c17177..62c832c 100644 --- a/internal/stores/scope_mongo_test.go +++ b/internal/stores/scope_mongo_test.go @@ -169,7 +169,11 @@ func TestScopeIndexes(t *testing.T) { require.NoError(t, cursor.All(ctx, &specs)) names := []string{} for _, sp := range specs { - names = append(names, sp["name"].(string)) + 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") } diff --git a/server/lock.go b/server/lock.go index 413eca5..716fa86 100644 --- a/server/lock.go +++ b/server/lock.go @@ -252,7 +252,8 @@ func (e *Lock) UnLock( // 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(), diff --git a/server/lock_scope_test.go b/server/lock_scope_test.go index 7b9a02b..0feb7ab 100644 --- a/server/lock_scope_test.go +++ b/server/lock_scope_test.go @@ -149,29 +149,73 @@ func TestUnLockScope(t *testing.T) { 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) } -// A lock of an in-scope service may point to an out-of-scope event only when -// an unrestricted caller linked it. Releasing it is then allowed and appends -// the "unlocked" entry to that event: UnLock checks the lock, not the event. +// 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.Equal(t, 1, countChange(x.stored(t, x.b), eventv1.ChangeType_unlocked)) + 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) { From cfbc147cde2072bc04a749b729e8481c3188a377 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 3 Oct 2026 08:57:43 +0200 Subject: [PATCH 06/13] feat(auth): scope the catalog by service Catalog entries are filtered and checked on their name. An in-scope entry is returned as stored, including the names of the services it depends on. Version compliance is computed on the scoped list: only in-scope projects are reported and a deliverable outside the scope is treated as absent, so its versions do not leak. Adds the idx_catalog_name index on the collection the catalog store really uses. Refs #196 --- internal/stores/catalog.go | 15 +- internal/stores/indexes.go | 17 ++- internal/stores/scope_mongo_test.go | 41 +++++ server/catalog.go | 28 +++- server/catalog_scope_test.go | 229 ++++++++++++++++++++++++++++ server/scope_testing_test.go | 16 +- 6 files changed, 337 insertions(+), 9 deletions(-) create mode 100644 server/catalog_scope_test.go 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/indexes.go b/internal/stores/indexes.go index d71c6b2..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" @@ -183,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/scope_mongo_test.go b/internal/stores/scope_mongo_test.go index 62c832c..0c901c5 100644 --- a/internal/stores/scope_mongo_test.go +++ b/internal/stores/scope_mongo_test.go @@ -4,11 +4,14 @@ 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 { @@ -176,4 +179,42 @@ func TestScopeIndexes(t *testing.T) { } } 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/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/scope_testing_test.go b/server/scope_testing_test.go index af90003..3807a9a 100644 --- a/server/scope_testing_test.go +++ b/server/scope_testing_test.go @@ -9,9 +9,11 @@ import ( "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" @@ -46,7 +48,7 @@ func scopeDB(t *testing.T) *mongo.Database { type scopeServices struct { events *Event locks *Lock - catalogs *Catalog // set in Task 5 + catalogs *Catalog eventStore *store.EventStoreClient lockStore *store.LockStoreClient // set in Task 4 } @@ -59,7 +61,8 @@ func newScopeServices(t *testing.T, db *mongo.Database) *scopeServices { lockStore := store.NewStoreLockFromCollection(db.Collection("locks")) locks := &Lock{store: *lockStore, eventStore: eventStore, logger: logger} events := &Event{store: eventStore, lockService: locks, logger: logger} - return &scopeServices{events: events, locks: locks, eventStore: eventStore, lockStore: lockStore} + 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. @@ -121,3 +124,12 @@ func seedLock(t *testing.T, s *scopeServices, service string) string { 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) +} From d7965e6fb0842ae3005d2919bd7f85bbd4d18e77 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 3 Oct 2026 09:11:58 +0200 Subject: [PATCH 07/13] test(auth): guard every RPC and HTTP route against unscoped access A table lists each RPC as filtered, checked or exempt with a reason, and must stay equal to the set of declared RPCs. Every non exempt RPC is called on real data with an empty scope: checked ones must answer PermissionDenied, filtered ones must return nothing. Custom HTTP routes are counted per file so a new one cannot ship without a stance. Refs #196 --- server/scope_guard_test.go | 369 +++++++++++++++++++++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 server/scope_guard_test.go 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) + } +} From e3a4782245f69893a78b0f68cbcfcd41257f7656 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 3 Oct 2026 09:18:58 +0200 Subject: [PATCH 08/13] test(auth): cover service scope end to end through the gateway Users and a team API key of two teams go through the real mux and middleware: lists are filtered, unit operations outside the scope answer 403, Me returns the scope, a scope change applies to live sessions and keys, and anonymous, global keys and administrators keep seeing everything. Refs #196 --- server/scope_e2e_test.go | 503 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 503 insertions(+) create mode 100644 server/scope_e2e_test.go diff --git a/server/scope_e2e_test.go b/server/scope_e2e_test.go new file mode 100644 index 0000000..a697ea5 --- /dev/null +++ b/server/scope_e2e_test.go @@ -0,0 +1,503 @@ +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"]) +} + +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.NotEqual(t, true, 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.NotEqual(t, true, 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"]) + + 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()) + requireForbidden(t, do(http.MethodPost, "/event", e2eEventBody("service-b"))) + + requireForbidden(t, do(http.MethodPut, "/event", e2eUpdateBody(h.evA, "service-b"))) + 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, "")) + 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", "")) + requireForbidden(t, do(http.MethodPut, "/catalog", `{"name":"service-b","owner":"o","version":"1"}`)) + requireForbidden(t, do(http.MethodDelete, "/catalog?name=service-b", "")) + + // 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) +} From fbf40ac6ec1c038d42310840c59279e64b5fde39 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 3 Oct 2026 09:24:08 +0200 Subject: [PATCH 09/13] test(auth): assert stored state after denied scope requests Denied catalog, event and unlock requests now re-read MongoDB to prove nothing was written, and Me is checked for an explicit scopeAll false. Refs #196 --- server/scope_e2e_test.go | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/server/scope_e2e_test.go b/server/scope_e2e_test.go index a697ea5..dcb5764 100644 --- a/server/scope_e2e_test.go +++ b/server/scope_e2e_test.go @@ -221,6 +221,27 @@ func requireForbidden(t *testing.T, rec *httptest.ResponseRecorder) { 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 @@ -235,19 +256,20 @@ func TestE2EMeReturnsScope(t *testing.T) { me := bodyJSON(t, rec) require.Equal(t, "user", me["kind"]) require.Equal(t, []string{"service-a"}, strs(me["scopeServices"])) - require.NotEqual(t, true, me["scopeAll"]) + 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.NotEqual(t, true, me["scopeAll"]) + 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()) @@ -272,9 +294,15 @@ func (h *scopeE2E) scopedChecks(t *testing.T, cookie *http.Cookie, key string) { 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") } @@ -294,6 +322,8 @@ func TestE2EUserScope(t *testing.T) { // 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) @@ -301,8 +331,14 @@ func TestE2EUserScope(t *testing.T) { 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). From 8696a551ef72113a384bb09643211cbb6adf9bcb Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 3 Oct 2026 09:26:50 +0200 Subject: [PATCH 10/13] docs(auth): document the per-service team scope Explains what a scope restricts, how lists and unit operations answer, the rules for objects without a service and for moving an object across scopes, what is not scoped, the team API validation and the upgrade note for teams that already list services. Refs #196 --- docs/AUTHENTICATION.md | 116 +++++++++++++++++++++++++++++++++++++++-- docs/CONFIGURATION.md | 2 + 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 385b643..2e57955 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -412,8 +412,7 @@ 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 +(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. @@ -429,6 +428,116 @@ 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 + +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 restricted team without services 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 always sends "all services": editing a restricted team from the + UI resets its scope to `all`. Manage restricted teams 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 +584,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. From c8e90af0c921639755d46f854c76b8492a61fc13 Mon Sep 17 00:00:00 2001 From: umignon Date: Sun, 4 Oct 2026 13:21:00 +0200 Subject: [PATCH 11/13] fix(auth): count team scope service names in characters The 128 limit used the byte length while the docs and the error say characters, so multibyte names were rejected too early. Count runes instead. Also drop a process reference from a test comment. Refs #196 --- server/auth_teams.go | 3 ++- server/auth_teams_scope_test.go | 2 ++ server/scope_testing_test.go | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/server/auth_teams.go b/server/auth_teams.go index 683bb94..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" @@ -87,7 +88,7 @@ func teamScopeFromRequest(scopeAll bool, services []string) (store.TeamScope, er return store.TeamScope{}, status.Errorf(codes.InvalidArgument, "a team scope holds at most %d services", teamScopeMaxServices) } for _, s := range clean { - if len(s) > teamScopeServiceMaxLength { + if utf8.RuneCountInString(s) > teamScopeServiceMaxLength { return store.TeamScope{}, status.Errorf(codes.InvalidArgument, "a scope service name is at most %d characters", teamScopeServiceMaxLength) } } diff --git a/server/auth_teams_scope_test.go b/server/auth_teams_scope_test.go index 94a7162..8af7531 100644 --- a/server/auth_teams_scope_test.go +++ b/server/auth_teams_scope_test.go @@ -45,6 +45,8 @@ func TestTeamScopeValidation(t *testing.T) { {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"}}, } diff --git a/server/scope_testing_test.go b/server/scope_testing_test.go index 3807a9a..5857b2d 100644 --- a/server/scope_testing_test.go +++ b/server/scope_testing_test.go @@ -50,7 +50,7 @@ type scopeServices struct { locks *Lock catalogs *Catalog eventStore *store.EventStoreClient - lockStore *store.LockStoreClient // set in Task 4 + lockStore *store.LockStoreClient } // newScopeServices wires the services on db with a silent logger. From fa762849eb3700303589e6b1174b0f8810c21068 Mon Sep 17 00:00:00 2001 From: umignon Date: Sun, 4 Oct 2026 13:21:10 +0200 Subject: [PATCH 12/13] docs(auth): warn that scope needs restricted anonymous access Document that the anonymous caller keeps scope all, so team scoping only isolates once AUTH_ANONYMOUS_PERMISSIONS is restricted. Correct the statement on teams without services and re-wrap a long line. Refs #196 --- docs/AUTHENTICATION.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 2e57955..7c2ea6e 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -412,9 +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, 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 +(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 | @@ -430,6 +430,16 @@ 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. @@ -463,7 +473,8 @@ perform an operation. - 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 restricted team without services sees and writes nothing. + A user who belongs to no team has an empty scope and sees and writes + nothing. ### Catalog From 2e6394a1e3dae3a05b086070f04234e05afdb0c7 Mon Sep 17 00:00:00 2001 From: umignon Date: Sun, 4 Oct 2026 20:28:08 +0200 Subject: [PATCH 13/13] docs(auth): describe the read-only scope of the team dialog The web team dialog (#201) now sends back the scope it loaded, so editing a restricted team from the UI no longer widens it. Refs #196 --- docs/AUTHENTICATION.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 7c2ea6e..7fd6bd5 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -508,8 +508,8 @@ Swagger, and the AuthService (identity administration, guarded by - 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 always sends "all services": editing a restricted team from the - UI resets its scope to `all`. Manage restricted teams through the API. + 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