diff --git a/.configs/gqlgen.yaml b/.configs/gqlgen.yaml index 129bd65ad..877d4a27e 100644 --- a/.configs/gqlgen.yaml +++ b/.configs/gqlgen.yaml @@ -68,6 +68,7 @@ autobind: - "github.com/nais/api/internal/servicemaintenance" - "github.com/nais/api/internal/servicemaintenance/activitylog" - "github.com/nais/api/internal/team" + - "github.com/nais/api/internal/team/teamscope" - "github.com/nais/api/internal/tunnel" - "github.com/nais/api/internal/unleash" - "github.com/nais/api/internal/user" diff --git a/integration_tests/cross_team_scope.lua b/integration_tests/cross_team_scope.lua new file mode 100644 index 000000000..1b4a72669 --- /dev/null +++ b/integration_tests/cross_team_scope.lua @@ -0,0 +1,241 @@ +local member = User.new() +local admin = User.new() +admin:admin(true) +for i = 1, 25 do + local slug = string.format("scope-%02d", i) + local team = Team.new(slug, "Scope testing", "#scope") + team:addMember(member) + Helper.SQLExec(string.format([[ + INSERT INTO issues (id, issue_type, resource_name, resource_type, team, env, severity) + VALUES ('00000000-0000-0000-0000-%012d', 'NO_RUNNING_INSTANCES', 'app', 'APPLICATION', '%s', 'dev', 'CRITICAL'); + INSERT INTO activity_log_entries (id, created_at, actor, action, resource_type, resource_name, team_slug, environment, data) + VALUES ('00000000-0000-0000-0000-%012d', '2026-01-01T00:00:00Z', 'deployer', 'DEPLOYMENT', 'APP', 'app', '%s', 'dev', '{}'); + ]], i, slug, i, slug)) +end +Team.new("outside", "Not a membership", "#outside") +Helper.SQLExec([[ + INSERT INTO issues (issue_type, resource_name, resource_type, team, env, severity) + VALUES + ('NO_RUNNING_INSTANCES', 'outside', 'APPLICATION', 'outside', 'dev', 'CRITICAL'), + ('NO_RUNNING_INSTANCES', 'prod-app', 'APPLICATION', 'scope-01', 'prod', 'WARNING'); + INSERT INTO activity_log_entries (actor, action, resource_type, resource_name, team_slug, environment, data) + VALUES + ('deployer', 'DEPLOYMENT', 'APP', 'outside', 'outside', 'dev', '{}'), + ('deployer', 'DEPLOYMENT', 'APP', 'tenant', NULL, 'dev', '{}'); +]]) + +Test.gql("Team discovery scopes precede ordering and pagination", function(t) + t.addHeader("x-user-email", member:email()) + t.query [[ + { + teams(first: 1, orderBy: {field: SLUG, direction: DESC}, filter: {teamScope: {memberOfViewer: true}}) { + nodes { slug } + pageInfo { totalCount hasNextPage endCursor } + } + selected: teams(filter: {teamScope: {teamSlugs: ["scope-25", "outside"], memberOfViewer: true}, hasWorkloads: false}) { + nodes { slug } + pageInfo { totalCount } + } + withWorkloads: teams(filter: {teamScope: {teamSlugs: ["scope-25"]}, hasWorkloads: true}) { + nodes { slug } + pageInfo { totalCount } + } + } + ]] + t.check { data = { + teams = { + nodes = { { slug = "scope-25" } }, + pageInfo = { totalCount = 25, hasNextPage = true, endCursor = Save("teamsEnd") }, + }, + selected = { nodes = { { slug = "scope-25" } }, pageInfo = { totalCount = 1 } }, + withWorkloads = { nodes = {}, pageInfo = { totalCount = 0 } }, + } } + t.query(string.format([[ + { + teams(first: 30, after: "%s", orderBy: {field: SLUG, direction: DESC}, filter: {teamScope: {memberOfViewer: true}}) { + nodes { slug } + pageInfo { totalCount hasNextPage endCursor } + } + } + ]], State.teamsEnd)) + local remaining = {} + for i = 24, 1, -1 do + table.insert(remaining, { slug = string.format("scope-%02d", i) }) + end + t.check { data = { + teams = { + nodes = remaining, + pageInfo = { totalCount = 25, hasNextPage = false, endCursor = Save("teamsLast") }, + }, + } } + t.query(string.format([[ + { + teams(first: 1, after: "%s", filter: {teamScope: {memberOfViewer: true}}) { + nodes { slug } + pageInfo { totalCount } + } + } + ]], State.teamsLast)) + t.check { data = { teams = { nodes = {}, pageInfo = { totalCount = 25 } } } } +end) + +Test.gql("Scopes include memberships beyond the first page", function(t) + t.addHeader("x-user-email", member:email()) + t.query [[ + { + me { ... on User { teams(first: 1) { pageInfo { totalCount } } } } + issues(first: 1, filter: {teamScope: {memberOfViewer: true}, severity: CRITICAL, environments: ["dev"]}) { + nodes { severity } + pageInfo { totalCount hasNextPage endCursor } + facets { teams { team { slug } count } } + } + activityLog(first: 1, filter: {teamScope: {memberOfViewer: true}, activityTypes: [DEPLOYMENT]}) { + nodes { teamSlug } + pageInfo { totalCount hasNextPage endCursor } + facets { resourceTypes { resourceType count } } + } + } + ]] + local teams = {} + for i = 1, 10 do + table.insert(teams, { team = { slug = string.format("scope-%02d", i) }, count = 1 }) + end + t.check { data = { + me = { teams = { pageInfo = { totalCount = 25 } } }, + issues = { + nodes = { { severity = "CRITICAL" } }, + pageInfo = { totalCount = 25, hasNextPage = true, endCursor = Save("issuesEnd") }, + facets = { teams = teams }, + }, + activityLog = { + nodes = { { teamSlug = "scope-25" } }, + pageInfo = { totalCount = 25, hasNextPage = true, endCursor = Save("activityEnd") }, + facets = { resourceTypes = { { resourceType = "APP", count = 25 } } }, + }, + } } + t.query(string.format([[ + { + issues(first: 30, after: "%s", filter: {teamScope: {memberOfViewer: true}, severity: CRITICAL}) { + pageInfo { totalCount endCursor } + } + activityLog(first: 30, after: "%s", filter: {teamScope: {memberOfViewer: true}, activityTypes: [DEPLOYMENT]}) { + nodes { teamSlug } + pageInfo { totalCount endCursor } + } + } + ]], State.issuesEnd, State.activityEnd)) + local remaining = {} + for i = 24, 1, -1 do + table.insert(remaining, { teamSlug = string.format("scope-%02d", i) }) + end + t.check { data = { + issues = { pageInfo = { totalCount = 25, endCursor = Save("issuesLast") } }, + activityLog = { + nodes = remaining, + pageInfo = { totalCount = 25, endCursor = Save("activityLast") }, + }, + } } + t.query(string.format([[ + { + issues(first: 1, after: "%s", filter: {teamScope: {memberOfViewer: true}, severity: CRITICAL}) { + nodes { id } + pageInfo { totalCount } + } + activityLog(first: 1, after: "%s", filter: {teamScope: {memberOfViewer: true}, activityTypes: [DEPLOYMENT]}) { + nodes { id } + pageInfo { totalCount } + } + } + ]], State.issuesLast, State.activityLast)) + t.check { data = { + issues = { nodes = {}, pageInfo = { totalCount = 25 } }, + activityLog = { nodes = {}, pageInfo = { totalCount = 25 } }, + } } +end) + +Test.gql("Tenant scopes and team filters are independent", function(t) + t.addHeader("x-user-email", member:email()) + t.query [[ + { + issues(filter: {teamScope: {teamSlugs: ["scope-25", "outside"], memberOfViewer: true}}) { + pageInfo { totalCount } + facets { teams { team { slug } count } } + } + activityLog(filter: {teamScope: {teamSlugs: ["scope-25", "outside"], memberOfViewer: true}}) { + nodes { teamSlug } + pageInfo { totalCount } + } + team(slug: "scope-01") { + issues(filter: {severity: WARNING, environments: ["prod"]}) { + pageInfo { totalCount } + facets { teams { count } } + } + activityLog(filter: {activityTypes: [DEPLOYMENT], environments: ["dev"]}) { + pageInfo { totalCount } + facets { resourceTypes { count } } + } + } + } + ]] + t.check { data = { + issues = { + pageInfo = { totalCount = 1 }, + facets = { teams = { { team = { slug = "scope-25" }, count = 1 } } }, + }, + activityLog = { + nodes = { { teamSlug = "scope-25" } }, + pageInfo = { totalCount = 1 }, + }, + team = { + issues = { pageInfo = { totalCount = 1 }, facets = { teams = { { count = 1 } } } }, + activityLog = { pageInfo = { totalCount = 1 }, facets = { resourceTypes = { { count = 1 } } } }, + }, + } } +end) + +for _, selection in ipairs({ "teamSlugs: []", "teamSlugs: [], memberOfViewer: true" }) do + Test.gql("Explicit empty scope: " .. selection, function(t) + t.addHeader("x-user-email", member:email()) + t.query(string.format([[ + { + teams(filter: {teamScope: {%s}}) { + nodes { slug } + pageInfo { totalCount } + } + issues(filter: {teamScope: {%s}}) { + nodes { id } + pageInfo { totalCount } + facets { teams { count } } + } + activityLog(filter: {teamScope: {%s}}) { + nodes { id } + pageInfo { totalCount } + facets { resourceTypes { count } } + } + } + ]], selection, selection, selection)) + t.check { data = { + teams = { nodes = {}, pageInfo = { totalCount = 0 } }, + issues = { nodes = {}, pageInfo = { totalCount = 0 }, facets = { teams = {} } }, + activityLog = { nodes = {}, pageInfo = { totalCount = 0 }, facets = { resourceTypes = {} } }, + } } + end) +end + +Test.gql("Admin access does not imply membership", function(t) + t.addHeader("x-user-email", admin:email()) + t.query [[ + { + teams(filter: {teamScope: {memberOfViewer: true}}) { pageInfo { totalCount } } + issues(filter: {teamScope: {memberOfViewer: true}}) { pageInfo { totalCount } } + activityLog(filter: {teamScope: {memberOfViewer: true}}) { pageInfo { totalCount } } + explicit: issues(filter: {teamScope: {teamSlugs: ["outside"]}}) { pageInfo { totalCount } } + } + ]] + t.check { data = { + teams = { pageInfo = { totalCount = 0 } }, + issues = { pageInfo = { totalCount = 0 } }, + activityLog = { pageInfo = { totalCount = 0 } }, + explicit = { pageInfo = { totalCount = 1 } }, + } } +end) diff --git a/internal/activitylog/activitylogsql/activitylog.sql.go b/internal/activitylog/activitylogsql/activitylog.sql.go index b8861baa6..3448542c6 100644 --- a/internal/activitylog/activitylogsql/activitylog.sql.go +++ b/internal/activitylog/activitylogsql/activitylog.sql.go @@ -683,8 +683,12 @@ WHERE OR created_at >= $6::TIMESTAMPTZ ) AND ( - $7::TIMESTAMPTZ IS NULL - OR created_at < $7::TIMESTAMPTZ + $7::slug[] IS NULL + OR team_slug = ANY ($7::slug[]) + ) + AND ( + $8::TIMESTAMPTZ IS NULL + OR created_at < $8::TIMESTAMPTZ ) GROUP BY resource_type, @@ -703,6 +707,7 @@ type FacetsForTenantActivityTypesParams struct { FilterFrom pgtype.Timestamptz FilterTo pgtype.Timestamptz From pgtype.Timestamptz + TeamSlugs []slug.Slug To pgtype.Timestamptz } @@ -722,6 +727,7 @@ func (q *Queries) FacetsForTenantActivityTypes(ctx context.Context, arg FacetsFo arg.FilterFrom, arg.FilterTo, arg.From, + arg.TeamSlugs, arg.To, ) if err != nil { @@ -880,7 +886,8 @@ WHERE OR activity_log_combined_view.created_at < $7::TIMESTAMPTZ ) ORDER BY - activity_log_combined_view.created_at DESC + activity_log_combined_view.created_at DESC, + id DESC LIMIT $9 OFFSET @@ -977,7 +984,8 @@ WHERE OR created_at < $8::TIMESTAMPTZ ) ORDER BY - created_at DESC + created_at DESC, + id DESC LIMIT $10 OFFSET @@ -1077,7 +1085,8 @@ WHERE OR created_at < $9::TIMESTAMPTZ ) ORDER BY - created_at DESC + created_at DESC, + id DESC LIMIT $11 OFFSET @@ -1178,7 +1187,8 @@ WHERE OR created_at < $7::TIMESTAMPTZ ) ORDER BY - created_at DESC + created_at DESC, + id DESC LIMIT $9 OFFSET @@ -1305,7 +1315,8 @@ WHERE OR activity_log_combined_view.created_at < $6::TIMESTAMPTZ ) ORDER BY - activity_log_combined_view.created_at DESC + activity_log_combined_view.created_at DESC, + id DESC LIMIT $8 OFFSET @@ -1394,8 +1405,12 @@ WITH OR created_at >= $4::TIMESTAMPTZ ) AND ( - $5::TIMESTAMPTZ IS NULL - OR created_at < $5::TIMESTAMPTZ + $5::slug[] IS NULL + OR team_slug = ANY ($5::slug[]) + ) + AND ( + $6::TIMESTAMPTZ IS NULL + OR created_at < $6::TIMESTAMPTZ ) ) SELECT @@ -1422,15 +1437,20 @@ WHERE OR created_at >= $4::TIMESTAMPTZ ) AND ( - $5::TIMESTAMPTZ IS NULL - OR created_at < $5::TIMESTAMPTZ + $5::slug[] IS NULL + OR team_slug = ANY ($5::slug[]) + ) + AND ( + $6::TIMESTAMPTZ IS NULL + OR created_at < $6::TIMESTAMPTZ ) ORDER BY - created_at DESC + created_at DESC, + id DESC LIMIT - $7 + $8 OFFSET - $6 + $7 ` type ListForTenantParams struct { @@ -1438,6 +1458,7 @@ type ListForTenantParams struct { ResourceTypes []string Environments []string From pgtype.Timestamptz + TeamSlugs []slug.Slug To pgtype.Timestamptz Offset int32 Limit int32 @@ -1454,6 +1475,7 @@ func (q *Queries) ListForTenant(ctx context.Context, arg ListForTenantParams) ([ arg.ResourceTypes, arg.Environments, arg.From, + arg.TeamSlugs, arg.To, arg.Offset, arg.Limit, diff --git a/internal/activitylog/facets.go b/internal/activitylog/facets.go index 1201c1960..a23eb8fd2 100644 --- a/internal/activitylog/facets.go +++ b/internal/activitylog/facets.go @@ -20,6 +20,7 @@ func ComputeFacets(ctx context.Context, scope *ActivityLogScope, filter *Activit if scope != nil && scope.TenantWide { rows, err := q.FacetsForTenantActivityTypes(ctx, activitylogsql.FacetsForTenantActivityTypesParams{ + TeamSlugs: scope.TeamSlugs, From: from, To: to, Filter: filterValues, diff --git a/internal/activitylog/model.go b/internal/activitylog/model.go index 854447df3..965a043c1 100644 --- a/internal/activitylog/model.go +++ b/internal/activitylog/model.go @@ -13,6 +13,7 @@ import ( "github.com/nais/api/internal/graph/model" "github.com/nais/api/internal/graph/pagination" "github.com/nais/api/internal/slug" + "github.com/nais/api/internal/team/teamscope" ) type ( @@ -48,6 +49,7 @@ func (c *ActivityLogEntryConnection) GetFilter() *ActivityLogFilter { return c.f // ActivityLogScope defines the base scope for an activity log query. type ActivityLogScope struct { TeamSlug *slug.Slug + TeamSlugs []slug.Slug ResourceType *string ResourceName *string EnvironmentName *string @@ -133,6 +135,11 @@ type ActivityLogFilter struct { To *time.Time `json:"to,omitempty"` } +type TenantActivityLogFilter struct { + ActivityLogFilter + TeamScope *teamscope.TeamScopeInput `json:"teamScope,omitempty"` +} + type ActivityLogActivityType string func (e ActivityLogActivityType) IsValid() bool { diff --git a/internal/activitylog/queries.go b/internal/activitylog/queries.go index 28d551f1e..c56958174 100644 --- a/internal/activitylog/queries.go +++ b/internal/activitylog/queries.go @@ -130,17 +130,24 @@ func ListForTeam(ctx context.Context, teamSlug slug.Slug, page *pagination.Pagin }, nil } -func ListForTenant(ctx context.Context, page *pagination.Pagination, filter *ActivityLogFilter) (*ActivityLogEntryConnection, error) { +func ListForTenant(ctx context.Context, page *pagination.Pagination, filter *TenantActivityLogFilter) (*ActivityLogEntryConnection, error) { q := db(ctx) + var entryFilter *ActivityLogFilter + scope := &ActivityLogScope{TenantWide: true} + if filter != nil { + entryFilter = &filter.ActivityLogFilter + scope.TeamSlugs = filter.TeamScope.Resolve(ctx) + } ret, err := q.ListForTenant(ctx, activitylogsql.ListForTenantParams{ Offset: page.Offset(), Limit: page.Limit(), - Filter: withFilters(filter), - ResourceTypes: withResourceTypes(filter), - Environments: withEnvironments(filter), - From: withFrom(filter), - To: withTo(filter), + Filter: withFilters(entryFilter), + ResourceTypes: withResourceTypes(entryFilter), + Environments: withEnvironments(entryFilter), + From: withFrom(entryFilter), + To: withTo(entryFilter), + TeamSlugs: scope.TeamSlugs, }) if err != nil { return nil, err @@ -149,6 +156,14 @@ func ListForTenant(ctx context.Context, page *pagination.Pagination, filter *Act var total int64 if len(ret) > 0 { total = ret[0].TotalCount + } else { + facets, err := ComputeFacets(ctx, scope, entryFilter) + if err != nil { + return nil, err + } + for _, resourceType := range facets.ResourceTypes { + total += int64(resourceType.Count) + } } conn, err := pagination.NewConvertConnectionWithError(ret, page, total, func(from *activitylogsql.ListForTenantRow) (ActivityLogEntry, error) { @@ -160,8 +175,8 @@ func ListForTenant(ctx context.Context, page *pagination.Pagination, filter *Act return &ActivityLogEntryConnection{ Connection: *conn, - scope: &ActivityLogScope{TenantWide: true}, - filter: filter, + scope: scope, + filter: entryFilter, }, nil } diff --git a/internal/activitylog/queries/activitylog.sql b/internal/activitylog/queries/activitylog.sql index d194c0717..360fa674e 100644 --- a/internal/activitylog/queries/activitylog.sql +++ b/internal/activitylog/queries/activitylog.sql @@ -59,7 +59,8 @@ WHERE OR activity_log_combined_view.created_at < sqlc.narg('to')::TIMESTAMPTZ ) ORDER BY - activity_log_combined_view.created_at DESC + activity_log_combined_view.created_at DESC, + id DESC LIMIT sqlc.arg('limit') OFFSET @@ -90,6 +91,10 @@ WITH sqlc.narg('from')::TIMESTAMPTZ IS NULL OR created_at >= sqlc.narg('from')::TIMESTAMPTZ ) + AND ( + sqlc.narg('team_slugs')::slug[] IS NULL + OR team_slug = ANY (sqlc.narg('team_slugs')::slug[]) + ) AND ( sqlc.narg('to')::TIMESTAMPTZ IS NULL OR created_at < sqlc.narg('to')::TIMESTAMPTZ @@ -118,12 +123,17 @@ WHERE sqlc.narg('from')::TIMESTAMPTZ IS NULL OR created_at >= sqlc.narg('from')::TIMESTAMPTZ ) + AND ( + sqlc.narg('team_slugs')::slug[] IS NULL + OR team_slug = ANY (sqlc.narg('team_slugs')::slug[]) + ) AND ( sqlc.narg('to')::TIMESTAMPTZ IS NULL OR created_at < sqlc.narg('to')::TIMESTAMPTZ ) ORDER BY - created_at DESC + created_at DESC, + id DESC LIMIT sqlc.arg('limit') OFFSET @@ -193,7 +203,8 @@ WHERE OR activity_log_combined_view.created_at < sqlc.narg('to')::TIMESTAMPTZ ) ORDER BY - activity_log_combined_view.created_at DESC + activity_log_combined_view.created_at DESC, + id DESC LIMIT sqlc.arg('limit') OFFSET @@ -231,7 +242,8 @@ WHERE OR created_at < sqlc.narg('to')::TIMESTAMPTZ ) ORDER BY - created_at DESC + created_at DESC, + id DESC LIMIT sqlc.arg('limit') OFFSET @@ -269,7 +281,8 @@ WHERE OR created_at < sqlc.narg('to')::TIMESTAMPTZ ) ORDER BY - created_at DESC + created_at DESC, + id DESC LIMIT sqlc.arg('limit') OFFSET @@ -308,7 +321,8 @@ WHERE OR created_at < sqlc.narg('to')::TIMESTAMPTZ ) ORDER BY - created_at DESC + created_at DESC, + id DESC LIMIT sqlc.arg('limit') OFFSET @@ -654,6 +668,10 @@ WHERE sqlc.narg('from')::TIMESTAMPTZ IS NULL OR created_at >= sqlc.narg('from')::TIMESTAMPTZ ) + AND ( + sqlc.narg('team_slugs')::slug[] IS NULL + OR team_slug = ANY (sqlc.narg('team_slugs')::slug[]) + ) AND ( sqlc.narg('to')::TIMESTAMPTZ IS NULL OR created_at < sqlc.narg('to')::TIMESTAMPTZ diff --git a/internal/graph/activitylog.resolvers.go b/internal/graph/activitylog.resolvers.go index 899fe8b51..184e2c343 100644 --- a/internal/graph/activitylog.resolvers.go +++ b/internal/graph/activitylog.resolvers.go @@ -53,7 +53,7 @@ func (r *postgresResolver) ActivityLog(ctx context.Context, obj *postgres.Postgr ) } -func (r *queryResolver) ActivityLog(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *activitylog.ActivityLogFilter) (*activitylog.ActivityLogEntryConnection, error) { +func (r *queryResolver) ActivityLog(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *activitylog.TenantActivityLogFilter) (*activitylog.ActivityLogEntryConnection, error) { page, err := pagination.ParsePage(first, after, last, before) if err != nil { return nil, err diff --git a/internal/graph/filter_scope_test.go b/internal/graph/filter_scope_test.go new file mode 100644 index 000000000..a24ffdfb3 --- /dev/null +++ b/internal/graph/filter_scope_test.go @@ -0,0 +1,64 @@ +package graph + +import ( + "testing" + + "github.com/nais/api/internal/graph/gengql" + "github.com/vektah/gqlparser/v2" +) + +func TestTenantAndScopedFilters(t *testing.T) { + schema := gengql.NewExecutableSchema(gengql.Config{}).Schema() + for _, tt := range []struct { + name string + query string + wantError bool + }{ + { + name: "tenant filter variables", + query: `query($activity: TenantActivityLogFilter, $issues: TenantIssueFilter) { + activityLog(filter: $activity) { pageInfo { totalCount } } + issues(filter: $issues) { pageInfo { totalCount } } + }`, + }, + { + name: "existing team filter variables", + query: `query($activity: ActivityLogFilter, $issues: IssueFilter) { + team(slug: "team-a") { + activityLog(filter: $activity) { pageInfo { totalCount } } + issues(filter: $issues) { pageInfo { totalCount } } + } + }`, + }, + { + name: "activity logger interface keeps scoped filter", + query: `query($filter: ActivityLogFilter) { + node(id: "resource-id") { + ... on ActivityLogger { activityLog(filter: $filter) { pageInfo { totalCount } } } + } + }`, + }, + { + name: "team activity rejects team selection", + query: `{ team(slug: "team-a") { activityLog(filter: {teamScope: {teamSlugs: ["other"]}}) { __typename } } }`, + wantError: true, + }, + { + name: "team issues reject team selection", + query: `{ team(slug: "team-a") { issues(filter: {teamScope: {teamSlugs: ["other"]}}) { __typename } } }`, + wantError: true, + }, + { + name: "old root filter variable requires migration", + query: `query($filter: ActivityLogFilter) { activityLog(filter: $filter) { __typename } }`, + wantError: true, + }, + } { + t.Run(tt.name, func(t *testing.T) { + _, errors := gqlparser.LoadQuery(schema, tt.query) + if (len(errors) > 0) != tt.wantError { + t.Errorf("query validation errors = %v, wantError = %v", errors, tt.wantError) + } + }) + } +} diff --git a/internal/graph/gengql/activitylog.generated.go b/internal/graph/gengql/activitylog.generated.go index 847f00d13..c1f268ac2 100644 --- a/internal/graph/gengql/activitylog.generated.go +++ b/internal/graph/gengql/activitylog.generated.go @@ -487,6 +487,71 @@ func (ec *executionContext) unmarshalInputActivityLogFilter(ctx context.Context, return it, nil } +func (ec *executionContext) unmarshalInputTenantActivityLogFilter(ctx context.Context, obj any) (activitylog.TenantActivityLogFilter, error) { + var it activitylog.TenantActivityLogFilter + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"teamScope", "activityTypes", "resourceTypes", "environments", "from", "to"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "teamScope": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("teamScope")) + data, err := ec.unmarshalOTeamScopeInput2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋteamᚋteamscopeᚐTeamScopeInput(ctx, v) + if err != nil { + return it, err + } + it.TeamScope = data + case "activityTypes": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("activityTypes")) + data, err := ec.unmarshalOActivityLogActivityType2ᚕgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐActivityLogActivityTypeᚄ(ctx, v) + if err != nil { + return it, err + } + it.ActivityTypes = data + case "resourceTypes": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("resourceTypes")) + data, err := ec.unmarshalOActivityLogEntryResourceType2ᚕgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐActivityLogEntryResourceTypeᚄ(ctx, v) + if err != nil { + return it, err + } + it.ResourceTypes = data + case "environments": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("environments")) + data, err := ec.unmarshalOString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + it.Environments = data + case "from": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("from")) + data, err := ec.unmarshalOTime2ᚖtimeᚐTime(ctx, v) + if err != nil { + return it, err + } + it.From = data + case "to": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("to")) + data, err := ec.unmarshalOTime2ᚖtimeᚐTime(ctx, v) + if err != nil { + return it, err + } + it.To = data + } + } + return it, nil +} + // endregion **************************** input.gotpl ***************************** // region ************************** interface.gotpl *************************** @@ -1597,4 +1662,12 @@ func (ec *executionContext) unmarshalOActivityLogFilter2ᚖgithubᚗcomᚋnais return &res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalOTenantActivityLogFilter2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐTenantActivityLogFilter(ctx context.Context, v any) (*activitylog.TenantActivityLogFilter, error) { + if v == nil { + return nil, nil + } + res, err := ec.unmarshalInputTenantActivityLogFilter(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + // endregion ***************************** type.gotpl ***************************** diff --git a/internal/graph/gengql/complexity.go b/internal/graph/gengql/complexity.go index fdb7833e2..2f9054f08 100644 --- a/internal/graph/gengql/complexity.go +++ b/internal/graph/gengql/complexity.go @@ -149,7 +149,7 @@ func NewComplexityRoot() ComplexityRoot { c.PostgresBranch.Workloads = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int { return cursorComplexity(first, last) * childComplexity } - c.Query.ActivityLog = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *activitylog.ActivityLogFilter) int { + c.Query.ActivityLog = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *activitylog.TenantActivityLogFilter) int { return cursorComplexity(first, last) * childComplexity } c.Query.Cves = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *vulnerability.CVEOrder, filter *vulnerability.CVEFilter) int { @@ -158,6 +158,9 @@ func NewComplexityRoot() ComplexityRoot { c.Query.Deployments = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *deployment.DeploymentOrder, filter *deployment.DeploymentFilter) int { return cursorComplexity(first, last) * childComplexity } + c.Query.Issues = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *issue.IssueOrder, filter *issue.TenantIssueFilter) int { + return cursorComplexity(first, last) * childComplexity + } c.Query.Reconcilers = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int { return cursorComplexity(first, last) * childComplexity } diff --git a/internal/graph/gengql/issues.generated.go b/internal/graph/gengql/issues.generated.go index 99274df8a..45f37d773 100644 --- a/internal/graph/gengql/issues.generated.go +++ b/internal/graph/gengql/issues.generated.go @@ -64,6 +64,9 @@ type InvalidSpecIssueResolver interface { type IssueConnectionResolver interface { Facets(ctx context.Context, obj *issue.IssueConnection) (*issue.IssueFacets, error) } +type IssueTeamFacetItemResolver interface { + Team(ctx context.Context, obj *issue.IssueTeamFacetItem) (*team.Team, error) +} type LastRunFailedIssueResolver interface { TeamEnvironment(ctx context.Context, obj *issue.LastRunFailedIssue) (*team.TeamEnvironment, error) @@ -1251,6 +1254,38 @@ func (ec *executionContext) fieldContext_IssueEdge_node(_ context.Context, field return fc, nil } +func (ec *executionContext) _IssueFacets_teams(ctx context.Context, field graphql.CollectedField, obj *issue.IssueFacets) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_IssueFacets_teams(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Teams, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []issue.IssueTeamFacetItem) graphql.Marshaler { + return ec.marshalNIssueTeamFacetItem2ᚕgithubᚗcomᚋnaisᚋapiᚋinternalᚋissueᚐIssueTeamFacetItemᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_IssueFacets_teams(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "IssueFacets", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_IssueTeamFacetItem(ctx, field) + }, + } + return fc, nil +} + func (ec *executionContext) _IssueFacets_environments(ctx context.Context, field graphql.CollectedField, obj *issue.IssueFacets) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -1471,6 +1506,61 @@ func (ec *executionContext) fieldContext_IssueSeverityFacetItem_count(_ context. return graphql.NewScalarFieldContext("IssueSeverityFacetItem", field, false, false, errors.New("field of type Int does not have child fields")) } +func (ec *executionContext) _IssueTeamFacetItem_team(ctx context.Context, field graphql.CollectedField, obj *issue.IssueTeamFacetItem) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_IssueTeamFacetItem_team(ctx, field) + }, + func(ctx context.Context) (any, error) { + return ec.Resolvers.IssueTeamFacetItem().Team(ctx, obj) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *team.Team) graphql.Marshaler { + return ec.marshalNTeam2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋteamᚐTeam(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_IssueTeamFacetItem_team(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "IssueTeamFacetItem", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_Team(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _IssueTeamFacetItem_count(ctx context.Context, field graphql.CollectedField, obj *issue.IssueTeamFacetItem) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_IssueTeamFacetItem_count(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Count, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v int) graphql.Marshaler { + return ec.marshalNInt2int(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_IssueTeamFacetItem_count(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("IssueTeamFacetItem", field, false, false, errors.New("field of type Int does not have child fields")) +} + func (ec *executionContext) _IssueTypeFacetItem_issueType(ctx context.Context, field graphql.CollectedField, obj *issue.IssueTypeFacetItem) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -3369,6 +3459,71 @@ func (ec *executionContext) unmarshalInputResourceIssueFilter(ctx context.Contex return it, nil } +func (ec *executionContext) unmarshalInputTenantIssueFilter(ctx context.Context, obj any) (issue.TenantIssueFilter, error) { + var it issue.TenantIssueFilter + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"teamScope", "resourceName", "resourceType", "environments", "severity", "issueType"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "teamScope": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("teamScope")) + data, err := ec.unmarshalOTeamScopeInput2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋteamᚋteamscopeᚐTeamScopeInput(ctx, v) + if err != nil { + return it, err + } + it.TeamScope = data + case "resourceName": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("resourceName")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.ResourceName = data + case "resourceType": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("resourceType")) + data, err := ec.unmarshalOResourceType2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋissueᚐResourceType(ctx, v) + if err != nil { + return it, err + } + it.ResourceType = data + case "environments": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("environments")) + data, err := ec.unmarshalOString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + it.Environments = data + case "severity": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("severity")) + data, err := ec.unmarshalOSeverity2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋissueᚐSeverity(ctx, v) + if err != nil { + return it, err + } + it.Severity = data + case "issueType": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("issueType")) + data, err := ec.unmarshalOIssueType2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋissueᚐIssueType(ctx, v) + if err != nil { + return it, err + } + it.IssueType = data + } + } + return it, nil +} + // endregion **************************** input.gotpl ***************************** // region ************************** interface.gotpl *************************** @@ -4402,6 +4557,11 @@ func (ec *executionContext) _IssueFacets(ctx context.Context, sel ast.SelectionS switch field.Name { case "__typename": out.Values[i] = graphql.MarshalString("IssueFacets") + case "teams": + out.Values[i] = ec._IssueFacets_teams(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "environments": out.Values[i] = ec._IssueFacets_environments(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -4533,6 +4693,81 @@ func (ec *executionContext) _IssueSeverityFacetItem(ctx context.Context, sel ast return out } +var issueTeamFacetItemImplementors = []string{"IssueTeamFacetItem"} + +func (ec *executionContext) _IssueTeamFacetItem(ctx context.Context, sel ast.SelectionSet, obj *issue.IssueTeamFacetItem) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, issueTeamFacetItemImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("IssueTeamFacetItem") + case "team": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._IssueTeamFacetItem_team(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "count": + out.Values[i] = ec._IssueTeamFacetItem_count(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var issueTypeFacetItemImplementors = []string{"IssueTypeFacetItem"} func (ec *executionContext) _IssueTypeFacetItem(ctx context.Context, sel ast.SelectionSet, obj *issue.IssueTypeFacetItem) graphql.Marshaler { @@ -6071,6 +6306,26 @@ func (ec *executionContext) marshalNIssueSeverityFacetItem2ᚕgithubᚗcomᚋnai return ret } +func (ec *executionContext) marshalNIssueTeamFacetItem2githubᚗcomᚋnaisᚋapiᚋinternalᚋissueᚐIssueTeamFacetItem(ctx context.Context, sel ast.SelectionSet, v issue.IssueTeamFacetItem) graphql.Marshaler { + return ec._IssueTeamFacetItem(ctx, sel, &v) +} + +func (ec *executionContext) marshalNIssueTeamFacetItem2ᚕgithubᚗcomᚋnaisᚋapiᚋinternalᚋissueᚐIssueTeamFacetItemᚄ(ctx context.Context, sel ast.SelectionSet, v []issue.IssueTeamFacetItem) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNIssueTeamFacetItem2githubᚗcomᚋnaisᚋapiᚋinternalᚋissueᚐIssueTeamFacetItem(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + func (ec *executionContext) unmarshalNIssueType2githubᚗcomᚋnaisᚋapiᚋinternalᚋissueᚐIssueType(ctx context.Context, v any) (issue.IssueType, error) { var res issue.IssueType err := res.UnmarshalGQL(v) @@ -6210,4 +6465,12 @@ func (ec *executionContext) marshalOSeverity2ᚖgithubᚗcomᚋnaisᚋapiᚋinte return v } +func (ec *executionContext) unmarshalOTenantIssueFilter2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋissueᚐTenantIssueFilter(ctx context.Context, v any) (*issue.TenantIssueFilter, error) { + if v == nil { + return nil, nil + } + res, err := ec.unmarshalInputTenantIssueFilter(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + // endregion ***************************** type.gotpl ***************************** diff --git a/internal/graph/gengql/root_.generated.go b/internal/graph/gengql/root_.generated.go index 9afcb45c5..6c8b6a41c 100644 --- a/internal/graph/gengql/root_.generated.go +++ b/internal/graph/gengql/root_.generated.go @@ -90,6 +90,7 @@ type ResolverRoot interface { InstanceGroup() InstanceGroupResolver InvalidSpecIssue() InvalidSpecIssueResolver IssueConnection() IssueConnectionResolver + IssueTeamFacetItem() IssueTeamFacetItemResolver Job() JobResolver JobConnection() JobConnectionResolver JobRun() JobRunResolver @@ -1241,6 +1242,7 @@ type ComplexityRoot struct { IssueTypes func(childComplexity int) int ResourceTypes func(childComplexity int) int Severities func(childComplexity int) int + Teams func(childComplexity int) int } IssueResourceTypeFacetItem struct { @@ -1253,6 +1255,11 @@ type ComplexityRoot struct { Severity func(childComplexity int) int } + IssueTeamFacetItem struct { + Count func(childComplexity int) int + Team func(childComplexity int) int + } + IssueTypeFacetItem struct { Count func(childComplexity int) int IssueType func(childComplexity int) int @@ -2234,7 +2241,7 @@ type ComplexityRoot struct { } Query struct { - ActivityLog func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *activitylog.ActivityLogFilter) int + ActivityLog func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *activitylog.TenantActivityLogFilter) int CVE func(childComplexity int, identifier string) int CostMonthlySummary func(childComplexity int, from scalar.Date, to scalar.Date) int CurrentUnitPrices func(childComplexity int) int @@ -2244,6 +2251,7 @@ type ComplexityRoot struct { Environments func(childComplexity int, orderBy *environment.EnvironmentOrder) int Features func(childComplexity int) int ImageVulnerabilityHistory func(childComplexity int, from scalar.Date) int + Issues func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *issue.IssueOrder, filter *issue.TenantIssueFilter) int Me func(childComplexity int) int Node func(childComplexity int, id ident.Ident) int Reconcilers func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int @@ -2260,7 +2268,7 @@ type ComplexityRoot struct { Users func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *user.UserOrder) int VulnerabilityFixHistory func(childComplexity int, from scalar.Date) int VulnerabilitySummaries func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *vulnerability.TenantVulnerabilitySummaryFilter, orderBy *vulnerability.VulnerabilitySummaryOrder) int - VulnerabilitySummary func(childComplexity int) int + VulnerabilitySummary func(childComplexity int, filter *vulnerability.TenantVulnerabilitySummaryFilter) int } Reconciler struct { @@ -8525,6 +8533,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.IssueFacets.Severities(childComplexity), true + case "IssueFacets.teams": + if e.ComplexityRoot.IssueFacets.Teams == nil { + break + } + + return e.ComplexityRoot.IssueFacets.Teams(childComplexity), true + case "IssueResourceTypeFacetItem.count": if e.ComplexityRoot.IssueResourceTypeFacetItem.Count == nil { break @@ -8553,6 +8568,20 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.IssueSeverityFacetItem.Severity(childComplexity), true + case "IssueTeamFacetItem.count": + if e.ComplexityRoot.IssueTeamFacetItem.Count == nil { + break + } + + return e.ComplexityRoot.IssueTeamFacetItem.Count(childComplexity), true + + case "IssueTeamFacetItem.team": + if e.ComplexityRoot.IssueTeamFacetItem.Team == nil { + break + } + + return e.ComplexityRoot.IssueTeamFacetItem.Team(childComplexity), true + case "IssueTypeFacetItem.count": if e.ComplexityRoot.IssueTypeFacetItem.Count == nil { break @@ -13456,7 +13485,7 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return 0, false } - return e.ComplexityRoot.Query.ActivityLog(childComplexity, args["first"].(*int), args["after"].(*pagination.Cursor), args["last"].(*int), args["before"].(*pagination.Cursor), args["filter"].(*activitylog.ActivityLogFilter)), true + return e.ComplexityRoot.Query.ActivityLog(childComplexity, args["first"].(*int), args["after"].(*pagination.Cursor), args["last"].(*int), args["before"].(*pagination.Cursor), args["filter"].(*activitylog.TenantActivityLogFilter)), true case "Query.cve": if e.ComplexityRoot.Query.CVE == nil { @@ -13556,6 +13585,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.Query.ImageVulnerabilityHistory(childComplexity, args["from"].(scalar.Date)), true + case "Query.issues": + if e.ComplexityRoot.Query.Issues == nil { + break + } + + args, err := ec.field_Query_issues_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Query.Issues(childComplexity, args["first"].(*int), args["after"].(*pagination.Cursor), args["last"].(*int), args["before"].(*pagination.Cursor), args["orderBy"].(*issue.IssueOrder), args["filter"].(*issue.TenantIssueFilter)), true + case "Query.me": if e.ComplexityRoot.Query.Me == nil { break @@ -13743,7 +13784,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin break } - return e.ComplexityRoot.Query.VulnerabilitySummary(childComplexity), true + args, err := ec.field_Query_vulnerabilitySummary_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Query.VulnerabilitySummary(childComplexity, args["filter"].(*vulnerability.TenantVulnerabilitySummaryFilter)), true case "Reconciler.activityLog": if e.ComplexityRoot.Reconciler.ActivityLog == nil { @@ -22283,8 +22329,11 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputTeamOrder, ec.unmarshalInputTeamPostgresFilter, ec.unmarshalInputTeamRepositoryFilter, + ec.unmarshalInputTeamScopeInput, ec.unmarshalInputTeamVulnerabilitySummaryFilter, ec.unmarshalInputTeamWorkloadsFilter, + ec.unmarshalInputTenantActivityLogFilter, + ec.unmarshalInputTenantIssueFilter, ec.unmarshalInputTenantVulnerabilitySummaryFilter, ec.unmarshalInputTriggerJobInput, ec.unmarshalInputUpdateApplicationInput, @@ -22436,7 +22485,7 @@ var sources = []*ast.Source{ """ Filter items. """ - filter: ActivityLogFilter + filter: TenantActivityLogFilter ): ActivityLogEntryConnection! } @@ -22648,6 +22697,7 @@ extend type ServiceAccount implements ActivityLogger { ): ActivityLogEntryConnection! } +"Filtering options for activity log entries." input ActivityLogFilter { """ Only include entries whose activity type matches one of these values. @@ -22678,6 +22728,27 @@ input ActivityLogFilter { to: Time } +"Filtering options for activity log entries across teams." +input TenantActivityLogFilter { + "Filter by team selection. Excludes entries without a team." + teamScope: TeamScopeInput + + "Only include entries matching these activity types." + activityTypes: [ActivityLogActivityType!] + + "Only include entries matching these resource types." + resourceTypes: [ActivityLogEntryResourceType!] + + "Only include entries in these environments." + environments: [String!] + + "Only include entries created at or after this timestamp." + from: Time + + "Only include entries created before this timestamp." + to: Time +} + enum ActivityLogActivityType """ @@ -26279,7 +26350,32 @@ enum InstanceGroupValueSourceKind { NAIS } `, BuiltIn: false}, - {Name: "../schema/issues.graphqls", Input: `extend type Team { + {Name: "../schema/issues.graphqls", Input: `extend type Query { + """ + Issues across teams in the tenant, ordered by severity unless otherwise specified. + """ + issues( + "Get the first n items in the connection. This can be used in combination with the after parameter." + first: Int + + "Get items after this cursor." + after: Cursor + + "Get the last n items in the connection. This can be used in combination with the before parameter." + last: Int + + "Get items before this cursor." + before: Cursor + + "Ordering options for items returned from the connection." + orderBy: IssueOrder + + "Filtering options for items returned from the connection." + filter: TenantIssueFilter + ): IssueConnection! +} + +extend type Team { "Issues that affects the team." issues( "Get the first n items in the connection. This can be used in combination with the after parameter." @@ -26310,6 +26406,7 @@ input IssueOrder { direction: OrderDirection! } +"Filtering options for issues." input IssueFilter { "Filter by resource name." resourceName: String @@ -26327,6 +26424,27 @@ input IssueFilter { issueType: IssueType } +"Filtering options for issues across teams." +input TenantIssueFilter { + "Filter by team selection." + teamScope: TeamScopeInput + + "Filter by resource name." + resourceName: String + + "Filter by resource type." + resourceType: ResourceType + + "Filter by environment." + environments: [String!] + + "Filter by severity." + severity: Severity + + "Filter by issue type." + issueType: IssueType +} + input ResourceIssueFilter { "Filter by severity." severity: Severity @@ -26369,6 +26487,9 @@ type IssueConnection { Facets for issues, providing distribution counts across different dimensions. """ type IssueFacets { + "Up to 10 teams with matching issues, ordered by count descending, then team slug." + teams: [IssueTeamFacetItem!]! + """ Distribution of issues by environment. """ @@ -26390,6 +26511,17 @@ type IssueFacets { issueTypes: [IssueTypeFacetItem!]! } +""" +A single facet item for teams. +""" +type IssueTeamFacetItem { + "The team." + team: Team! + + "Number of matching issues." + count: Int! +} + """ A single facet item for issue severities. """ @@ -30431,10 +30563,15 @@ input SearchFilter { """ types: [SearchType!] + """ + Team selection. An empty query defaults to memberships when no selection is supplied. + """ + teamScope: TeamScopeInput + """ Teams to search in. If not specified, all teams will be searched. """ - teams: [Slug!] + teams: [Slug!] @deprecated(reason: "Use teamScope.teamSlugs instead.") } """ @@ -33834,10 +33971,31 @@ type TeamGoogleArtifactRegistry { repository: String! } +""" +Team selection. Supplied constraints are intersected without changing access permissions. +""" +input TeamScopeInput { + """ + Only include these teams. Omit to include any team; an empty list selects none. + """ + teamSlugs: [Slug!] + + """ + Only include the authenticated user's memberships when true. + Identities without memberships match no teams. False or omission adds no constraint. + """ + memberOfViewer: Boolean +} + """ Input for filtering teams. """ input TeamFilter { + """ + Filter by team selection. + """ + teamScope: TeamScopeInput + """ Filter teams by the existence of workloads. """ @@ -35626,7 +35784,10 @@ extend type Query { ): ImageVulnerabilityHistory! "Get the vulnerability summary for the tenant." - vulnerabilitySummary: TenantVulnerabilitySummary! + vulnerabilitySummary( + "Filter the aggregate." + filter: TenantVulnerabilitySummaryFilter + ): TenantVulnerabilitySummary! "List per-workload vulnerability summaries across all teams." vulnerabilitySummaries( @@ -35677,8 +35838,11 @@ extend type Query { ): CVEConnection! } -"Filter active CVEs by their calculated risk assessment." +"Filter active CVEs." input CVEFilter { + "Only include CVEs affecting the selected teams." + teamScope: TeamScopeInput + "Operational priority. URGENT requires workload context and currently returns no CVEs." priority: CVEPriority } @@ -35932,6 +36096,12 @@ input TeamVulnerabilitySummaryFilter { Input for filtering vulnerability summaries across all teams. """ input TenantVulnerabilitySummaryFilter { + "Filter by team selection." + teamScope: TeamScopeInput + + "Only include workloads in this environment." + environmentName: String + """ Only return vulnerability summaries whose highest priority is exactly one of the given values. URGENT is workload-contextual and cannot be resolved at @@ -36240,7 +36410,7 @@ type CVE implements Node { "Risk and threat intelligence signals used to prioritize the CVE." riskAssessment: VulnerabilityRiskAssessment! - "Affected workloads" + "Affected workloads within the team selection of the CVE list, when applicable." workloads( "Get the first n items in the connection. This can be used in combination with the after parameter." first: Int @@ -36254,7 +36424,7 @@ type CVE implements Node { "Get items before this cursor." before: Cursor - "Filter workloads. When teamSlugs is set only workloads belonging to those teams are returned." + "Further filter affected workloads." filter: CVEWorkloadsFilter "Order all matching findings before pagination. Defaults to team ascending." @@ -36296,10 +36466,16 @@ type CVEImpact { packageCount: Int! } -"Filter for workloads affected by a CVE. When teamSlugs is provided only workloads belonging to those teams are returned." +"Filter workloads affected by a CVE." input CVEWorkloadsFilter { - "Team identifiers used to limit results to workloads owned by those teams." - teamSlugs: [Slug!] + "Filter by team selection. Combined with teamSlugs, only teams matching both are included." + teamScope: TeamScopeInput + + "Only include workloads in this environment." + environmentName: String + + "Only include workloads owned by these teams." + teamSlugs: [Slug!] @deprecated(reason: "Use teamScope.teamSlugs instead.") } type WorkloadWithVulnerabilityConnection { @@ -39112,6 +39288,8 @@ func (ec *executionContext) childFields_IssueEdge(ctx context.Context, field gra func (ec *executionContext) childFields_IssueFacets(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { + case "teams": + return ec.fieldContext_IssueFacets_teams(ctx, field) case "environments": return ec.fieldContext_IssueFacets_environments(ctx, field) case "severities": @@ -39144,6 +39322,16 @@ func (ec *executionContext) childFields_IssueSeverityFacetItem(ctx context.Conte return nil, fmt.Errorf("no field named %q was found under type IssueSeverityFacetItem", field.Name) } +func (ec *executionContext) childFields_IssueTeamFacetItem(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "team": + return ec.fieldContext_IssueTeamFacetItem_team(ctx, field) + case "count": + return ec.fieldContext_IssueTeamFacetItem_count(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type IssueTeamFacetItem", field.Name) +} + func (ec *executionContext) childFields_IssueTypeFacetItem(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "issueType": diff --git a/internal/graph/gengql/schema.generated.go b/internal/graph/gengql/schema.generated.go index 06968cd9b..46e0b21c0 100644 --- a/internal/graph/gengql/schema.generated.go +++ b/internal/graph/gengql/schema.generated.go @@ -139,13 +139,14 @@ type MutationResolver interface { } type QueryResolver interface { Node(ctx context.Context, id ident.Ident) (model.Node, error) - ActivityLog(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *activitylog.ActivityLogFilter) (*activitylog.ActivityLogEntryConnection, error) + ActivityLog(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *activitylog.TenantActivityLogFilter) (*activitylog.ActivityLogEntryConnection, error) Roles(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *authz.RoleFilter) (*pagination.Connection[*authz.Role], error) CostMonthlySummary(ctx context.Context, from scalar.Date, to scalar.Date) (*cost.CostMonthlySummary, error) Deployments(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *deployment.DeploymentOrder, filter *deployment.DeploymentFilter) (*pagination.Connection[*deployment.Deployment], error) Environments(ctx context.Context, orderBy *environment.EnvironmentOrder) (*pagination.Connection[*environment.Environment], error) Environment(ctx context.Context, name string) (*environment.Environment, error) Features(ctx context.Context) (*feature.Features, error) + Issues(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *issue.IssueOrder, filter *issue.TenantIssueFilter) (*issue.IssueConnection, error) CurrentUnitPrices(ctx context.Context) (*price.CurrentUnitPrices, error) Reconcilers(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) (*pagination.Connection[*reconciler.Reconciler], error) Search(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter search.SearchFilter) (*pagination.Connection[search.SearchNode], error) @@ -160,7 +161,7 @@ type QueryResolver interface { UserSyncLog(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) (*pagination.Connection[usersync.UserSyncLogEntry], error) TeamsUtilization(ctx context.Context, resourceType utilization.UtilizationResourceType) ([]*utilization.TeamUtilizationData, error) ImageVulnerabilityHistory(ctx context.Context, from scalar.Date) (*vulnerability.ImageVulnerabilityHistory, error) - VulnerabilitySummary(ctx context.Context) (*vulnerability.TenantVulnerabilitySummary, error) + VulnerabilitySummary(ctx context.Context, filter *vulnerability.TenantVulnerabilitySummaryFilter) (*vulnerability.TenantVulnerabilitySummary, error) VulnerabilitySummaries(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *vulnerability.TenantVulnerabilitySummaryFilter, orderBy *vulnerability.VulnerabilitySummaryOrder) (*pagination.Connection[*vulnerability.WorkloadVulnerabilitySummary], error) VulnerabilityFixHistory(ctx context.Context, from scalar.Date) (*vulnerability.VulnerabilityFixHistory, error) CVE(ctx context.Context, identifier string) (*vulnerability.CVE, error) @@ -1219,8 +1220,8 @@ func (ec *executionContext) field_Query_activityLog_args(ctx context.Context, ra } args["before"] = arg3 arg4, err := graphql.ProcessArgField(ctx, rawArgs, "filter", - func(ctx context.Context, v any) (*activitylog.ActivityLogFilter, error) { - return ec.unmarshalOActivityLogFilter2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐActivityLogFilter(ctx, v) + func(ctx context.Context, v any) (*activitylog.TenantActivityLogFilter, error) { + return ec.unmarshalOTenantActivityLogFilter2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐTenantActivityLogFilter(ctx, v) }) if err != nil { return nil, err @@ -1415,6 +1416,60 @@ func (ec *executionContext) field_Query_imageVulnerabilityHistory_args(ctx conte return args, nil } +func (ec *executionContext) field_Query_issues_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "first", + func(ctx context.Context, v any) (*int, error) { + return ec.unmarshalOInt2ᚖint(ctx, v) + }) + if err != nil { + return nil, err + } + args["first"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "after", + func(ctx context.Context, v any) (*pagination.Cursor, error) { + return ec.unmarshalOCursor2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐCursor(ctx, v) + }) + if err != nil { + return nil, err + } + args["after"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "last", + func(ctx context.Context, v any) (*int, error) { + return ec.unmarshalOInt2ᚖint(ctx, v) + }) + if err != nil { + return nil, err + } + args["last"] = arg2 + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "before", + func(ctx context.Context, v any) (*pagination.Cursor, error) { + return ec.unmarshalOCursor2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐCursor(ctx, v) + }) + if err != nil { + return nil, err + } + args["before"] = arg3 + arg4, err := graphql.ProcessArgField(ctx, rawArgs, "orderBy", + func(ctx context.Context, v any) (*issue.IssueOrder, error) { + return ec.unmarshalOIssueOrder2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋissueᚐIssueOrder(ctx, v) + }) + if err != nil { + return nil, err + } + args["orderBy"] = arg4 + arg5, err := graphql.ProcessArgField(ctx, rawArgs, "filter", + func(ctx context.Context, v any) (*issue.TenantIssueFilter, error) { + return ec.unmarshalOTenantIssueFilter2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋissueᚐTenantIssueFilter(ctx, v) + }) + if err != nil { + return nil, err + } + args["filter"] = arg5 + return args, nil +} + func (ec *executionContext) field_Query_node_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1859,6 +1914,20 @@ func (ec *executionContext) field_Query_vulnerabilitySummaries_args(ctx context. return args, nil } +func (ec *executionContext) field_Query_vulnerabilitySummary_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "filter", + func(ctx context.Context, v any) (*vulnerability.TenantVulnerabilitySummaryFilter, error) { + return ec.unmarshalOTenantVulnerabilitySummaryFilter2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋvulnerabilityᚐTenantVulnerabilitySummaryFilter(ctx, v) + }) + if err != nil { + return nil, err + } + args["filter"] = arg0 + return args, nil +} + func (ec *executionContext) field_Subscription_log_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -5234,7 +5303,7 @@ func (ec *executionContext) _Query_activityLog(ctx context.Context, field graphq }, func(ctx context.Context) (any, error) { fc := graphql.GetFieldContext(ctx) - return ec.Resolvers.Query().ActivityLog(ctx, fc.Args["first"].(*int), fc.Args["after"].(*pagination.Cursor), fc.Args["last"].(*int), fc.Args["before"].(*pagination.Cursor), fc.Args["filter"].(*activitylog.ActivityLogFilter)) + return ec.Resolvers.Query().ActivityLog(ctx, fc.Args["first"].(*int), fc.Args["after"].(*pagination.Cursor), fc.Args["last"].(*int), fc.Args["before"].(*pagination.Cursor), fc.Args["filter"].(*activitylog.TenantActivityLogFilter)) }, nil, func(ctx context.Context, selections ast.SelectionSet, v *activitylog.ActivityLogEntryConnection) graphql.Marshaler { @@ -5520,6 +5589,50 @@ func (ec *executionContext) fieldContext_Query_features(_ context.Context, field return fc, nil } +func (ec *executionContext) _Query_issues(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Query_issues(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().Issues(ctx, fc.Args["first"].(*int), fc.Args["after"].(*pagination.Cursor), fc.Args["last"].(*int), fc.Args["before"].(*pagination.Cursor), fc.Args["orderBy"].(*issue.IssueOrder), fc.Args["filter"].(*issue.TenantIssueFilter)) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *issue.IssueConnection) graphql.Marshaler { + return ec.marshalNIssueConnection2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋissueᚐIssueConnection(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Query_issues(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_IssueConnection(ctx, field) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_issues_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Query_currentUnitPrices(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -6100,7 +6213,8 @@ func (ec *executionContext) _Query_vulnerabilitySummary(ctx context.Context, fie return ec.fieldContext_Query_vulnerabilitySummary(ctx, field) }, func(ctx context.Context) (any, error) { - return ec.Resolvers.Query().VulnerabilitySummary(ctx) + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().VulnerabilitySummary(ctx, fc.Args["filter"].(*vulnerability.TenantVulnerabilitySummaryFilter)) }, nil, func(ctx context.Context, selections ast.SelectionSet, v *vulnerability.TenantVulnerabilitySummary) graphql.Marshaler { @@ -6110,7 +6224,7 @@ func (ec *executionContext) _Query_vulnerabilitySummary(ctx context.Context, fie true, ) } -func (ec *executionContext) fieldContext_Query_vulnerabilitySummary(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Query_vulnerabilitySummary(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Query", Field: field, @@ -6120,6 +6234,17 @@ func (ec *executionContext) fieldContext_Query_vulnerabilitySummary(_ context.Co return ec.childFields_TenantVulnerabilitySummary(ctx, field) }, } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_vulnerabilitySummary_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } return fc, nil } @@ -8361,6 +8486,28 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "issues": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_issues(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "currentUnitPrices": field := field diff --git a/internal/graph/gengql/search.generated.go b/internal/graph/gengql/search.generated.go index f18a0c873..3d55ceb5a 100644 --- a/internal/graph/gengql/search.generated.go +++ b/internal/graph/gengql/search.generated.go @@ -188,7 +188,7 @@ func (ec *executionContext) unmarshalInputSearchFilter(ctx context.Context, obj asMap[k] = v } - fieldsInOrder := [...]string{"query", "types", "teams"} + fieldsInOrder := [...]string{"query", "types", "teamScope", "teams"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -209,6 +209,13 @@ func (ec *executionContext) unmarshalInputSearchFilter(ctx context.Context, obj return it, err } it.Types = data + case "teamScope": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("teamScope")) + data, err := ec.unmarshalOTeamScopeInput2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋteamᚋteamscopeᚐTeamScopeInput(ctx, v) + if err != nil { + return it, err + } + it.TeamScope = data case "teams": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("teams")) data, err := ec.unmarshalOSlug2ᚕgithubᚗcomᚋnaisᚋapiᚋinternalᚋslugᚐSlugᚄ(ctx, v) diff --git a/internal/graph/gengql/teams.generated.go b/internal/graph/gengql/teams.generated.go index d3e17f8aa..71ce57abb 100644 --- a/internal/graph/gengql/teams.generated.go +++ b/internal/graph/gengql/teams.generated.go @@ -32,6 +32,7 @@ import ( "github.com/nais/api/internal/serviceaccount" "github.com/nais/api/internal/slug" "github.com/nais/api/internal/team" + "github.com/nais/api/internal/team/teamscope" "github.com/nais/api/internal/tunnel" "github.com/nais/api/internal/unleash" "github.com/nais/api/internal/user" @@ -7947,13 +7948,20 @@ func (ec *executionContext) unmarshalInputTeamFilter(ctx context.Context, obj an asMap[k] = v } - fieldsInOrder := [...]string{"hasWorkloads", "vulnerabilityPriorityGroup"} + fieldsInOrder := [...]string{"teamScope", "hasWorkloads", "vulnerabilityPriorityGroup"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { continue } switch k { + case "teamScope": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("teamScope")) + data, err := ec.unmarshalOTeamScopeInput2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋteamᚋteamscopeᚐTeamScopeInput(ctx, v) + if err != nil { + return it, err + } + it.TeamScope = data case "hasWorkloads": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("hasWorkloads")) data, err := ec.unmarshalOBoolean2ᚖbool(ctx, v) @@ -8047,6 +8055,43 @@ func (ec *executionContext) unmarshalInputTeamOrder(ctx context.Context, obj any return it, nil } +func (ec *executionContext) unmarshalInputTeamScopeInput(ctx context.Context, obj any) (teamscope.TeamScopeInput, error) { + var it teamscope.TeamScopeInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"teamSlugs", "memberOfViewer"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "teamSlugs": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("teamSlugs")) + data, err := ec.unmarshalOSlug2ᚕgithubᚗcomᚋnaisᚋapiᚋinternalᚋslugᚐSlugᚄ(ctx, v) + if err != nil { + return it, err + } + it.TeamSlugs = data + case "memberOfViewer": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("memberOfViewer")) + data, err := ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } + it.MemberOfViewer = data + } + } + return it, nil +} + func (ec *executionContext) unmarshalInputUpdateTeamEnvironmentInput(ctx context.Context, obj any) (team.UpdateTeamEnvironmentInput, error) { var it team.UpdateTeamEnvironmentInput if obj == nil { @@ -13117,4 +13162,12 @@ func (ec *executionContext) unmarshalOTeamOrder2ᚖgithubᚗcomᚋnaisᚋapiᚋi return &res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalOTeamScopeInput2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋteamᚋteamscopeᚐTeamScopeInput(ctx context.Context, v any) (*teamscope.TeamScopeInput, error) { + if v == nil { + return nil, nil + } + res, err := ec.unmarshalInputTeamScopeInput(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + // endregion ***************************** type.gotpl ***************************** diff --git a/internal/graph/gengql/vulnerability.generated.go b/internal/graph/gengql/vulnerability.generated.go index 15ca07637..076aec40a 100644 --- a/internal/graph/gengql/vulnerability.generated.go +++ b/internal/graph/gengql/vulnerability.generated.go @@ -4127,13 +4127,20 @@ func (ec *executionContext) unmarshalInputCVEFilter(ctx context.Context, obj any asMap[k] = v } - fieldsInOrder := [...]string{"priority"} + fieldsInOrder := [...]string{"teamScope", "priority"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { continue } switch k { + case "teamScope": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("teamScope")) + data, err := ec.unmarshalOTeamScopeInput2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋteamᚋteamscopeᚐTeamScopeInput(ctx, v) + if err != nil { + return it, err + } + it.TeamScope = data case "priority": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("priority")) data, err := ec.unmarshalOCVEPriority2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋvulnerabilityᚐCVEPriority(ctx, v) @@ -4231,13 +4238,27 @@ func (ec *executionContext) unmarshalInputCVEWorkloadsFilter(ctx context.Context asMap[k] = v } - fieldsInOrder := [...]string{"teamSlugs"} + fieldsInOrder := [...]string{"teamScope", "environmentName", "teamSlugs"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { continue } switch k { + case "teamScope": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("teamScope")) + data, err := ec.unmarshalOTeamScopeInput2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋteamᚋteamscopeᚐTeamScopeInput(ctx, v) + if err != nil { + return it, err + } + it.TeamScope = data + case "environmentName": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("environmentName")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.EnvironmentName = data case "teamSlugs": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("teamSlugs")) data, err := ec.unmarshalOSlug2ᚕgithubᚗcomᚋnaisᚋapiᚋinternalᚋslugᚐSlugᚄ(ctx, v) @@ -4407,13 +4428,27 @@ func (ec *executionContext) unmarshalInputTenantVulnerabilitySummaryFilter(ctx c asMap[k] = v } - fieldsInOrder := [...]string{"priorities", "hasKevEntry"} + fieldsInOrder := [...]string{"teamScope", "environmentName", "priorities", "hasKevEntry"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { continue } switch k { + case "teamScope": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("teamScope")) + data, err := ec.unmarshalOTeamScopeInput2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋteamᚋteamscopeᚐTeamScopeInput(ctx, v) + if err != nil { + return it, err + } + it.TeamScope = data + case "environmentName": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("environmentName")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.EnvironmentName = data case "priorities": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("priorities")) data, err := ec.unmarshalOCVEPriority2ᚕgithubᚗcomᚋnaisᚋapiᚋinternalᚋvulnerabilityᚐCVEPriorityᚄ(ctx, v) diff --git a/internal/graph/issues.resolvers.go b/internal/graph/issues.resolvers.go index 4c2ba3813..36afc97e3 100644 --- a/internal/graph/issues.resolvers.go +++ b/internal/graph/issues.resolvers.go @@ -70,7 +70,11 @@ func (r *invalidSpecIssueResolver) Workload(ctx context.Context, obj *issue.Inva } func (r *issueConnectionResolver) Facets(ctx context.Context, obj *issue.IssueConnection) (*issue.IssueFacets, error) { - return issue.ComputeFacets(ctx, obj.GetTeamSlug(), obj.GetScope(), obj.GetFilter()) + return issue.ComputeFacets(ctx, obj) +} + +func (r *issueTeamFacetItemResolver) Team(ctx context.Context, obj *issue.IssueTeamFacetItem) (*team.Team, error) { + return team.Get(ctx, obj.TeamSlug) } func (r *lastRunFailedIssueResolver) TeamEnvironment(ctx context.Context, obj *issue.LastRunFailedIssue) (*team.TeamEnvironment, error) { @@ -113,6 +117,14 @@ func (r *postgresBranchIssueResolver) Branch(ctx context.Context, obj *issue.Pos return postgres.GetPostgresBranchByObjectName(ctx, obj.TeamSlug, obj.EnvironmentName, obj.ResourceName) } +func (r *queryResolver) Issues(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *issue.IssueOrder, filter *issue.TenantIssueFilter) (*issue.IssueConnection, error) { + page, err := pagination.ParsePage(first, after, last, before) + if err != nil { + return nil, err + } + return issue.ListForTenant(ctx, page, orderBy, filter) +} + func (r *sqlInstanceStateIssueResolver) TeamEnvironment(ctx context.Context, obj *issue.SqlInstanceStateIssue) (*team.TeamEnvironment, error) { return team.GetTeamEnvironment(ctx, obj.TeamSlug, obj.EnvironmentName) } @@ -198,6 +210,10 @@ func (r *Resolver) IssueConnection() gengql.IssueConnectionResolver { return &issueConnectionResolver{r} } +func (r *Resolver) IssueTeamFacetItem() gengql.IssueTeamFacetItemResolver { + return &issueTeamFacetItemResolver{r} +} + func (r *Resolver) LastRunFailedIssue() gengql.LastRunFailedIssueResolver { return &lastRunFailedIssueResolver{r} } @@ -248,6 +264,7 @@ type ( failedSynchronizationIssueResolver struct{ *Resolver } invalidSpecIssueResolver struct{ *Resolver } issueConnectionResolver struct{ *Resolver } + issueTeamFacetItemResolver struct{ *Resolver } lastRunFailedIssueResolver struct{ *Resolver } missingSbomIssueResolver struct{ *Resolver } noRunningInstancesIssueResolver struct{ *Resolver } diff --git a/internal/graph/schema/activitylog.graphqls b/internal/graph/schema/activitylog.graphqls index ff10695ef..b1a5c046a 100644 --- a/internal/graph/schema/activitylog.graphqls +++ b/internal/graph/schema/activitylog.graphqls @@ -26,7 +26,7 @@ extend type Query { """ Filter items. """ - filter: ActivityLogFilter + filter: TenantActivityLogFilter ): ActivityLogEntryConnection! } @@ -238,6 +238,7 @@ extend type ServiceAccount implements ActivityLogger { ): ActivityLogEntryConnection! } +"Filtering options for activity log entries." input ActivityLogFilter { """ Only include entries whose activity type matches one of these values. @@ -268,6 +269,27 @@ input ActivityLogFilter { to: Time } +"Filtering options for activity log entries across teams." +input TenantActivityLogFilter { + "Filter by team selection. Excludes entries without a team." + teamScope: TeamScopeInput + + "Only include entries matching these activity types." + activityTypes: [ActivityLogActivityType!] + + "Only include entries matching these resource types." + resourceTypes: [ActivityLogEntryResourceType!] + + "Only include entries in these environments." + environments: [String!] + + "Only include entries created at or after this timestamp." + from: Time + + "Only include entries created before this timestamp." + to: Time +} + enum ActivityLogActivityType """ diff --git a/internal/graph/schema/issues.graphqls b/internal/graph/schema/issues.graphqls index d6438dc62..969e6a8b4 100644 --- a/internal/graph/schema/issues.graphqls +++ b/internal/graph/schema/issues.graphqls @@ -1,3 +1,28 @@ +extend type Query { + """ + Issues across teams in the tenant, ordered by severity unless otherwise specified. + """ + issues( + "Get the first n items in the connection. This can be used in combination with the after parameter." + first: Int + + "Get items after this cursor." + after: Cursor + + "Get the last n items in the connection. This can be used in combination with the before parameter." + last: Int + + "Get items before this cursor." + before: Cursor + + "Ordering options for items returned from the connection." + orderBy: IssueOrder + + "Filtering options for items returned from the connection." + filter: TenantIssueFilter + ): IssueConnection! +} + extend type Team { "Issues that affects the team." issues( @@ -29,6 +54,7 @@ input IssueOrder { direction: OrderDirection! } +"Filtering options for issues." input IssueFilter { "Filter by resource name." resourceName: String @@ -46,6 +72,27 @@ input IssueFilter { issueType: IssueType } +"Filtering options for issues across teams." +input TenantIssueFilter { + "Filter by team selection." + teamScope: TeamScopeInput + + "Filter by resource name." + resourceName: String + + "Filter by resource type." + resourceType: ResourceType + + "Filter by environment." + environments: [String!] + + "Filter by severity." + severity: Severity + + "Filter by issue type." + issueType: IssueType +} + input ResourceIssueFilter { "Filter by severity." severity: Severity @@ -88,6 +135,9 @@ type IssueConnection { Facets for issues, providing distribution counts across different dimensions. """ type IssueFacets { + "Up to 10 teams with matching issues, ordered by count descending, then team slug." + teams: [IssueTeamFacetItem!]! + """ Distribution of issues by environment. """ @@ -109,6 +159,17 @@ type IssueFacets { issueTypes: [IssueTypeFacetItem!]! } +""" +A single facet item for teams. +""" +type IssueTeamFacetItem { + "The team." + team: Team! + + "Number of matching issues." + count: Int! +} + """ A single facet item for issue severities. """ diff --git a/internal/graph/schema/search.graphqls b/internal/graph/schema/search.graphqls index 2d9da18eb..56ea94e13 100644 --- a/internal/graph/schema/search.graphqls +++ b/internal/graph/schema/search.graphqls @@ -49,10 +49,15 @@ input SearchFilter { """ types: [SearchType!] + """ + Team selection. An empty query defaults to memberships when no selection is supplied. + """ + teamScope: TeamScopeInput + """ Teams to search in. If not specified, all teams will be searched. """ - teams: [Slug!] + teams: [Slug!] @deprecated(reason: "Use teamScope.teamSlugs instead.") } """ diff --git a/internal/graph/schema/teams.graphqls b/internal/graph/schema/teams.graphqls index 78daaa854..7306f7f75 100644 --- a/internal/graph/schema/teams.graphqls +++ b/internal/graph/schema/teams.graphqls @@ -797,10 +797,31 @@ type TeamGoogleArtifactRegistry { repository: String! } +""" +Team selection. Supplied constraints are intersected without changing access permissions. +""" +input TeamScopeInput { + """ + Only include these teams. Omit to include any team; an empty list selects none. + """ + teamSlugs: [Slug!] + + """ + Only include the authenticated user's memberships when true. + Identities without memberships match no teams. False or omission adds no constraint. + """ + memberOfViewer: Boolean +} + """ Input for filtering teams. """ input TeamFilter { + """ + Filter by team selection. + """ + teamScope: TeamScopeInput + """ Filter teams by the existence of workloads. """ diff --git a/internal/graph/schema/vulnerability.graphqls b/internal/graph/schema/vulnerability.graphqls index e4f69497e..8dde89a24 100644 --- a/internal/graph/schema/vulnerability.graphqls +++ b/internal/graph/schema/vulnerability.graphqls @@ -14,7 +14,10 @@ extend type Query { ): ImageVulnerabilityHistory! "Get the vulnerability summary for the tenant." - vulnerabilitySummary: TenantVulnerabilitySummary! + vulnerabilitySummary( + "Filter the aggregate." + filter: TenantVulnerabilitySummaryFilter + ): TenantVulnerabilitySummary! "List per-workload vulnerability summaries across all teams." vulnerabilitySummaries( @@ -65,8 +68,11 @@ extend type Query { ): CVEConnection! } -"Filter active CVEs by their calculated risk assessment." +"Filter active CVEs." input CVEFilter { + "Only include CVEs affecting the selected teams." + teamScope: TeamScopeInput + "Operational priority. URGENT requires workload context and currently returns no CVEs." priority: CVEPriority } @@ -320,6 +326,12 @@ input TeamVulnerabilitySummaryFilter { Input for filtering vulnerability summaries across all teams. """ input TenantVulnerabilitySummaryFilter { + "Filter by team selection." + teamScope: TeamScopeInput + + "Only include workloads in this environment." + environmentName: String + """ Only return vulnerability summaries whose highest priority is exactly one of the given values. URGENT is workload-contextual and cannot be resolved at @@ -628,7 +640,7 @@ type CVE implements Node { "Risk and threat intelligence signals used to prioritize the CVE." riskAssessment: VulnerabilityRiskAssessment! - "Affected workloads" + "Affected workloads within the team selection of the CVE list, when applicable." workloads( "Get the first n items in the connection. This can be used in combination with the after parameter." first: Int @@ -642,7 +654,7 @@ type CVE implements Node { "Get items before this cursor." before: Cursor - "Filter workloads. When teamSlugs is set only workloads belonging to those teams are returned." + "Further filter affected workloads." filter: CVEWorkloadsFilter "Order all matching findings before pagination. Defaults to team ascending." @@ -684,10 +696,16 @@ type CVEImpact { packageCount: Int! } -"Filter for workloads affected by a CVE. When teamSlugs is provided only workloads belonging to those teams are returned." +"Filter workloads affected by a CVE." input CVEWorkloadsFilter { - "Team identifiers used to limit results to workloads owned by those teams." - teamSlugs: [Slug!] + "Filter by team selection. Combined with teamSlugs, only teams matching both are included." + teamScope: TeamScopeInput + + "Only include workloads in this environment." + environmentName: String + + "Only include workloads owned by these teams." + teamSlugs: [Slug!] @deprecated(reason: "Use teamScope.teamSlugs instead.") } type WorkloadWithVulnerabilityConnection { diff --git a/internal/graph/vulnerability.resolvers.go b/internal/graph/vulnerability.resolvers.go index 12af8af8e..109a2f9fd 100644 --- a/internal/graph/vulnerability.resolvers.go +++ b/internal/graph/vulnerability.resolvers.go @@ -26,7 +26,7 @@ func (r *applicationResolver) VulnerabilityFixHistory(ctx context.Context, obj * } func (r *cVEResolver) Workloads(ctx context.Context, obj *vulnerability.CVE, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *vulnerability.CVEWorkloadsFilter, orderBy *vulnerability.CVEWorkloadOrder) (*vulnerability.WorkloadWithVulnerabilityConnection, error) { - if obj.AffectedWorkloads != nil && (filter == nil || len(filter.TeamSlugs) == 0) && onlyAsksForPageInfoTotalCount(ctx) { + if obj.AffectedWorkloads != nil && (filter == nil || (len(filter.TeamSlugs) == 0 && filter.TeamScope == nil && filter.EnvironmentName == nil)) && onlyAsksForPageInfoTotalCount(ctx) { return &vulnerability.WorkloadWithVulnerabilityConnection{ Connection: pagination.NewTotalCountOnlyConnection[*vulnerability.WorkloadWithVulnerability](*obj.AffectedWorkloads), }, nil @@ -37,7 +37,7 @@ func (r *cVEResolver) Workloads(ctx context.Context, obj *vulnerability.CVE, fir return nil, err } - return vulnerability.GetWorkloadsByCVE(ctx, obj.Identifier, page, filter, orderBy) + return vulnerability.GetWorkloadsByCVE(ctx, obj, page, filter, orderBy) } func (r *containerImageResolver) Sbom(ctx context.Context, obj *workload.ContainerImage) (*vulnerability.ContainerImageSBOM, error) { @@ -115,8 +115,8 @@ func (r *queryResolver) ImageVulnerabilityHistory(ctx context.Context, from scal return vulnerability.GetWorkloadVulnerabilityHistoryForTenant(ctx, from.Time()) } -func (r *queryResolver) VulnerabilitySummary(ctx context.Context) (*vulnerability.TenantVulnerabilitySummary, error) { - return vulnerability.GetTenantVulnerabilitySummary(ctx) +func (r *queryResolver) VulnerabilitySummary(ctx context.Context, filter *vulnerability.TenantVulnerabilitySummaryFilter) (*vulnerability.TenantVulnerabilitySummary, error) { + return vulnerability.GetTenantVulnerabilitySummary(ctx, filter) } func (r *queryResolver) VulnerabilitySummaries(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *vulnerability.TenantVulnerabilitySummaryFilter, orderBy *vulnerability.VulnerabilitySummaryOrder) (*pagination.Connection[*vulnerability.WorkloadVulnerabilitySummary], error) { diff --git a/internal/graph/vulnerability.resolvers_test.go b/internal/graph/vulnerability.resolvers_test.go index d08c0be1c..55ab825af 100644 --- a/internal/graph/vulnerability.resolvers_test.go +++ b/internal/graph/vulnerability.resolvers_test.go @@ -215,6 +215,46 @@ func TestCVEWorkloadsCountOnlySelectionUsesPrecomputedCount(t *testing.T) { } } +func TestCVEWorkloadsInheritRootTeamScope(t *testing.T) { + for _, tt := range []struct { + name string + filter string + selection string + namespaces []string + total int + calls int32 + }{ + {"cached count", "", "pageInfo { totalCount }", nil, 3, 0}, + {"full connection", "", "pageInfo { totalCount } impact { workloadCount }", []string{"team-a", "team-b"}, 3, 1}, + {"narrowed", `filter: {teamScope: {teamSlugs: ["team-b", "outside"]}}`, "pageInfo { totalCount }", []string{"team-b"}, 2, 1}, + {"unrestricted child", `filter: {teamScope: {}}`, "pageInfo { totalCount }", []string{"team-a", "team-b"}, 3, 1}, + {"legacy narrowed", `filter: {teamSlugs: ["team-b", "outside"]}`, "pageInfo { totalCount }", []string{"team-b"}, 2, 1}, + {"disjoint", `filter: {teamScope: {teamSlugs: ["outside"]}}`, "pageInfo { totalCount }", nil, 0, 0}, + {"empty", `filter: {teamScope: {teamSlugs: []}}`, "pageInfo { totalCount }", nil, 0, 0}, + } { + t.Run(tt.name, func(t *testing.T) { + client := &cveAliasClient{ + nodes: []*vulnerabilities.CveSummary{{ + Cve: &vulnerabilities.Cve{Id: "CVE-scoped"}, AffectedWorkloads: 3, + }}, + workloadTotal: 17, scopedWorkloadTotal: 2, scopeNamespaces: tt.namespaces, + } + if len(tt.namespaces) == 2 { + client.scopedWorkloadTotal = 3 + } + body := executeCVEQuery(t, client, fmt.Sprintf(`{ + cves(filter: {teamScope: {teamSlugs: ["team-a", "team-b"]}}) { + edges { node { workloads(%s) { %s } } } + } + }`, "first: 1 "+tt.filter, tt.selection)) + got := decodeCVEWorkloadResponse(t, body).Data["cves"].Edges[0].Node.Workloads.PageInfo.TotalCount + if got != tt.total || client.workloadCalls.Load() != tt.calls { + t.Fatalf("total=%d, calls=%d, want total=%d, calls=%d: %s", got, client.workloadCalls.Load(), tt.total, tt.calls, body) + } + }) + } +} + func TestCVEWorkloadsCountOnlySelectionWithAliasesAndFragments(t *testing.T) { client := &cveAliasClient{nodes: []*vulnerabilities.CveSummary{ {Cve: &vulnerabilities.Cve{Id: "CVE-high", Priority: vulnerabilities.Priority_PRIORITY_HIGH}, AffectedWorkloads: 1}, @@ -354,6 +394,38 @@ func TestCVEWorkloadsTeamFilterUsesScopedFallbackCount(t *testing.T) { } } +func TestCVEWorkloadScopesBypassPrecomputedCount(t *testing.T) { + for _, tt := range []struct { + filter string + total int + calls int32 + }{ + {`teamScope: {teamSlugs: ["team-a"]}`, 2, 1}, + {`teamScope: {teamSlugs: []}`, 0, 0}, + {`teamScope: {memberOfViewer: true}`, 0, 0}, + {`environmentName: "dev"`, 17, 1}, + } { + t.Run(tt.filter, func(t *testing.T) { + client := &cveAliasClient{ + nodes: []*vulnerabilities.CveSummary{{ + Cve: &vulnerabilities.Cve{Id: "CVE-high"}, AffectedWorkloads: 99, + }}, + workloadTotal: 17, scopedWorkloadTotal: 2, scopeNamespaces: []string{"team-a"}, + } + body := executeCVEQuery(t, client, fmt.Sprintf(`{ + cves(first: 1) { edges { node { workloads(filter: {%s}) { pageInfo { totalCount } } } } } + }`, tt.filter)) + result := decodeCVEWorkloadResponse(t, body) + if got := result.Data["cves"].Edges[0].Node.Workloads.PageInfo.TotalCount; got != tt.total { + t.Fatalf("total=%d, want %d: %s", got, tt.total, body) + } + if got := client.workloadCalls.Load(); got != tt.calls { + t.Errorf("workload RPCs=%d, want %d", got, tt.calls) + } + }) + } +} + func TestCVEWorkloadsCountOnlyKeepsAffectedWorkloadCountWithRepeatedFindingsAndAliases(t *testing.T) { canonicalCVEID := "CVE-2025-1234" aliasCVEID := "GHSA-xxxx-yyyy-zzzz" diff --git a/internal/issue/facets.go b/internal/issue/facets.go index 4815ae968..852f11432 100644 --- a/internal/issue/facets.go +++ b/internal/issue/facets.go @@ -10,10 +10,12 @@ import ( "github.com/nais/api/internal/slug" ) -func ComputeFacets(ctx context.Context, teamSlug slug.Slug, scope *IssueScope, filter *IssueFilter) (*IssueFacets, error) { +func ComputeFacets(ctx context.Context, connection *IssueConnection) (*IssueFacets, error) { params := issuesql.FacetsForIssuesParams{ - Team: teamSlug.String(), + Team: connection.teamSlug.String(), + TeamSlugs: connection.teamSlugs, } + scope, filter := connection.scope, connection.filter if scope != nil { params.ScopeResourceName = &scope.ResourceName @@ -48,6 +50,7 @@ func buildFacets(rows []*issuesql.FacetsForIssuesRow) *IssueFacets { resourceTypeCounts := map[ResourceType]int{} environmentCounts := map[string]int{} issueTypeCounts := map[IssueType]int{} + teamCounts := map[slug.Slug]int{} for _, row := range rows { sev := Severity(row.Severity) @@ -68,6 +71,9 @@ func buildFacets(rows []*issuesql.FacetsForIssuesRow) *IssueFacets { } filtered := int(row.FilteredCount) + if filtered > 0 { + teamCounts[slug.Slug(row.Team)] += filtered + } severityCounts[sev] += filtered resourceTypeCounts[rt] += filtered environmentCounts[row.Env] += filtered @@ -104,7 +110,20 @@ func buildFacets(rows []*issuesql.FacetsForIssuesRow) *IssueFacets { return strings.Compare(a.IssueType.String(), b.IssueType.String()) }) + teams := make([]IssueTeamFacetItem, 0, len(teamCounts)) + for team, count := range teamCounts { + teams = append(teams, IssueTeamFacetItem{TeamSlug: team, Count: count}) + } + slices.SortFunc(teams, func(a, b IssueTeamFacetItem) int { + if a.Count != b.Count { + return b.Count - a.Count + } + return strings.Compare(a.TeamSlug.String(), b.TeamSlug.String()) + }) + teams = teams[:min(10, len(teams))] + return &IssueFacets{ + Teams: teams, Environments: environments, Severities: severities, ResourceTypes: resourceTypes, diff --git a/internal/issue/facets_test.go b/internal/issue/facets_test.go index f2eb4430f..4826808e0 100644 --- a/internal/issue/facets_test.go +++ b/internal/issue/facets_test.go @@ -1,13 +1,38 @@ package issue import ( + "fmt" "reflect" "testing" "github.com/nais/api/internal/graph/model" "github.com/nais/api/internal/issue/issuesql" + "github.com/nais/api/internal/slug" ) +func TestTeamFacets(t *testing.T) { + rows := []*issuesql.FacetsForIssuesRow{ + {Team: "filtered-out", FilteredCount: 0}, + {Team: "highest", FilteredCount: 5}, + {Team: "highest", FilteredCount: 8}, + } + for i := 11; i >= 0; i-- { + rows = append(rows, &issuesql.FacetsForIssuesRow{Team: fmt.Sprintf("team-%02d", i), FilteredCount: 2}) + } + facets := buildFacets(rows) + if len(facets.Teams) != 10 { + t.Fatalf("got %d team facets, want 10", len(facets.Teams)) + } + if facets.Teams[0].TeamSlug != "highest" || facets.Teams[0].Count != 13 { + t.Fatalf("expected aggregated highest count first, got %+v", facets.Teams[0]) + } + for i, item := range facets.Teams[1:] { + if want := slug.Slug(fmt.Sprintf("team-%02d", i)); item.TeamSlug != want || item.Count != 2 { + t.Errorf("team facet %d = %+v, want %s with count 2", i+1, item, want) + } + } +} + func TestBuildFacets(t *testing.T) { rows := []*issuesql.FacetsForIssuesRow{ {Severity: "CRITICAL", ResourceType: "APPLICATION", Env: "prod", IssueType: "DEPRECATED_INGRESS", FilteredCount: 3}, diff --git a/internal/issue/issuesql/issue.sql.go b/internal/issue/issuesql/issue.sql.go index 3aae4a915..d4fff6fb9 100644 --- a/internal/issue/issuesql/issue.sql.go +++ b/internal/issue/issuesql/issue.sql.go @@ -8,10 +8,12 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" + "github.com/nais/api/internal/slug" ) const facetsForIssues = `-- name: FacetsForIssues :many SELECT + team, severity, resource_type, env, @@ -42,25 +44,34 @@ SELECT FROM issues WHERE - team = $6 + ( + $6::TEXT = '' + OR team = $6 + ) AND ( - $7::TEXT IS NULL - OR resource_type = $7::TEXT + $7::slug[] IS NULL + OR team = ANY ($7::slug[]) ) AND ( $8::TEXT IS NULL - OR resource_name = $8::TEXT + OR resource_type = $8::TEXT ) AND ( $9::TEXT IS NULL - OR env = $9::TEXT + OR resource_name = $9::TEXT + ) + AND ( + $10::TEXT IS NULL + OR env = $10::TEXT ) GROUP BY + team, severity, resource_type, env, issue_type ORDER BY + team, severity, resource_type, env, @@ -74,12 +85,14 @@ type FacetsForIssuesParams struct { FilterResourceType *string FilterResourceName *string Team string + TeamSlugs []slug.Slug ScopeResourceType *string ScopeResourceName *string ScopeEnv *string } type FacetsForIssuesRow struct { + Team string Severity SeverityLevel ResourceType string Env string @@ -95,6 +108,7 @@ func (q *Queries) FacetsForIssues(ctx context.Context, arg FacetsForIssuesParams arg.FilterResourceType, arg.FilterResourceName, arg.Team, + arg.TeamSlugs, arg.ScopeResourceType, arg.ScopeResourceName, arg.ScopeEnv, @@ -107,6 +121,7 @@ func (q *Queries) FacetsForIssues(ctx context.Context, arg FacetsForIssuesParams for rows.Next() { var i FacetsForIssuesRow if err := rows.Scan( + &i.Team, &i.Severity, &i.ResourceType, &i.Env, @@ -195,68 +210,76 @@ SELECT FROM issues WHERE - team = $1 + ( + $1::TEXT = '' + OR team = $1 + ) AND ( - $2::TEXT[] IS NULL - OR env = ANY ($2::TEXT[]) + $2::slug[] IS NULL + OR team = ANY ($2::slug[]) ) AND ( - $3::TEXT IS NULL - OR issue_type = $3::TEXT + $3::TEXT[] IS NULL + OR env = ANY ($3::TEXT[]) ) AND ( - $4::severity_level IS NULL - OR severity = $4::severity_level + $4::TEXT IS NULL + OR issue_type = $4::TEXT ) AND ( - $5::TEXT IS NULL - OR resource_type = $5::TEXT + $5::severity_level IS NULL + OR severity = $5::severity_level ) AND ( $6::TEXT IS NULL - OR resource_name = $6::TEXT + OR resource_type = $6::TEXT + ) + AND ( + $7::TEXT IS NULL + OR resource_name = $7::TEXT ) ORDER BY CASE - WHEN $7::TEXT = 'env:asc' THEN env + WHEN $8::TEXT = 'env:asc' THEN env END ASC, CASE - WHEN $7::TEXT = 'env:desc' THEN env + WHEN $8::TEXT = 'env:desc' THEN env END DESC, CASE - WHEN $7::TEXT = 'issue_type:asc' THEN issue_type + WHEN $8::TEXT = 'issue_type:asc' THEN issue_type END ASC, CASE - WHEN $7::TEXT = 'issue_type:desc' THEN issue_type + WHEN $8::TEXT = 'issue_type:desc' THEN issue_type END DESC, CASE - WHEN $7::TEXT = 'resource_type:asc' THEN resource_type + WHEN $8::TEXT = 'resource_type:asc' THEN resource_type END ASC, CASE - WHEN $7::TEXT = 'resource_type:desc' THEN resource_type + WHEN $8::TEXT = 'resource_type:desc' THEN resource_type END DESC, CASE - WHEN $7::TEXT = 'resource_name:asc' THEN resource_name + WHEN $8::TEXT = 'resource_name:asc' THEN resource_name END ASC, CASE - WHEN $7::TEXT = 'resource_name:desc' THEN resource_name + WHEN $8::TEXT = 'resource_name:desc' THEN resource_name END DESC, CASE - WHEN $7::TEXT = 'severity:asc' THEN severity + WHEN $8::TEXT = 'severity:asc' THEN severity END ASC, CASE - WHEN $7::TEXT = 'severity:desc' THEN severity + WHEN $8::TEXT = 'severity:desc' THEN severity END DESC, severity DESC, id OFFSET - $8 -LIMIT $9 +LIMIT + $10 ` type ListIssuesParams struct { Team string + TeamSlugs []slug.Slug Env []string IssueType *string Severity *SeverityLevel @@ -284,6 +307,7 @@ type ListIssuesRow struct { func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]*ListIssuesRow, error) { rows, err := q.db.Query(ctx, listIssues, arg.Team, + arg.TeamSlugs, arg.Env, arg.IssueType, arg.Severity, diff --git a/internal/issue/model.go b/internal/issue/model.go index 382807b62..e62ba27e1 100644 --- a/internal/issue/model.go +++ b/internal/issue/model.go @@ -13,6 +13,7 @@ import ( "github.com/nais/api/internal/graph/scalar" "github.com/nais/api/internal/persistence/sqlinstance" "github.com/nais/api/internal/slug" + "github.com/nais/api/internal/team/teamscope" ) type Issue interface { @@ -292,22 +293,25 @@ type IssueScope struct { type IssueConnection struct { pagination.Connection[Issue] - teamSlug slug.Slug - scope *IssueScope - filter *IssueFilter + teamSlug slug.Slug + teamSlugs []slug.Slug + scope *IssueScope + filter *IssueFilter } -func (c *IssueConnection) GetTeamSlug() slug.Slug { return c.teamSlug } -func (c *IssueConnection) GetScope() *IssueScope { return c.scope } -func (c *IssueConnection) GetFilter() *IssueFilter { return c.filter } - type IssueFacets struct { + Teams []IssueTeamFacetItem `json:"teams"` Environments []model.StringFacetItem `json:"environments"` Severities []IssueSeverityFacetItem `json:"severities"` ResourceTypes []IssueResourceTypeFacetItem `json:"resourceTypes"` IssueTypes []IssueTypeFacetItem `json:"issueTypes"` } +type IssueTeamFacetItem struct { + TeamSlug slug.Slug `json:"-"` + Count int `json:"count"` +} + type IssueSeverityFacetItem struct { Severity Severity `json:"severity"` Count int `json:"count"` @@ -551,6 +555,11 @@ type IssueFilter struct { ResourceIssueFilter } +type TenantIssueFilter struct { + IssueFilter + TeamScope *teamscope.TeamScopeInput `json:"teamScope,omitempty"` +} + // ApplicationRestartLoopIssueDetails holds details about a restart loop issue. type ApplicationRestartLoopIssueDetails struct { RestartCount int `json:"restartCount"` diff --git a/internal/issue/queries.go b/internal/issue/queries.go index 28a542eb0..e94789a0e 100644 --- a/internal/issue/queries.go +++ b/internal/issue/queries.go @@ -44,11 +44,32 @@ func GetByIdent(ctx context.Context, id ident.Ident) (Issue, error) { } func ListIssues(ctx context.Context, teamSlug slug.Slug, page *pagination.Pagination, orderBy *IssueOrder, scope *IssueScope, filter *IssueFilter) (*IssueConnection, error) { + return listIssues(ctx, teamSlug, page, orderBy, scope, filter, nil) +} + +func ListForTenant(ctx context.Context, page *pagination.Pagination, orderBy *IssueOrder, filter *TenantIssueFilter) (*IssueConnection, error) { + var issueFilter *IssueFilter + var teams []slug.Slug + if filter != nil { + issueFilter = &filter.IssueFilter + teams = filter.TeamScope.Resolve(ctx) + } + return listIssues(ctx, "", page, orderBy, nil, issueFilter, teams) +} + +func listIssues(ctx context.Context, teamSlug slug.Slug, page *pagination.Pagination, orderBy *IssueOrder, scope *IssueScope, filter *IssueFilter, teams []slug.Slug) (*IssueConnection, error) { + connection := &IssueConnection{ + teamSlug: teamSlug, + teamSlugs: teams, + scope: scope, + filter: filter, + } params := issuesql.ListIssuesParams{ - Team: teamSlug.String(), - Offset: page.Offset(), - Limit: page.Limit(), - OrderBy: orderBy.String(), + Team: teamSlug.String(), + TeamSlugs: teams, + Offset: page.Offset(), + Limit: page.Limit(), + OrderBy: orderBy.String(), } if scope != nil { @@ -79,6 +100,14 @@ func ListIssues(ctx context.Context, teamSlug slug.Slug, page *pagination.Pagina var total int64 if len(ret) > 0 { total = ret[0].TotalCount + } else { + facets, err := ComputeFacets(ctx, connection) + if err != nil { + return nil, err + } + for _, severity := range facets.Severities { + total += int64(severity.Count) + } } conn, err := pagination.NewConvertConnectionWithError(ret, page, total, func(from *issuesql.ListIssuesRow) (Issue, error) { @@ -100,12 +129,8 @@ func ListIssues(ctx context.Context, teamSlug slug.Slug, page *pagination.Pagina return nil, err } - return &IssueConnection{ - Connection: *conn, - teamSlug: teamSlug, - scope: scope, - filter: filter, - }, nil + connection.Connection = *conn + return connection, nil } func convert(issue *issuesql.Issue) (Issue, error) { diff --git a/internal/issue/queries/issue.sql b/internal/issue/queries/issue.sql index 68e68f9f4..627a82cb3 100644 --- a/internal/issue/queries/issue.sql +++ b/internal/issue/queries/issue.sql @@ -33,7 +33,14 @@ SELECT FROM issues WHERE - team = @team + ( + @team::TEXT = '' + OR team = @team + ) + AND ( + sqlc.narg('team_slugs')::slug[] IS NULL + OR team = ANY (sqlc.narg('team_slugs')::slug[]) + ) AND ( sqlc.narg('env')::TEXT[] IS NULL OR env = ANY (sqlc.narg('env')::TEXT[]) @@ -95,6 +102,7 @@ LIMIT -- name: FacetsForIssues :many SELECT + team, severity, resource_type, env, @@ -125,7 +133,14 @@ SELECT FROM issues WHERE - team = @team + ( + @team::TEXT = '' + OR team = @team + ) + AND ( + sqlc.narg('team_slugs')::slug[] IS NULL + OR team = ANY (sqlc.narg('team_slugs')::slug[]) + ) AND ( sqlc.narg('scope_resource_type')::TEXT IS NULL OR resource_type = sqlc.narg('scope_resource_type')::TEXT @@ -139,11 +154,13 @@ WHERE OR env = sqlc.narg('scope_env')::TEXT ) GROUP BY + team, severity, resource_type, env, issue_type ORDER BY + team, severity, resource_type, env, diff --git a/internal/search/bleve.go b/internal/search/bleve.go index 9dfc159b3..79026edba 100644 --- a/internal/search/bleve.go +++ b/internal/search/bleve.go @@ -162,6 +162,20 @@ func (b *bleveSearcher) Search(ctx context.Context, page *pagination.Pagination, return nil, err } + teams := filter.TeamScope.Resolve(ctx) + if len(filter.Teams) > 0 { + legacyTeams := make([]slug.Slug, 0, len(filter.Teams)) + for _, team := range filter.Teams { + if teams == nil || slices.Contains(teams, team) { + legacyTeams = append(legacyTeams, team) + } + } + teams = legacyTeams + } + if teams != nil && len(teams) == 0 { + return pagination.NewConnection([]SearchNode{}, page, 0), nil + } + queries := []query.Query{} if filter.Query != "" { @@ -185,14 +199,11 @@ func (b *bleveSearcher) Search(ctx context.Context, page *pagination.Pagination, qq, term, )) - } else { - if len(filter.Teams) > 0 { - queries = append(queries, teamDisjunctionQuery(filter.Teams)) - } else if len(slugs) == 0 { + } else if filter.TeamScope == nil && teams == nil { + if len(slugs) == 0 { return pagination.NewConnection([]SearchNode{}, page, 0), nil - } else { - queries = append(queries, teamDisjunctionQuery(slugs)) } + teams = slugs } if len(filter.Types) > 0 { @@ -205,19 +216,17 @@ func (b *bleveSearcher) Search(ctx context.Context, page *pagination.Pagination, queries = append(queries, typesQuery) } - if filter.Query != "" && len(filter.Teams) > 0 { - queries = append(queries, teamDisjunctionQuery(filter.Teams)) + if teams != nil { + queries = append(queries, teamDisjunctionQuery(teams)) + } + if len(queries) == 0 { + queries = append(queries, bleve.NewMatchAllQuery()) } var q query.Query = bleve.NewConjunctionQuery(queries...) filteredByTeamOnly := len(filter.Types) == 1 && slices.Contains(filter.Types, "TEAM") if !filteredByTeamOnly && (len(slugs) > 0 || filter.Query == "") { - teamSlugs := make([]string, 0, len(slugs)) - for _, slug := range slugs { - teamSlugs = append(teamSlugs, slug.String()) - } - boostFields := []string{"team"} if filter.Query == "" { boostFields = append(boostFields, "kind") @@ -227,7 +236,7 @@ func (b *bleveSearcher) Search(ctx context.Context, page *pagination.Pagination, switch field { case "team": v := 1 - if slices.Contains(teamSlugs, string(term)) { + if slices.Contains(slugs, slug.Slug(term)) { v *= 1000 } b := query.Boost(v) diff --git a/internal/search/bleve_test.go b/internal/search/bleve_test.go index c6c3ad3ba..833f327ce 100644 --- a/internal/search/bleve_test.go +++ b/internal/search/bleve_test.go @@ -11,6 +11,7 @@ import ( "github.com/nais/api/internal/graph/ident" "github.com/nais/api/internal/graph/pagination" "github.com/nais/api/internal/slug" + "github.com/nais/api/internal/team/teamscope" "github.com/sirupsen/logrus" ) @@ -148,6 +149,74 @@ func TestSearchEmptyQueryWithoutUserTeamsReturnsNoDefaults(t *testing.T) { } } +func TestSearchTeamScope(t *testing.T) { + searcher := newTestSearcher(t, []slug.Slug{"team-a"}, []Document{ + newTestDocument("app-a1", "APPLICATION", "app", withTeam("team-a")), + newTestDocument("app-a2", "APPLICATION", "app", withTeam("team-a")), + newTestDocument("app-b", "APPLICATION", "app", withTeam("team-b")), + }) + t.Cleanup(func() { _ = searcher.Client.Close() }) + ctx := authz.ContextWithActor(t.Context(), testUser{id: uuid.New()}, []*authz.Role{ + {Name: "Team member", TargetTeamSlug: new(slug.Slug("team-a"))}, + }) + for _, query := range []string{"", "app"} { + for _, tt := range []struct { + name string + scope *teamscope.TeamScopeInput + legacy []slug.Slug + total int + ids []string + }{ + {"explicit", &teamscope.TeamScopeInput{TeamSlugs: []slug.Slug{"team-b"}}, nil, 1, []string{"app-b"}}, + {"membership", &teamscope.TeamScopeInput{MemberOfViewer: new(true)}, nil, 2, []string{"app-a1", "app-a2"}}, + {"intersection", &teamscope.TeamScopeInput{TeamSlugs: []slug.Slug{"team-a", "team-b"}, MemberOfViewer: new(true)}, nil, 2, []string{"app-a1", "app-a2"}}, + {"empty", &teamscope.TeamScopeInput{TeamSlugs: []slug.Slug{}}, nil, 0, nil}, + {"disjoint", &teamscope.TeamScopeInput{TeamSlugs: []slug.Slug{"team-b"}, MemberOfViewer: new(true)}, nil, 0, nil}, + {"unrestricted", &teamscope.TeamScopeInput{}, nil, 3, []string{"app-a1", "app-a2", "app-b"}}, + {"membership false", &teamscope.TeamScopeInput{MemberOfViewer: new(false)}, nil, 3, []string{"app-a1", "app-a2", "app-b"}}, + {"legacy intersection", &teamscope.TeamScopeInput{MemberOfViewer: new(true)}, []slug.Slug{"team-b"}, 0, nil}, + } { + t.Run(query+"/"+tt.name, func(t *testing.T) { + filter := SearchFilter{Query: query, Types: []SearchType{"APPLICATION"}, TeamScope: tt.scope, Teams: tt.legacy} + result, err := searcher.Search(ctx, newTestPage(t, 10), filter) + if err != nil { + t.Fatal(err) + } + assertSameElements(t, tt.ids, testNodeIDs(result.Nodes())) + if result.PageInfo.TotalCount != tt.total { + t.Fatalf("totalCount = %d, want %d", result.PageInfo.TotalCount, tt.total) + } + result, err = searcher.Search(ctx, newTestPage(t, 1), filter) + if err != nil { + t.Fatal(err) + } + if result.PageInfo.TotalCount != tt.total || len(result.Nodes()) != min(1, tt.total) || result.PageInfo.HasNextPage != (tt.total > 1) { + t.Fatalf("scope must precede pagination: %+v", result.PageInfo) + } + }) + } + } +} + +func TestSearchEmptyQueryWithUnrestrictedScope(t *testing.T) { + searcher := newTestSearcher(t, nil, []Document{ + newTestDocument("team-a", "TEAM", "team-a"), + newTestDocument("team-b", "TEAM", "team-b"), + newTestDocument("app-a", "APPLICATION", "app", withTeam("team-a")), + }) + t.Cleanup(func() { _ = searcher.Client.Close() }) + result, err := searcher.Search(newTestContext(), newTestPage(t, 10), SearchFilter{ + TeamScope: &teamscope.TeamScopeInput{}, + }) + if err != nil { + t.Fatal(err) + } + assertSameElements(t, []string{"team-a", "team-b", "app-a"}, testNodeIDs(result.Nodes())) + if result.PageInfo.TotalCount != 3 { + t.Fatalf("totalCount = %d, want 3", result.PageInfo.TotalCount) + } +} + func TestSearchPostgresByDatabaseNameAndTeam(t *testing.T) { searcher := newTestSearcher(t, nil, []Document{ newTestDocument("orders-a", "POSTGRES", "orders", withTeam("team-a")), diff --git a/internal/search/models.go b/internal/search/models.go index def68bfd2..7c8d9c668 100644 --- a/internal/search/models.go +++ b/internal/search/models.go @@ -8,6 +8,7 @@ import ( "github.com/nais/api/internal/graph/ident" "github.com/nais/api/internal/graph/pagination" "github.com/nais/api/internal/slug" + "github.com/nais/api/internal/team/teamscope" ) type ( @@ -26,9 +27,10 @@ type Result struct { } type SearchFilter struct { - Query string `json:"query"` - Types []SearchType `json:"types,omitempty"` - Teams []slug.Slug `json:"teams,omitempty"` + Query string `json:"query"` + Types []SearchType `json:"types,omitempty"` + Teams []slug.Slug `json:"teams,omitempty"` + TeamScope *teamscope.TeamScopeInput `json:"teamScope,omitempty"` } type SearchType string diff --git a/internal/team/model.go b/internal/team/model.go index bdb8791e7..2aa8278d1 100644 --- a/internal/team/model.go +++ b/internal/team/model.go @@ -15,6 +15,7 @@ import ( "github.com/nais/api/internal/graph/model" "github.com/nais/api/internal/graph/pagination" "github.com/nais/api/internal/slug" + "github.com/nais/api/internal/team/teamscope" "github.com/nais/api/internal/team/teamsql" "github.com/nais/api/internal/validate" ) @@ -356,6 +357,7 @@ func (e UserTeamOrderField) MarshalGQL(w io.Writer) { } type TeamFilter struct { + TeamScope *teamscope.TeamScopeInput `json:"teamScope,omitempty"` HasWorkloads *bool `json:"hasWorkloads"` VulnerabilityPriorityGroup *TeamVulnerabilityPriorityGroup `json:"vulnerabilityPriorityGroup"` } diff --git a/internal/team/queries.go b/internal/team/queries.go index e79fb876f..1267d47b6 100644 --- a/internal/team/queries.go +++ b/internal/team/queries.go @@ -3,6 +3,7 @@ package team import ( "context" "errors" + "slices" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -175,6 +176,14 @@ func listAndSortByExternalSort(ctx context.Context, page *pagination.Pagination, teams[i] = toGraphTeam(t) } + if filter != nil { + if scope := filter.TeamScope.Resolve(ctx); scope != nil { + teams = slices.DeleteFunc(teams, func(t *Team) bool { + return !slices.Contains(scope, t.Slug) + }) + } + } + filteredTeams := SortFilter.Filter(ctx, teams, filter) if filter != nil && filter.VulnerabilityPriorityGroup != nil { diff --git a/internal/team/teamscope/scope.go b/internal/team/teamscope/scope.go new file mode 100644 index 000000000..bdd3894bc --- /dev/null +++ b/internal/team/teamscope/scope.go @@ -0,0 +1,42 @@ +package teamscope + +import ( + "context" + "slices" + + "github.com/nais/api/internal/auth/authz" + "github.com/nais/api/internal/slug" +) + +type TeamScopeInput struct { + TeamSlugs []slug.Slug `json:"teamSlugs,omitempty"` + MemberOfViewer *bool `json:"memberOfViewer,omitempty"` +} + +// Resolve returns nil for an unrestricted selection and an empty slice for no matching teams. +func (s *TeamScopeInput) Resolve(ctx context.Context) []slug.Slug { + if s == nil { + return nil + } + + teams := slices.Clone(s.TeamSlugs) + if s.MemberOfViewer == nil || !*s.MemberOfViewer { + return teams + } + + memberships := make([]slug.Slug, 0) + actor := authz.ActorFromContext(ctx) + if actor == nil || actor.User == nil || actor.User.IsServiceAccount() { + return memberships + } + for _, role := range actor.Roles { + if role.TargetTeamSlug == nil { + continue + } + team := *role.TargetTeamSlug + if (teams == nil || slices.Contains(teams, team)) && !slices.Contains(memberships, team) { + memberships = append(memberships, team) + } + } + return memberships +} diff --git a/internal/team/teamscope/scope_test.go b/internal/team/teamscope/scope_test.go new file mode 100644 index 000000000..9b9effb66 --- /dev/null +++ b/internal/team/teamscope/scope_test.go @@ -0,0 +1,58 @@ +package teamscope_test + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/nais/api/internal/auth/authz" + "github.com/nais/api/internal/slug" + "github.com/nais/api/internal/team/teamscope" +) + +type scopeUser struct { + authz.AuthenticatedUser + serviceAccount bool +} + +func (u scopeUser) IsServiceAccount() bool { return u.serviceAccount } + +func TestResolve(t *testing.T) { + roles := []*authz.Role{{Name: "Admin"}} + memberships := make([]slug.Slug, 25) + for i := range memberships { + memberships[i] = slug.Slug(fmt.Sprintf("team-%02d", i)) + roles = append(roles, &authz.Role{Name: "Team member", TargetTeamSlug: new(memberships[i])}) + } + roles = append(roles, roles[1]) + ctx := authz.ContextWithActor(t.Context(), scopeUser{}, roles) + serviceCtx := authz.ContextWithActor(t.Context(), scopeUser{serviceAccount: true}, roles) + adminCtx := authz.ContextWithActor(t.Context(), scopeUser{}, []*authz.Role{{Name: "Admin"}}) + + for _, tt := range []struct { + name string + ctx context.Context + scope *teamscope.TeamScopeInput + want []slug.Slug + }{ + {"omitted", ctx, nil, nil}, + {"empty input", ctx, &teamscope.TeamScopeInput{}, nil}, + {"explicit empty", ctx, &teamscope.TeamScopeInput{TeamSlugs: []slug.Slug{}}, []slug.Slug{}}, + {"explicit teams", ctx, &teamscope.TeamScopeInput{TeamSlugs: []slug.Slug{"other"}}, []slug.Slug{"other"}}, + {"all memberships", ctx, &teamscope.TeamScopeInput{MemberOfViewer: new(true)}, memberships}, + {"intersection", ctx, &teamscope.TeamScopeInput{TeamSlugs: []slug.Slug{"team-24", "other"}, MemberOfViewer: new(true)}, []slug.Slug{"team-24"}}, + {"empty intersection", ctx, &teamscope.TeamScopeInput{TeamSlugs: []slug.Slug{}, MemberOfViewer: new(true)}, []slug.Slug{}}, + {"membership false", ctx, &teamscope.TeamScopeInput{MemberOfViewer: new(false)}, nil}, + {"admin without memberships", adminCtx, &teamscope.TeamScopeInput{MemberOfViewer: new(true)}, []slug.Slug{}}, + {"service account", serviceCtx, &teamscope.TeamScopeInput{MemberOfViewer: new(true)}, []slug.Slug{}}, + {"service account explicit teams", serviceCtx, &teamscope.TeamScopeInput{TeamSlugs: []slug.Slug{"other"}}, []slug.Slug{"other"}}, + {"no actor", t.Context(), &teamscope.TeamScopeInput{MemberOfViewer: new(true)}, []slug.Slug{}}, + } { + t.Run(tt.name, func(t *testing.T) { + if diff := cmp.Diff(tt.want, tt.scope.Resolve(tt.ctx)); diff != "" { + t.Errorf("team selection (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/vulnerability/cve_workloads_test.go b/internal/vulnerability/cve_workloads_test.go index 3c190425b..345736f12 100644 --- a/internal/vulnerability/cve_workloads_test.go +++ b/internal/vulnerability/cve_workloads_test.go @@ -7,9 +7,12 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/nais/api/internal/auth/authz" "github.com/nais/api/internal/graph/model" "github.com/nais/api/internal/graph/pagination" "github.com/nais/api/internal/slug" + "github.com/nais/api/internal/team/teamscope" + "github.com/nais/api/internal/user" "github.com/nais/v13s/pkg/api/vulnerabilities" "github.com/sirupsen/logrus" ) @@ -22,6 +25,52 @@ type cveWorkloadsClient struct { err error } +func TestCVEWorkloadScope(t *testing.T) { + for _, tt := range []struct { + name string + filter *CVEWorkloadsFilter + teams []string + empty bool + parent []slug.Slug + }{ + {"membership", &CVEWorkloadsFilter{TeamScope: &teamscope.TeamScopeInput{MemberOfViewer: new(true)}}, []string{"team-a"}, false, nil}, + {"explicit empty", &CVEWorkloadsFilter{TeamScope: &teamscope.TeamScopeInput{TeamSlugs: []slug.Slug{}}}, nil, true, nil}, + {"legacy empty unchanged", &CVEWorkloadsFilter{TeamSlugs: []slug.Slug{}}, nil, false, nil}, + {"intersection with legacy", &CVEWorkloadsFilter{TeamSlugs: []slug.Slug{"team-a", "team-b"}, TeamScope: &teamscope.TeamScopeInput{MemberOfViewer: new(true)}}, []string{"team-a"}, false, nil}, + {"disjoint legacy", &CVEWorkloadsFilter{TeamSlugs: []slug.Slug{"team-b"}, TeamScope: &teamscope.TeamScopeInput{MemberOfViewer: new(true)}}, nil, true, nil}, + {"inherited", &CVEWorkloadsFilter{}, []string{"team-b"}, false, []slug.Slug{"team-b"}}, + {"inherited unrestricted child", &CVEWorkloadsFilter{TeamScope: &teamscope.TeamScopeInput{}}, []string{"team-b"}, false, []slug.Slug{"team-b"}}, + {"inherited intersection", &CVEWorkloadsFilter{TeamScope: &teamscope.TeamScopeInput{TeamSlugs: []slug.Slug{"team-a", "team-b"}}}, []string{"team-b"}, false, []slug.Slug{"team-b"}}, + {"inherited disjoint membership", &CVEWorkloadsFilter{TeamScope: &teamscope.TeamScopeInput{MemberOfViewer: new(true)}}, nil, true, []slug.Slug{"team-b"}}, + {"inherited disjoint legacy", &CVEWorkloadsFilter{TeamSlugs: []slug.Slug{"team-a"}}, nil, true, []slug.Slug{"team-b"}}, + } { + t.Run(tt.name, func(t *testing.T) { + client := &cveWorkloadsClient{response: &vulnerabilities.ListWorkloadsForVulnerabilityResponse{ + PageInfo: &vulnerabilities.PageInfo{TotalCount: 9}, + }} + roles := []*authz.Role{{Name: "Team member", TargetTeamSlug: new(slug.Slug("team-a"))}} + ctx := NewLoaderContext(authz.ContextWithActor(t.Context(), &user.User{}, roles), &Manager{Client: client}, logrus.New()) + tt.filter.EnvironmentName = new("dev") + conn, err := GetWorkloadsByCVE(ctx, &CVE{Identifier: "CVE-1", teamSlugs: tt.parent}, nil, tt.filter, nil) + if err != nil { + t.Fatal(err) + } + if tt.empty { + if client.options != nil || conn.PageInfo.TotalCount != 0 || conn.Impact == nil || *conn.Impact != (CVEImpact{}) { + t.Fatalf("empty scope must not query upstream: %+v", conn) + } + return + } + if diff := cmp.Diff(tt.teams, client.options.Filter.Namespaces); diff != "" { + t.Errorf("teams (-want +got):\n%s", diff) + } + if client.options.Filter.GetCluster() != "dev" || conn.PageInfo.TotalCount != 9 { + t.Errorf("environment or upstream count lost: %+v, %+v", client.options.Filter, conn.PageInfo) + } + }) + } +} + func (c *cveWorkloadsClient) ListWorkloadsForVulnerability(_ context.Context, filter vulnerabilities.VulnerabilityFilter, opts ...vulnerabilities.Option) (*vulnerabilities.ListWorkloadsForVulnerabilityResponse, error) { c.options = vulnerabilities.GetOptions(opts...) c.filter = filter @@ -51,7 +100,7 @@ func TestCVEWorkloadsImpactAndOrdering(t *testing.T) { if err != nil { t.Fatal(err) } - conn, err := GetWorkloadsByCVE(ctx, "CVE-1", page, + conn, err := GetWorkloadsByCVE(ctx, &CVE{Identifier: "CVE-1"}, page, &CVEWorkloadsFilter{TeamSlugs: []slug.Slug{"team-a", "team-b"}}, &CVEWorkloadOrder{Field: field, Direction: direction}, ) @@ -90,10 +139,10 @@ func TestCVEWorkloadsRejectsInvalidOrderAndSurfacesUpstreamErrors(t *testing.T) } client := &cveWorkloadsClient{err: errors.New("upstream unavailable")} ctx := NewLoaderContext(context.Background(), &Manager{Client: client}, logrus.New()) - if _, err := GetWorkloadsByCVE(ctx, "CVE-1", page, nil, &CVEWorkloadOrder{Field: "INVALID"}); err == nil || client.options != nil { + if _, err := GetWorkloadsByCVE(ctx, &CVE{Identifier: "CVE-1"}, page, nil, &CVEWorkloadOrder{Field: "INVALID"}); err == nil || client.options != nil { t.Fatal("invalid order must be rejected before the RPC") } - if _, err := GetWorkloadsByCVE(ctx, "CVE-1", page, nil, nil); err == nil { + if _, err := GetWorkloadsByCVE(ctx, &CVE{Identifier: "CVE-1"}, page, nil, nil); err == nil { t.Fatal("upstream failure must be returned") } } diff --git a/internal/vulnerability/fake/v13s.go b/internal/vulnerability/fake/v13s.go index 7618a726e..afa974d32 100644 --- a/internal/vulnerability/fake/v13s.go +++ b/internal/vulnerability/fake/v13s.go @@ -93,7 +93,7 @@ func (f *fakeVulnerabilitiesClient) ListVulnerabilitySummaries(ctx context.Conte labelFilters := summaryLabelFilters(options.Labels) nodes := make([]*vulnerabilities.WorkloadSummary, 0) for _, s := range f.fakeData.workloadSummaries { - if filter != nil && filter.Namespace != nil && s.GetWorkload().GetNamespace() != *filter.Namespace { + if !matchesSummaryScope(s, filter) { continue } if len(statuses) > 0 && !slices.Contains(statuses, s.GetSbomStatus().GetStatus()) { @@ -127,7 +127,7 @@ func (f *fakeVulnerabilitiesClient) GetVulnerabilitySummary(ctx context.Context, } for _, s := range f.fakeData.workloadSummaries { - if filter != nil && filter.Namespace != nil && s.Workload.Namespace != *filter.Namespace { + if !matchesSummaryScope(s, filter) { continue } if !model.MatchesLabelFilters(model.UserLabels(s.GetWorkload().GetLabels()), labelFilters) { @@ -166,6 +166,16 @@ func (f *fakeVulnerabilitiesClient) GetVulnerabilitySummary(ctx context.Context, return resp, nil } +func matchesSummaryScope(summary *vulnerabilities.WorkloadSummary, filter *vulnerabilities.Filter) bool { + if filter == nil { + return true + } + w := summary.GetWorkload() + return (filter.Namespace == nil || w.GetNamespace() == *filter.Namespace) && + (len(filter.Namespaces) == 0 || slices.Contains(filter.Namespaces, w.GetNamespace())) && + (filter.Cluster == nil || w.GetCluster() == *filter.Cluster) +} + func summaryLabelFilters(labels []*vulnerabilities.LabelFilter) model.LabelFilters { filters := make(model.LabelFilters, 0, len(labels)) for _, label := range labels { diff --git a/internal/vulnerability/fake/v13s_test.go b/internal/vulnerability/fake/v13s_test.go index 8efb1f35e..6866ce4b3 100644 --- a/internal/vulnerability/fake/v13s_test.go +++ b/internal/vulnerability/fake/v13s_test.go @@ -82,6 +82,44 @@ func TestListVulnerabilitySummariesFilters(t *testing.T) { } } +func TestSummaryTeamAndEnvironmentScope(t *testing.T) { + client := NewFakeVulnerabilitiesClient() + client.once.Do(func() { + client.fakeData = &fakeData{workloadSummaries: []*vulnerabilities.WorkloadSummary{ + {Workload: &vulnerabilities.Workload{Namespace: "team-a", Cluster: "dev"}, VulnerabilitySummary: &vulnerabilities.Summary{RiskScore: 3, HasSbom: true}}, + {Workload: &vulnerabilities.Workload{Namespace: "team-a", Cluster: "prod"}, VulnerabilitySummary: &vulnerabilities.Summary{RiskScore: 9}}, + {Workload: &vulnerabilities.Workload{Namespace: "team-b", Cluster: "dev"}, VulnerabilitySummary: &vulnerabilities.Summary{RiskScore: 17}}, + }} + }) + for _, tt := range []struct { + name string + opts []vulnerabilities.Option + want int32 + }{ + {"teams", []vulnerabilities.Option{vulnerabilities.NamespacesFilter("team-a")}, 12}, + {"teams and environment", []vulnerabilities.Option{vulnerabilities.NamespacesFilter("team-a"), vulnerabilities.ClusterFilter("dev")}, 3}, + {"multiple teams and environment", []vulnerabilities.Option{vulnerabilities.NamespacesFilter("team-a", "team-b"), vulnerabilities.ClusterFilter("dev")}, 20}, + } { + t.Run(tt.name, func(t *testing.T) { + aggregate, err := client.GetVulnerabilitySummary(t.Context(), tt.opts...) + if err != nil { + t.Fatal(err) + } + list, err := client.ListVulnerabilitySummaries(t.Context(), tt.opts...) + if err != nil { + t.Fatal(err) + } + var total int32 + for _, node := range list.GetNodes() { + total += node.GetVulnerabilitySummary().GetRiskScore() + } + if total != tt.want || aggregate.GetVulnerabilitySummary().GetRiskScore() != tt.want { + t.Fatalf("list total=%d, aggregate=%d, want %d", total, aggregate.GetVulnerabilitySummary().GetRiskScore(), tt.want) + } + }) + } +} + type summaryLabelCase struct { name string filters []*vulnerabilities.LabelFilter diff --git a/internal/vulnerability/models.go b/internal/vulnerability/models.go index 7c41361ee..da4485560 100644 --- a/internal/vulnerability/models.go +++ b/internal/vulnerability/models.go @@ -11,6 +11,7 @@ import ( "github.com/nais/api/internal/graph/model" "github.com/nais/api/internal/graph/pagination" "github.com/nais/api/internal/slug" + "github.com/nais/api/internal/team/teamscope" "github.com/nais/api/internal/workload" "github.com/nais/v13s/pkg/api/vulnerabilities" ) @@ -465,7 +466,9 @@ type UpdateImageVulnerabilityPayload struct { } type CVEWorkloadsFilter struct { - TeamSlugs []slug.Slug `json:"teamSlugs"` + TeamScope *teamscope.TeamScopeInput `json:"teamScope,omitempty"` + EnvironmentName *string `json:"environmentName,omitempty"` + TeamSlugs []slug.Slug `json:"teamSlugs"` } type ImageVulnerabilitySuppressionState string @@ -522,8 +525,10 @@ type TeamVulnerabilitySummaryFilter struct { } type TenantVulnerabilitySummaryFilter struct { - Priorities []CVEPriority `json:"priorities,omitempty"` - HasKevEntry *bool `json:"hasKevEntry,omitempty"` + TeamScope *teamscope.TeamScopeInput `json:"teamScope,omitempty"` + EnvironmentName *string `json:"environmentName,omitempty"` + Priorities []CVEPriority `json:"priorities,omitempty"` + HasKevEntry *bool `json:"hasKevEntry,omitempty"` } type ImageVulnerabilitySample struct { @@ -619,6 +624,7 @@ type CVE struct { // AffectedWorkloads is used to short circuit counting affected workloads in resolvers, // if the only field requested of the workloads field is the total count. AffectedWorkloads *int `json:"-"` + teamSlugs []slug.Slug } func (CVE) IsNode() {} @@ -660,7 +666,8 @@ func workloadTypeForVulnerabilityIdent(t workload.Type) string { } type CVEFilter struct { - Priority *CVEPriority `json:"priority,omitempty"` + Priority *CVEPriority `json:"priority,omitempty"` + TeamScope *teamscope.TeamScopeInput `json:"teamScope,omitempty"` } type CVEOrder struct { diff --git a/internal/vulnerability/queries.go b/internal/vulnerability/queries.go index 6f6f7cde2..3e6767a05 100644 --- a/internal/vulnerability/queries.go +++ b/internal/vulnerability/queries.go @@ -234,23 +234,13 @@ func ListVulnerabilitySummaries(ctx context.Context, s slug.Slug, filter *TeamVu } func ListTenantVulnerabilitySummaries(ctx context.Context, filter *TenantVulnerabilitySummaryFilter, page *pagination.Pagination, orderBy *VulnerabilitySummaryOrder) (*WorkloadVulnerabilitySummaryConnection, error) { - opts := make([]vulnerabilities.Option, 0) + opts, empty, err := tenantSummaryFilterOptions(ctx, filter) + if err != nil { + return nil, err + } opts = append(opts, vulnerabilities.Offset(page.Offset())) opts = append(opts, vulnerabilities.Limit(page.Limit())) - if filter != nil { - priorityOpt, err := priorityFilterOption(filter.Priorities) - if err != nil { - return nil, err - } - if priorityOpt != nil { - opts = append(opts, priorityOpt) - } - if filter.HasKevEntry != nil { - opts = append(opts, vulnerabilities.KevFilter(*filter.HasKevEntry)) - } - } - if orderBy != nil { direction := toDirection(orderBy.Direction) @@ -262,6 +252,10 @@ func ListTenantVulnerabilitySummaries(ctx context.Context, filter *TenantVulnera opts = append(opts, vulnerabilities.Order(o, direction)) } + if empty { + return pagination.EmptyConnection[*WorkloadVulnerabilitySummary](), nil + } + resp, err := fromContext(ctx).manager.Client.ListVulnerabilitySummaries(ctx, opts...) if err != nil { return nil, apierror.Errorf("list tenant vulnerability summaries: %v", err) @@ -600,8 +594,45 @@ func GetWorkloadVulnerabilityHistoryForTeam(ctx context.Context, slug slug.Slug, return getVulnerabilityHistory(ctx, opts) } -func GetTenantVulnerabilitySummary(ctx context.Context) (*TenantVulnerabilitySummary, error) { - resp, err := fromContext(ctx).manager.Client.GetVulnerabilitySummary(ctx) +func teamSlugsFilterOption(teams []slug.Slug) vulnerabilities.Option { + namespaces := make([]string, len(teams)) + for i, team := range teams { + namespaces[i] = team.String() + } + return vulnerabilities.NamespacesFilter(namespaces...) +} + +func tenantSummaryFilterOptions(ctx context.Context, filter *TenantVulnerabilitySummaryFilter) ([]vulnerabilities.Option, bool, error) { + if filter == nil { + return nil, false, nil + } + opts, err := teamSummaryFilterOptions(&TeamVulnerabilitySummaryFilter{ + EnvironmentName: filter.EnvironmentName, + Priorities: filter.Priorities, + HasKevEntry: filter.HasKevEntry, + }) + if err != nil { + return nil, false, err + } + teams := filter.TeamScope.Resolve(ctx) + if teams != nil { + if len(teams) == 0 { + return opts, true, nil + } + opts = append(opts, teamSlugsFilterOption(teams)) + } + return opts, false, nil +} + +func GetTenantVulnerabilitySummary(ctx context.Context, filter *TenantVulnerabilitySummaryFilter) (*TenantVulnerabilitySummary, error) { + opts, empty, err := tenantSummaryFilterOptions(ctx, filter) + if err != nil { + return nil, err + } + if empty { + return &TenantVulnerabilitySummary{}, nil + } + resp, err := fromContext(ctx).manager.Client.GetVulnerabilitySummary(ctx, opts...) if err != nil { return nil, apierror.Errorf("get tenant vulnerability summary: %v", err) } @@ -856,6 +887,17 @@ func ListCVEs(ctx context.Context, page *pagination.Pagination, orderBy *CVEOrde } } + var teams []slug.Slug + if filter != nil { + teams = filter.TeamScope.Resolve(ctx) + if teams != nil { + if len(teams) == 0 { + return pagination.NewConnection([]*CVE{}, page, 0), nil + } + opts = append(opts, teamSlugsFilterOption(teams)) + } + } + // Apply ordering if specified if orderBy != nil { direction := toDirection(orderBy.Direction) @@ -897,6 +939,7 @@ func ListCVEs(ctx context.Context, page *pagination.Pagination, orderBy *CVEOrde c := toCVE(cve) c.AffectedWorkloads = new(int(cveSummary.AffectedWorkloads)) + c.teamSlugs = teams cves = append(cves, c) } @@ -917,7 +960,7 @@ func invertDirection(direction vulnerabilities.Direction) vulnerabilities.Direct return vulnerabilities.Direction_DESC } -func GetWorkloadsByCVE(ctx context.Context, cve string, page *pagination.Pagination, filter *CVEWorkloadsFilter, orderBy *CVEWorkloadOrder) (*WorkloadWithVulnerabilityConnection, error) { +func GetWorkloadsByCVE(ctx context.Context, cve *CVE, page *pagination.Pagination, filter *CVEWorkloadsFilter, orderBy *CVEWorkloadOrder) (*WorkloadWithVulnerabilityConnection, error) { order := vulnerabilities.Order(vulnerabilities.OrderByNamespace, vulnerabilities.Direction_ASC) if orderBy != nil { field, ok := cveWorkloadOrderFields[orderBy.Field] @@ -934,16 +977,42 @@ func GetWorkloadsByCVE(ctx context.Context, cve string, page *pagination.Paginat vulnerabilities.ExcludeClustersFilter("management"), order, } - if filter != nil && len(filter.TeamSlugs) > 0 { - slugs := make([]string, len(filter.TeamSlugs)) - for i, s := range filter.TeamSlugs { - slugs[i] = string(s) + teams := cve.teamSlugs + if filter != nil { + if scope := filter.TeamScope.Resolve(ctx); scope != nil { + selected := make([]slug.Slug, 0, len(scope)) + for _, team := range scope { + if teams == nil || slices.Contains(teams, team) { + selected = append(selected, team) + } + } + teams = selected + } + if len(filter.TeamSlugs) > 0 { + legacyTeams := make([]slug.Slug, 0, len(filter.TeamSlugs)) + for _, team := range filter.TeamSlugs { + if teams == nil || slices.Contains(teams, team) { + legacyTeams = append(legacyTeams, team) + } + } + teams = legacyTeams + } + if filter.EnvironmentName != nil { + opts = append(opts, vulnerabilities.ClusterFilter(environmentmapper.ClusterName(*filter.EnvironmentName))) + } + } + if teams != nil { + if len(teams) == 0 { + return &WorkloadWithVulnerabilityConnection{ + Connection: pagination.EmptyConnection[*WorkloadWithVulnerability](), + Impact: &CVEImpact{}, + }, nil } - opts = append(opts, vulnerabilities.NamespacesFilter(slugs...)) + opts = append(opts, teamSlugsFilterOption(teams)) } resp, err := fromContext(ctx).manager.Client.ListWorkloadsForVulnerability( ctx, - vulnerabilities.VulnerabilityFilter{CveIds: []string{cve}}, + vulnerabilities.VulnerabilityFilter{CveIds: []string{cve.Identifier}}, opts..., ) if err != nil { diff --git a/internal/vulnerability/queries_test.go b/internal/vulnerability/queries_test.go index 2302ebeeb..d074bf8bb 100644 --- a/internal/vulnerability/queries_test.go +++ b/internal/vulnerability/queries_test.go @@ -5,9 +5,12 @@ import ( "slices" "testing" + "github.com/nais/api/internal/auth/authz" "github.com/nais/api/internal/graph/model" "github.com/nais/api/internal/graph/pagination" "github.com/nais/api/internal/slug" + "github.com/nais/api/internal/team/teamscope" + "github.com/nais/api/internal/user" "github.com/nais/api/internal/workload" "github.com/nais/api/internal/workload/application" "github.com/nais/v13s/pkg/api/vulnerabilities" @@ -26,6 +29,56 @@ type recordingSummaryClient struct { options *vulnerabilities.Options } +func TestTenantSummaryScope(t *testing.T) { + roles := []*authz.Role{{Name: "Team member", TargetTeamSlug: new(slug.Slug("team-a"))}} + for _, tt := range []struct { + name string + scope *teamscope.TeamScopeInput + teams []string + empty bool + }{ + {"unrestricted", nil, nil, false}, + {"explicit", &teamscope.TeamScopeInput{TeamSlugs: []slug.Slug{"team-a", "team-b"}}, []string{"team-a", "team-b"}, false}, + {"membership", &teamscope.TeamScopeInput{MemberOfViewer: new(true)}, []string{"team-a"}, false}, + {"empty", &teamscope.TeamScopeInput{TeamSlugs: []slug.Slug{}}, nil, true}, + {"disjoint", &teamscope.TeamScopeInput{TeamSlugs: []slug.Slug{"team-b"}, MemberOfViewer: new(true)}, nil, true}, + } { + t.Run(tt.name, func(t *testing.T) { + client := &recordingSummaryClient{} + ctx := NewLoaderContext(authz.ContextWithActor(t.Context(), &user.User{}, roles), &Manager{Client: client}, logrus.New()) + filter := &TenantVulnerabilitySummaryFilter{ + TeamScope: tt.scope, EnvironmentName: new("dev"), + Priorities: []CVEPriority{CVEPriorityHigh}, HasKevEntry: new(true), + } + conn, err := ListTenantVulnerabilitySummaries(ctx, filter, nil, nil) + if err != nil { + t.Fatal(err) + } + summary, err := GetTenantVulnerabilitySummary(ctx, filter) + if err != nil { + t.Fatal(err) + } + if tt.empty { + if client.calls != 0 || conn.PageInfo.TotalCount != 0 || summary.RiskScore != 0 || summary.LastUpdated != nil { + t.Fatalf("empty scope must not query upstream: calls=%d, summary=%+v", client.calls, summary) + } + return + } + want := &vulnerabilities.Filter{ + Namespaces: tt.teams, Cluster: new("dev"), + Priorities: []vulnerabilities.Priority{vulnerabilities.Priority_PRIORITY_HIGH}, + HasKev: new(true), + } + if client.calls != 2 || !proto.Equal(want, client.options.Filter) { + t.Fatalf("calls=%d, filter=%v, want %v", client.calls, client.options.Filter, want) + } + if conn.PageInfo.TotalCount != 17 || summary.RiskScore != 37 || summary.Coverage != 50 { + t.Fatalf("upstream totals were lost: connection=%+v, summary=%+v", conn.PageInfo, summary) + } + }) + } +} + func (c *recordingSummaryClient) ListVulnerabilitySummaries(_ context.Context, opts ...vulnerabilities.Option) (*vulnerabilities.ListVulnerabilitySummariesResponse, error) { c.calls++ c.options = vulnerabilities.GetOptions(opts...) @@ -173,6 +226,56 @@ func (c *recordingCVEClient) ListCveSummaries(_ context.Context, opts ...vulnera }, nil } +func TestListCVEsTeamScope(t *testing.T) { + for _, tt := range []struct { + name string + scope *teamscope.TeamScopeInput + teams []string + empty bool + }{ + {"unrestricted", nil, nil, false}, + {"explicit", &teamscope.TeamScopeInput{TeamSlugs: []slug.Slug{"team-a", "team-b"}}, []string{"team-a", "team-b"}, false}, + {"membership", &teamscope.TeamScopeInput{MemberOfViewer: new(true)}, []string{"team-a"}, false}, + {"intersection", &teamscope.TeamScopeInput{TeamSlugs: []slug.Slug{"team-a", "team-b"}, MemberOfViewer: new(true)}, []string{"team-a"}, false}, + {"empty", &teamscope.TeamScopeInput{TeamSlugs: []slug.Slug{}}, nil, true}, + {"disjoint", &teamscope.TeamScopeInput{TeamSlugs: []slug.Slug{"team-b"}, MemberOfViewer: new(true)}, nil, true}, + } { + t.Run(tt.name, func(t *testing.T) { + client := &recordingCVEClient{} + ctx := NewLoaderContext(authz.ContextWithActor(t.Context(), &user.User{}, []*authz.Role{ + {Name: "Team member", TargetTeamSlug: new(slug.Slug("team-a"))}, + }), &Manager{Client: client}, logrus.New()) + page, err := pagination.ParsePage(new(1), nil, nil, nil) + if err != nil { + t.Fatal(err) + } + conn, err := ListCVEs(ctx, page, &CVEOrder{Field: CVEOrderFieldAffectedWorkloadsCount, Direction: model.OrderDirectionDesc}, + &CVEFilter{TeamScope: tt.scope, Priority: new(CVEPriorityHigh)}) + if err != nil { + t.Fatal(err) + } + if tt.empty { + if client.calls != 0 || conn.PageInfo.TotalCount != 0 || len(conn.Nodes()) != 0 { + t.Fatalf("empty scope must not query upstream: calls=%d, connection=%+v", client.calls, conn) + } + return + } + want := &vulnerabilities.Filter{ + Namespaces: tt.teams, Priorities: []vulnerabilities.Priority{vulnerabilities.Priority_PRIORITY_HIGH}, + } + if !proto.Equal(want, client.options.Filter) { + t.Fatalf("filter = %v, want %v", client.options.Filter, want) + } + if client.options.Limit != 1 || client.options.OrderBy.GetField() != string(vulnerabilities.OrderByAffectedWorkloads) { + t.Fatalf("pagination/order lost: %+v", client.options) + } + if conn.PageInfo.TotalCount != 42 || len(conn.Nodes()) != 1 || *conn.Nodes()[0].AffectedWorkloads != 3 { + t.Fatalf("upstream counts or inherited scope lost: %+v", conn) + } + }) + } +} + func TestListCVEsDelegatesPriorityToV13s(t *testing.T) { high, elevated := CVEPriorityHigh, CVEPriorityElevated for _, test := range []struct {