From 430a1d764fd47444a519dcb504c4ddea102f5ea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Thu, 25 Jun 2026 15:24:35 +0200 Subject: [PATCH 01/24] Implement tenant activity log API --- integration_tests/tenant_activitylog.lua | 195 +++++++++++++++++++ internal/activitylog/facets.go | 29 ++- internal/activitylog/filter.go | 18 ++ internal/activitylog/model.go | 4 + internal/activitylog/queries.go | 41 ++++ internal/activitylog/queries/activitylog.sql | 78 ++++++++ internal/auth/authz/queries.go | 30 +++ internal/auth/authz/queries/roles.sql | 42 ++++ internal/graph/activitylog.resolvers.go | 14 ++ internal/graph/schema/activitylog.graphqls | 49 +++++ 10 files changed, 498 insertions(+), 2 deletions(-) create mode 100644 integration_tests/tenant_activitylog.lua diff --git a/integration_tests/tenant_activitylog.lua b/integration_tests/tenant_activitylog.lua new file mode 100644 index 000000000..27a252d1c --- /dev/null +++ b/integration_tests/tenant_activitylog.lua @@ -0,0 +1,195 @@ +local admin = User.new() +admin:admin(true) +local member = User.new() +local nonMember = User.new() +local teamOne = Team.new("slug-1", "purpose", "#channel") +local teamTwo = Team.new("slug-2", "purpose", "#channel") +teamOne:addMember(member) +teamTwo:addMember(member) + +Test.gql("Create repository event for team one", function(t) + t.addHeader("x-user-email", admin:email()) + + t.query [[ + mutation { + addRepositoryToTeam(input: {teamSlug: "slug-1", repositoryName: "nais/api-team-one"}) { + repository { + name + } + } + } + ]] + + t.check { + data = { + addRepositoryToTeam = { + repository = { + name = "nais/api-team-one", + }, + }, + }, + } +end) + +Test.gql("Create repository event for team two", function(t) + t.addHeader("x-user-email", admin:email()) + + t.query [[ + mutation { + addRepositoryToTeam(input: {teamSlug: "slug-2", repositoryName: "nais/api-team-two"}) { + repository { + name + } + } + } + ]] + + t.check { + data = { + addRepositoryToTeam = { + repository = { + name = "nais/api-team-two", + }, + }, + }, + } +end) + +Test.gql("Tenant activity log returns facets with teams and pagination metadata", function(t) + t.addHeader("x-user-email", admin:email()) + + t.query [[ + query { + tenantActivityLog(first: 10, filter: { activityTypes: [REPOSITORY_ADDED] }) { + nodes { + resourceName + teamSlug + } + pageInfo { + totalCount + hasNextPage + } + facets { + activityTypes { + activityType + count + } + resourceTypes { + resourceType + count + } + environments { + value + count + } + teams { + value + count + } + } + } + } + ]] + + t.check { + data = { + tenantActivityLog = { + nodes = { + { + resourceName = "nais/api-team-two", + teamSlug = "slug-2", + }, + { + resourceName = "nais/api-team-one", + teamSlug = "slug-1", + }, + }, + pageInfo = { + totalCount = 2, + hasNextPage = false, + }, + facets = { + activityTypes = { + { + activityType = "REPOSITORY_ADDED", + count = 2, + }, + }, + resourceTypes = { + { + resourceType = "REPOSITORY", + count = 2, + }, + }, + environments = {}, + teams = { + { + value = "slug-1", + count = 1, + }, + { + value = "slug-2", + count = 1, + }, + }, + }, + }, + }, + } +end) + +Test.gql("Tenant activity log supports time filtering", function(t) + t.addHeader("x-user-email", admin:email()) + + t.query [[ + query { + tenantActivityLog( + first: 10 + filter: { activityTypes: [REPOSITORY_ADDED], from: "9999-01-01T00:00:00Z" } + ) { + nodes { + resourceName + } + pageInfo { + totalCount + } + } + } + ]] + + t.check { + data = { + tenantActivityLog = { + nodes = {}, + pageInfo = { + totalCount = 0, + }, + }, + }, + } +end) + +Test.gql("Tenant activity log requires activity_logs read authorization", function(t) + t.addHeader("x-user-email", nonMember:email()) + + t.query [[ + query { + tenantActivityLog(first: 1) { + nodes { + id + } + } + } + ]] + + t.check { + data = Null, + errors = { + { + locations = NotNull(), + message = Contains('you need the "activity_logs:read"'), + path = { "tenantActivityLog" }, + }, + }, + } +end) diff --git a/internal/activitylog/facets.go b/internal/activitylog/facets.go index 74d922fb8..7bf238240 100644 --- a/internal/activitylog/facets.go +++ b/internal/activitylog/facets.go @@ -17,9 +17,13 @@ func ComputeFacets(ctx context.Context, scope *ActivityLogScope, filter *Activit ResourceType: scopeField(scope, func(s *ActivityLogScope) *string { return s.ResourceType }), ResourceName: scopeField(scope, func(s *ActivityLogScope) *string { return s.ResourceName }), EnvironmentName: scopeField(scope, func(s *ActivityLogScope) *string { return s.EnvironmentName }), + From: withFrom(filter), + To: withTo(filter), Filter: withFilters(filter), FilterResourceTypes: withResourceTypes(filter), FilterEnvironments: withEnvironments(filter), + FilterFrom: withFrom(filter), + FilterTo: withTo(filter), }) if err != nil { return nil, err @@ -39,6 +43,7 @@ func buildFacets(rows []*activitylogsql.FacetsRow) *ActivityLogFacets { activityTypeCounts := map[ActivityLogActivityType]int{} resourceTypeCounts := map[ActivityLogEntryResourceType]int{} environmentCounts := map[string]int{} + teamCounts := map[string]int{} for _, row := range rows { // Seed with total_count to ensure all values that exist in this scope are present @@ -53,6 +58,13 @@ func buildFacets(rows []*activitylogsql.FacetsRow) *ActivityLogFacets { } } + if row.TeamSlug != nil { + teamSlug := row.TeamSlug.String() + if _, ok := teamCounts[teamSlug]; !ok { + teamCounts[teamSlug] = 0 + } + } + for _, at := range LookupActivityTypes(row.ResourceType, row.Action) { if _, ok := activityTypeCounts[at]; !ok { activityTypeCounts[at] = 0 @@ -67,19 +79,24 @@ func buildFacets(rows []*activitylogsql.FacetsRow) *ActivityLogFacets { environmentCounts[row.Environment] += filteredCount } + if row.TeamSlug != nil { + teamCounts[row.TeamSlug.String()] += filteredCount + } + for _, at := range LookupActivityTypes(row.ResourceType, row.Action) { activityTypeCounts[at] += filteredCount } } - return assembleFacets(activityTypeCounts, resourceTypeCounts, environmentCounts) + return assembleFacets(activityTypeCounts, resourceTypeCounts, environmentCounts, teamCounts) } -func assembleFacets(activityTypeCounts map[ActivityLogActivityType]int, resourceTypeCounts map[ActivityLogEntryResourceType]int, environmentCounts map[string]int) *ActivityLogFacets { +func assembleFacets(activityTypeCounts map[ActivityLogActivityType]int, resourceTypeCounts map[ActivityLogEntryResourceType]int, environmentCounts map[string]int, teamCounts map[string]int) *ActivityLogFacets { facets := &ActivityLogFacets{ ActivityTypes: make([]ActivityLogActivityTypeFacetItem, 0, len(activityTypeCounts)), ResourceTypes: make([]ActivityLogResourceTypeFacetItem, 0, len(resourceTypeCounts)), Environments: make([]model.StringFacetItem, 0, len(environmentCounts)), + Teams: make([]model.StringFacetItem, 0, len(teamCounts)), } for at, count := range activityTypeCounts { @@ -103,6 +120,13 @@ func assembleFacets(activityTypeCounts map[ActivityLogActivityType]int, resource }) } + for teamSlug, count := range teamCounts { + facets.Teams = append(facets.Teams, model.StringFacetItem{ + Value: teamSlug, + Count: count, + }) + } + // Sort alphabetically for stable ordering (items don't jump around when filters change) slices.SortFunc(facets.ActivityTypes, func(a, b ActivityLogActivityTypeFacetItem) int { return strings.Compare(string(a.ActivityType), string(b.ActivityType)) @@ -113,6 +137,7 @@ func assembleFacets(activityTypeCounts map[ActivityLogActivityType]int, resource }) model.SortStringFacetItems(facets.Environments) + model.SortStringFacetItems(facets.Teams) return facets } diff --git a/internal/activitylog/filter.go b/internal/activitylog/filter.go index 7d5afcd2a..cd7a4413f 100644 --- a/internal/activitylog/filter.go +++ b/internal/activitylog/filter.go @@ -2,6 +2,8 @@ package activitylog import ( "slices" + + "github.com/jackc/pgx/v5/pgtype" ) type filter struct { @@ -90,3 +92,19 @@ func withEnvironments(filter *ActivityLogFilter) []string { return filter.Environments } + +func withFrom(filter *ActivityLogFilter) pgtype.Timestamptz { + if filter == nil || filter.From == nil { + return pgtype.Timestamptz{Valid: false} + } + + return pgtype.Timestamptz{Time: *filter.From, Valid: true} +} + +func withTo(filter *ActivityLogFilter) pgtype.Timestamptz { + if filter == nil || filter.To == nil { + return pgtype.Timestamptz{Valid: false} + } + + return pgtype.Timestamptz{Time: *filter.To, Valid: true} +} diff --git a/internal/activitylog/model.go b/internal/activitylog/model.go index 44a3e26d0..8ecfea1ff 100644 --- a/internal/activitylog/model.go +++ b/internal/activitylog/model.go @@ -45,6 +45,7 @@ func (c *ActivityLogEntryConnection) GetFilter() *ActivityLogFilter { return c.f // ActivityLogScope defines the base scope for an activity log query. type ActivityLogScope struct { + Tenant bool TeamSlug *slug.Slug ResourceType *string ResourceName *string @@ -57,6 +58,7 @@ type ActivityLogFacets struct { ActivityTypes []ActivityLogActivityTypeFacetItem `json:"activityTypes"` ResourceTypes []ActivityLogResourceTypeFacetItem `json:"resourceTypes"` Environments []model.StringFacetItem `json:"environments"` + Teams []model.StringFacetItem `json:"teams"` } type ActivityLogActivityTypeFacetItem struct { @@ -121,6 +123,8 @@ type ActivityLogFilter struct { ActivityTypes []ActivityLogActivityType `json:"activityTypes,omitempty"` ResourceTypes []ActivityLogEntryResourceType `json:"resourceTypes,omitempty"` Environments []string `json:"environments,omitempty"` + From *time.Time `json:"from,omitempty"` + To *time.Time `json:"to,omitempty"` } type ActivityLogActivityType string diff --git a/internal/activitylog/queries.go b/internal/activitylog/queries.go index 1226d5b79..f06f6f314 100644 --- a/internal/activitylog/queries.go +++ b/internal/activitylog/queries.go @@ -86,6 +86,8 @@ func ListForTeam(ctx context.Context, teamSlug slug.Slug, page *pagination.Pagin Filter: withFilters(filter), ResourceTypes: withResourceTypes(filter), Environments: withEnvironments(filter), + From: withFrom(filter), + To: withTo(filter), }) if err != nil { return nil, err @@ -110,6 +112,41 @@ func ListForTeam(ctx context.Context, teamSlug slug.Slug, page *pagination.Pagin }, nil } +func ListForTenant(ctx context.Context, page *pagination.Pagination, filter *ActivityLogFilter) (*ActivityLogEntryConnection, error) { + q := db(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), + }) + if err != nil { + return nil, err + } + + var total int64 + if len(ret) > 0 { + total = ret[0].TotalCount + } + + conn, err := pagination.NewConvertConnectionWithError(ret, page, total, func(from *activitylogsql.ListForTenantRow) (ActivityLogEntry, error) { + return toGraphActivityLogEntry(&from.ActivityLogCombinedView) + }) + if err != nil { + return nil, err + } + + return &ActivityLogEntryConnection{ + Connection: *conn, + scope: &ActivityLogScope{Tenant: true}, + filter: filter, + }, nil +} + func ListForResource(ctx context.Context, resourceType ActivityLogEntryResourceType, resourceName string, page *pagination.Pagination, filter *ActivityLogFilter) (*ActivityLogEntryConnection, error) { q := db(ctx) @@ -121,6 +158,8 @@ func ListForResource(ctx context.Context, resourceType ActivityLogEntryResourceT Filter: withFilters(filter), ResourceTypes: withResourceTypes(filter), Environments: withEnvironments(filter), + From: withFrom(filter), + To: withTo(filter), }) if err != nil { return nil, err @@ -158,6 +197,8 @@ func ListForResourceTeamAndEnvironment(ctx context.Context, resourceType Activit Filter: withFilters(filter), ResourceTypes: withResourceTypes(filter), Environments: withEnvironments(filter), + From: withFrom(filter), + To: withTo(filter), }) if err != nil { return nil, err diff --git a/internal/activitylog/queries/activitylog.sql b/internal/activitylog/queries/activitylog.sql index 2dad2fec7..a41d46475 100644 --- a/internal/activitylog/queries/activitylog.sql +++ b/internal/activitylog/queries/activitylog.sql @@ -18,6 +18,49 @@ WHERE sqlc.narg('environments')::TEXT[] IS NULL OR environment = ANY (sqlc.narg('environments')::TEXT[]) ) + AND ( + sqlc.narg('from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('from')::TIMESTAMPTZ + ) + AND ( + sqlc.narg('to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('to')::TIMESTAMPTZ + ) +ORDER BY + created_at DESC +LIMIT + sqlc.arg('limit') +OFFSET + sqlc.arg('offset') +; + +-- name: ListForTenant :many +SELECT + sqlc.embed(activity_log_combined_view), + COUNT(*) OVER () AS total_count +FROM + activity_log_combined_view +WHERE + ( + sqlc.narg('filter')::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY (sqlc.narg('filter')::TEXT[]) + ) + AND ( + sqlc.narg('resource_types')::TEXT[] IS NULL + OR resource_type = ANY (sqlc.narg('resource_types')::TEXT[]) + ) + AND ( + sqlc.narg('environments')::TEXT[] IS NULL + OR environment = ANY (sqlc.narg('environments')::TEXT[]) + ) + AND ( + sqlc.narg('from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('from')::TIMESTAMPTZ + ) + AND ( + sqlc.narg('to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('to')::TIMESTAMPTZ + ) ORDER BY created_at DESC LIMIT @@ -47,6 +90,14 @@ WHERE sqlc.narg('environments')::TEXT[] IS NULL OR environment = ANY (sqlc.narg('environments')::TEXT[]) ) + AND ( + sqlc.narg('from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('from')::TIMESTAMPTZ + ) + AND ( + sqlc.narg('to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('to')::TIMESTAMPTZ + ) ORDER BY created_at DESC LIMIT @@ -78,6 +129,14 @@ WHERE sqlc.narg('environments')::TEXT[] IS NULL OR activity_log_combined_view.environment = ANY (sqlc.narg('environments')::TEXT[]) ) + AND ( + sqlc.narg('from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('from')::TIMESTAMPTZ + ) + AND ( + sqlc.narg('to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('to')::TIMESTAMPTZ + ) ORDER BY created_at DESC LIMIT @@ -133,6 +192,7 @@ ORDER BY SELECT resource_type, action, + COALESCE(team_slug, '') AS team_slug, COALESCE(environment, '') AS environment, COUNT(*) AS total_count, COUNT(*) FILTER ( @@ -149,6 +209,14 @@ SELECT sqlc.narg('filter_environments')::TEXT[] IS NULL OR environment = ANY (sqlc.narg('filter_environments')::TEXT[]) ) + AND ( + sqlc.narg('filter_from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('filter_from')::TIMESTAMPTZ + ) + AND ( + sqlc.narg('filter_to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('filter_to')::TIMESTAMPTZ + ) ) AS filtered_count FROM activity_log_combined_view @@ -169,13 +237,23 @@ WHERE sqlc.narg('environment_name')::TEXT IS NULL OR environment = sqlc.narg('environment_name') ) + AND ( + sqlc.narg('from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('from')::TIMESTAMPTZ + ) + AND ( + sqlc.narg('to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('to')::TIMESTAMPTZ + ) GROUP BY resource_type, action, + team_slug, environment ORDER BY resource_type, action, + team_slug, environment ; diff --git a/internal/auth/authz/queries.go b/internal/auth/authz/queries.go index 7527e99bf..d6fae040f 100644 --- a/internal/auth/authz/queries.go +++ b/internal/auth/authz/queries.go @@ -174,6 +174,36 @@ func CanCreateServiceAccounts(ctx context.Context, teamSlug *slug.Slug) error { return requireAuthorization(ctx, "service_accounts:create", teamSlug) } +func CanReadActivityLogs(ctx context.Context) error { + user := ActorFromContext(ctx).User + var ( + authorized bool + err error + ) + + if user.IsServiceAccount() { + authorized, err = db(ctx).ServiceAccountHasAnyAuthorization(ctx, authzsql.ServiceAccountHasAnyAuthorizationParams{ + ServiceAccountID: user.GetID(), + AuthorizationName: "activity_logs:read", + }) + } else { + authorized, err = db(ctx).HasAnyAuthorization(ctx, authzsql.HasAnyAuthorizationParams{ + UserID: user.GetID(), + AuthorizationName: "activity_logs:read", + }) + } + + if err != nil { + return err + } + + if authorized { + return nil + } + + return newMissingAuthorizationError("activity_logs:read") +} + func CanUpdateServiceAccounts(ctx context.Context, teamSlug *slug.Slug) error { return requireAuthorization(ctx, "service_accounts:update", teamSlug) } diff --git a/internal/auth/authz/queries/roles.sql b/internal/auth/authz/queries/roles.sql index 22c3e637b..7cc5092a2 100644 --- a/internal/auth/authz/queries/roles.sql +++ b/internal/auth/authz/queries/roles.sql @@ -227,6 +227,32 @@ SELECT )::BOOLEAN ; +-- name: HasAnyAuthorization :one +SELECT + ( + EXISTS ( + SELECT + 1 + FROM + authorizations a + INNER JOIN role_authorizations ra ON ra.authorization_name = a.name + INNER JOIN user_roles ur ON ur.role_name = ra.role_name + WHERE + ur.user_id = @user_id + AND a.name = @authorization_name + ) + OR EXISTS ( + SELECT + 1 + FROM + users + WHERE + id = @user_id + AND admin = TRUE + ) + )::BOOLEAN +; + -- name: ServiceAccountHasTeamAuthorization :one SELECT ( @@ -266,6 +292,22 @@ SELECT )::BOOLEAN ; +-- name: ServiceAccountHasAnyAuthorization :one +SELECT + ( + EXISTS ( + SELECT + 1 + FROM + role_authorizations ra + INNER JOIN service_account_roles sar ON sar.role_name = ra.role_name + WHERE + sar.service_account_id = @service_account_id + AND ra.authorization_name = @authorization_name + ) + )::BOOLEAN +; + -- name: ServiceAccountHasRole :one SELECT EXISTS ( diff --git a/internal/graph/activitylog.resolvers.go b/internal/graph/activitylog.resolvers.go index 2dc0c107e..b03d256d4 100644 --- a/internal/graph/activitylog.resolvers.go +++ b/internal/graph/activitylog.resolvers.go @@ -4,6 +4,7 @@ import ( "context" "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/auth/authz" "github.com/nais/api/internal/environmentmapper" "github.com/nais/api/internal/graph/gengql" "github.com/nais/api/internal/graph/pagination" @@ -34,6 +35,19 @@ func (r *openSearchResolver) ActivityLog(ctx context.Context, obj *opensearch.Op ) } +func (r *queryResolver) TenantActivityLog(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *activitylog.ActivityLogFilter) (*activitylog.ActivityLogEntryConnection, error) { + if err := authz.CanReadActivityLogs(ctx); err != nil { + return nil, err + } + + page, err := pagination.ParsePage(first, after, last, before) + if err != nil { + return nil, err + } + + return activitylog.ListForTenant(ctx, page, filter) +} + func (r *reconcilerResolver) ActivityLog(ctx context.Context, obj *reconciler.Reconciler, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *activitylog.ActivityLogFilter) (*activitylog.ActivityLogEntryConnection, error) { page, err := pagination.ParsePage(first, after, last, before) if err != nil { diff --git a/internal/graph/schema/activitylog.graphqls b/internal/graph/schema/activitylog.graphqls index ee3f1368d..5d58d6f18 100644 --- a/internal/graph/schema/activitylog.graphqls +++ b/internal/graph/schema/activitylog.graphqls @@ -1,3 +1,35 @@ +extend type Query { + """ + Activity log across all teams in the tenant. + """ + tenantActivityLog( + """ + 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 + + """ + Filter items. + """ + filter: ActivityLogFilter + ): ActivityLogEntryConnection! +} + extend type Team implements ActivityLogger { """ Activity log associated with the team. @@ -174,6 +206,18 @@ input ActivityLogFilter { When combined with other fields in this input, entries must match this filter as well as the other selected filters. """ environments: [String!] + + """ + Only include entries created at or after this timestamp. + When combined with other fields in this input, entries must match this filter as well as the other selected filters. + """ + from: Time + + """ + Only include entries created before this timestamp. + When combined with other fields in this input, entries must match this filter as well as the other selected filters. + """ + to: Time } enum ActivityLogActivityType @@ -277,6 +321,11 @@ type ActivityLogFacets { Distribution of entries by environment. """ environments: [StringFacetItem!]! + + """ + Distribution of entries by team slug. + """ + teams: [StringFacetItem!]! } """ From 0fd0aae6f8b4e28f75fe5ce1a2a7f4ce8928c356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Thu, 25 Jun 2026 15:24:56 +0200 Subject: [PATCH 02/24] Regenerate activity log code --- .../activitylogsql/activitylog.sql.go | 183 ++++++++++++++++-- .../activitylog/activitylogsql/querier.go | 1 + internal/auth/authz/authzsql/querier.go | 2 + internal/auth/authz/authzsql/roles.sql.go | 66 +++++++ .../graph/gengql/activitylog.generated.go | 53 ++++- internal/graph/gengql/complexity.go | 3 + internal/graph/gengql/root_.generated.go | 74 ++++++- internal/graph/gengql/schema.generated.go | 127 +++++++++++- 8 files changed, 486 insertions(+), 23 deletions(-) diff --git a/internal/activitylog/activitylogsql/activitylog.sql.go b/internal/activitylog/activitylogsql/activitylog.sql.go index 9a500922a..6c200698e 100644 --- a/internal/activitylog/activitylogsql/activitylog.sql.go +++ b/internal/activitylog/activitylogsql/activitylog.sql.go @@ -7,6 +7,7 @@ import ( "context" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" "github.com/nais/api/internal/slug" ) @@ -60,6 +61,7 @@ const facets = `-- name: Facets :many SELECT resource_type, action, + COALESCE(team_slug, '') AS team_slug, COALESCE(environment, '') AS environment, COUNT(*) AS total_count, COUNT(*) FILTER ( @@ -76,33 +78,51 @@ SELECT $3::TEXT[] IS NULL OR environment = ANY ($3::TEXT[]) ) + AND ( + $4::TIMESTAMPTZ IS NULL + OR created_at >= $4::TIMESTAMPTZ + ) + AND ( + $5::TIMESTAMPTZ IS NULL + OR created_at < $5::TIMESTAMPTZ + ) ) AS filtered_count FROM activity_log_combined_view WHERE ( - $4::TEXT IS NULL - OR team_slug = $4 + $6::TEXT IS NULL + OR team_slug = $6 ) AND ( - $5::TEXT IS NULL - OR resource_type = $5 + $7::TEXT IS NULL + OR resource_type = $7 ) AND ( - $6::TEXT IS NULL - OR resource_name = $6 + $8::TEXT IS NULL + OR resource_name = $8 ) AND ( - $7::TEXT IS NULL - OR environment = $7 + $9::TEXT IS NULL + OR environment = $9 + ) + AND ( + $10::TIMESTAMPTZ IS NULL + OR created_at >= $10::TIMESTAMPTZ + ) + AND ( + $11::TIMESTAMPTZ IS NULL + OR created_at < $11::TIMESTAMPTZ ) GROUP BY resource_type, action, + team_slug, environment ORDER BY resource_type, action, + team_slug, environment ` @@ -110,15 +130,20 @@ type FacetsParams struct { Filter []string FilterResourceTypes []string FilterEnvironments []string + FilterFrom pgtype.Timestamptz + FilterTo pgtype.Timestamptz TeamSlug *string ResourceType *string ResourceName *string EnvironmentName *string + From pgtype.Timestamptz + To pgtype.Timestamptz } type FacetsRow struct { ResourceType string Action string + TeamSlug *slug.Slug Environment string TotalCount int64 FilteredCount int64 @@ -129,10 +154,14 @@ func (q *Queries) Facets(ctx context.Context, arg FacetsParams) ([]*FacetsRow, e arg.Filter, arg.FilterResourceTypes, arg.FilterEnvironments, + arg.FilterFrom, + arg.FilterTo, arg.TeamSlug, arg.ResourceType, arg.ResourceName, arg.EnvironmentName, + arg.From, + arg.To, ) if err != nil { return nil, err @@ -144,6 +173,7 @@ func (q *Queries) Facets(ctx context.Context, arg FacetsParams) ([]*FacetsRow, e if err := rows.Scan( &i.ResourceType, &i.Action, + &i.TeamSlug, &i.Environment, &i.TotalCount, &i.FilteredCount, @@ -246,12 +276,20 @@ WHERE $5::TEXT[] IS NULL OR environment = ANY ($5::TEXT[]) ) + AND ( + $6::TIMESTAMPTZ IS NULL + OR created_at >= $6::TIMESTAMPTZ + ) + AND ( + $7::TIMESTAMPTZ IS NULL + OR created_at < $7::TIMESTAMPTZ + ) ORDER BY created_at DESC LIMIT - $7 + $9 OFFSET - $6 + $8 ` type ListForResourceParams struct { @@ -260,6 +298,8 @@ type ListForResourceParams struct { Filter []string ResourceTypes []string Environments []string + From pgtype.Timestamptz + To pgtype.Timestamptz Offset int32 Limit int32 } @@ -276,6 +316,8 @@ func (q *Queries) ListForResource(ctx context.Context, arg ListForResourceParams arg.Filter, arg.ResourceTypes, arg.Environments, + arg.From, + arg.To, arg.Offset, arg.Limit, ) @@ -331,12 +373,20 @@ WHERE $7::TEXT[] IS NULL OR activity_log_combined_view.environment = ANY ($7::TEXT[]) ) + AND ( + $8::TIMESTAMPTZ IS NULL + OR created_at >= $8::TIMESTAMPTZ + ) + AND ( + $9::TIMESTAMPTZ IS NULL + OR created_at < $9::TIMESTAMPTZ + ) ORDER BY created_at DESC LIMIT - $9 + $11 OFFSET - $8 + $10 ` type ListForResourceTeamAndEnvironmentParams struct { @@ -347,6 +397,8 @@ type ListForResourceTeamAndEnvironmentParams struct { Filter []string ResourceTypes []string Environments []string + From pgtype.Timestamptz + To pgtype.Timestamptz Offset int32 Limit int32 } @@ -365,6 +417,8 @@ func (q *Queries) ListForResourceTeamAndEnvironment(ctx context.Context, arg Lis arg.Filter, arg.ResourceTypes, arg.Environments, + arg.From, + arg.To, arg.Offset, arg.Limit, ) @@ -417,12 +471,20 @@ WHERE $4::TEXT[] IS NULL OR environment = ANY ($4::TEXT[]) ) + AND ( + $5::TIMESTAMPTZ IS NULL + OR created_at >= $5::TIMESTAMPTZ + ) + AND ( + $6::TIMESTAMPTZ IS NULL + OR created_at < $6::TIMESTAMPTZ + ) ORDER BY created_at DESC LIMIT - $6 + $8 OFFSET - $5 + $7 ` type ListForTeamParams struct { @@ -430,6 +492,8 @@ type ListForTeamParams struct { Filter []string ResourceTypes []string Environments []string + From pgtype.Timestamptz + To pgtype.Timestamptz Offset int32 Limit int32 } @@ -445,6 +509,8 @@ func (q *Queries) ListForTeam(ctx context.Context, arg ListForTeamParams) ([]*Li arg.Filter, arg.ResourceTypes, arg.Environments, + arg.From, + arg.To, arg.Offset, arg.Limit, ) @@ -477,6 +543,95 @@ func (q *Queries) ListForTeam(ctx context.Context, arg ListForTeamParams) ([]*Li return items, nil } +const listForTenant = `-- name: ListForTenant :many +SELECT + activity_log_combined_view.id, activity_log_combined_view.created_at, activity_log_combined_view.actor, activity_log_combined_view.action, activity_log_combined_view.resource_type, activity_log_combined_view.resource_name, activity_log_combined_view.team_slug, activity_log_combined_view.data, activity_log_combined_view.environment, + COUNT(*) OVER () AS total_count +FROM + activity_log_combined_view +WHERE + ( + $1::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY ($1::TEXT[]) + ) + AND ( + $2::TEXT[] IS NULL + OR resource_type = ANY ($2::TEXT[]) + ) + AND ( + $3::TEXT[] IS NULL + OR environment = ANY ($3::TEXT[]) + ) + AND ( + $4::TIMESTAMPTZ IS NULL + OR created_at >= $4::TIMESTAMPTZ + ) + AND ( + $5::TIMESTAMPTZ IS NULL + OR created_at < $5::TIMESTAMPTZ + ) +ORDER BY + created_at DESC +LIMIT + $7 +OFFSET + $6 +` + +type ListForTenantParams struct { + Filter []string + ResourceTypes []string + Environments []string + From pgtype.Timestamptz + To pgtype.Timestamptz + Offset int32 + Limit int32 +} + +type ListForTenantRow struct { + ActivityLogCombinedView ActivityLogCombinedView + TotalCount int64 +} + +func (q *Queries) ListForTenant(ctx context.Context, arg ListForTenantParams) ([]*ListForTenantRow, error) { + rows, err := q.db.Query(ctx, listForTenant, + arg.Filter, + arg.ResourceTypes, + arg.Environments, + arg.From, + arg.To, + arg.Offset, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*ListForTenantRow{} + for rows.Next() { + var i ListForTenantRow + if err := rows.Scan( + &i.ActivityLogCombinedView.ID, + &i.ActivityLogCombinedView.CreatedAt, + &i.ActivityLogCombinedView.Actor, + &i.ActivityLogCombinedView.Action, + &i.ActivityLogCombinedView.ResourceType, + &i.ActivityLogCombinedView.ResourceName, + &i.ActivityLogCombinedView.TeamSlug, + &i.ActivityLogCombinedView.Data, + &i.ActivityLogCombinedView.Environment, + &i.TotalCount, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const refreshMaterializedView = `-- name: RefreshMaterializedView :exec REFRESH MATERIALIZED VIEW CONCURRENTLY activity_log_subset_mat_view ` diff --git a/internal/activitylog/activitylogsql/querier.go b/internal/activitylog/activitylogsql/querier.go index 155f17041..25752ea44 100644 --- a/internal/activitylog/activitylogsql/querier.go +++ b/internal/activitylog/activitylogsql/querier.go @@ -16,6 +16,7 @@ type Querier interface { ListForResource(ctx context.Context, arg ListForResourceParams) ([]*ListForResourceRow, error) ListForResourceTeamAndEnvironment(ctx context.Context, arg ListForResourceTeamAndEnvironmentParams) ([]*ListForResourceTeamAndEnvironmentRow, error) ListForTeam(ctx context.Context, arg ListForTeamParams) ([]*ListForTeamRow, error) + ListForTenant(ctx context.Context, arg ListForTenantParams) ([]*ListForTenantRow, error) RefreshMaterializedView(ctx context.Context) error } diff --git a/internal/auth/authz/authzsql/querier.go b/internal/auth/authz/authzsql/querier.go index 872dac734..2aeeaef51 100644 --- a/internal/auth/authz/authzsql/querier.go +++ b/internal/auth/authz/authzsql/querier.go @@ -19,6 +19,7 @@ type Querier interface { // TODO: This should be rewritten to fetch rows from the roles table instead as it uses the authz.Role struct, which reflects rows from the roles table. GetRolesForUsers(ctx context.Context, userIds []uuid.UUID) ([]*GetRolesForUsersRow, error) GitHubAuthorizationRoleCheck(ctx context.Context, arg GitHubAuthorizationRoleCheckParams) (bool, error) + HasAnyAuthorization(ctx context.Context, arg HasAnyAuthorizationParams) (bool, error) HasGlobalAuthorization(ctx context.Context, arg HasGlobalAuthorizationParams) (bool, error) HasTeamAuthorization(ctx context.Context, arg HasTeamAuthorizationParams) (bool, error) // Strict team membership check WITHOUT admin bypass @@ -28,6 +29,7 @@ type Querier interface { ListRolesForServiceAccount(ctx context.Context, arg ListRolesForServiceAccountParams) ([]*Role, error) RevokeRoleFromServiceAccount(ctx context.Context, arg RevokeRoleFromServiceAccountParams) error ServiceAccountCanAssignRole(ctx context.Context, arg ServiceAccountCanAssignRoleParams) (bool, error) + ServiceAccountHasAnyAuthorization(ctx context.Context, arg ServiceAccountHasAnyAuthorizationParams) (bool, error) ServiceAccountHasGlobalAuthorization(ctx context.Context, arg ServiceAccountHasGlobalAuthorizationParams) (bool, error) ServiceAccountHasRole(ctx context.Context, arg ServiceAccountHasRoleParams) (bool, error) ServiceAccountHasTeamAuthorization(ctx context.Context, arg ServiceAccountHasTeamAuthorizationParams) (bool, error) diff --git a/internal/auth/authz/authzsql/roles.sql.go b/internal/auth/authz/authzsql/roles.sql.go index 8199594e0..6762f9f14 100644 --- a/internal/auth/authz/authzsql/roles.sql.go +++ b/internal/auth/authz/authzsql/roles.sql.go @@ -235,6 +235,44 @@ func (q *Queries) GitHubAuthorizationRoleCheck(ctx context.Context, arg GitHubAu return column_1, err } +const hasAnyAuthorization = `-- name: HasAnyAuthorization :one +SELECT + ( + EXISTS ( + SELECT + 1 + FROM + authorizations a + INNER JOIN role_authorizations ra ON ra.authorization_name = a.name + INNER JOIN user_roles ur ON ur.role_name = ra.role_name + WHERE + ur.user_id = $1 + AND a.name = $2 + ) + OR EXISTS ( + SELECT + 1 + FROM + users + WHERE + id = $1 + AND admin = TRUE + ) + )::BOOLEAN +` + +type HasAnyAuthorizationParams struct { + UserID uuid.UUID + AuthorizationName string +} + +func (q *Queries) HasAnyAuthorization(ctx context.Context, arg HasAnyAuthorizationParams) (bool, error) { + row := q.db.QueryRow(ctx, hasAnyAuthorization, arg.UserID, arg.AuthorizationName) + var column_1 bool + err := row.Scan(&column_1) + return column_1, err +} + const hasGlobalAuthorization = `-- name: HasGlobalAuthorization :one SELECT ( @@ -506,6 +544,34 @@ func (q *Queries) ServiceAccountCanAssignRole(ctx context.Context, arg ServiceAc return column_1, err } +const serviceAccountHasAnyAuthorization = `-- name: ServiceAccountHasAnyAuthorization :one +SELECT + ( + EXISTS ( + SELECT + 1 + FROM + role_authorizations ra + INNER JOIN service_account_roles sar ON sar.role_name = ra.role_name + WHERE + sar.service_account_id = $1 + AND ra.authorization_name = $2 + ) + )::BOOLEAN +` + +type ServiceAccountHasAnyAuthorizationParams struct { + ServiceAccountID uuid.UUID + AuthorizationName string +} + +func (q *Queries) ServiceAccountHasAnyAuthorization(ctx context.Context, arg ServiceAccountHasAnyAuthorizationParams) (bool, error) { + row := q.db.QueryRow(ctx, serviceAccountHasAnyAuthorization, arg.ServiceAccountID, arg.AuthorizationName) + var column_1 bool + err := row.Scan(&column_1) + return column_1, err +} + const serviceAccountHasGlobalAuthorization = `-- name: ServiceAccountHasGlobalAuthorization :one SELECT ( diff --git a/internal/graph/gengql/activitylog.generated.go b/internal/graph/gengql/activitylog.generated.go index 49749210d..a49c0f40e 100644 --- a/internal/graph/gengql/activitylog.generated.go +++ b/internal/graph/gengql/activitylog.generated.go @@ -379,6 +379,38 @@ func (ec *executionContext) fieldContext_ActivityLogFacets_environments(_ contex return fc, nil } +func (ec *executionContext) _ActivityLogFacets_teams(ctx context.Context, field graphql.CollectedField, obj *activitylog.ActivityLogFacets) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_ActivityLogFacets_teams(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Teams, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []model.StringFacetItem) graphql.Marshaler { + return ec.marshalNStringFacetItem2ᚕgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋmodelᚐStringFacetItemᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_ActivityLogFacets_teams(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ActivityLogFacets", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_StringFacetItem(ctx, field) + }, + } + return fc, nil +} + func (ec *executionContext) _ActivityLogResourceTypeFacetItem_resourceType(ctx context.Context, field graphql.CollectedField, obj *activitylog.ActivityLogResourceTypeFacetItem) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -440,7 +472,7 @@ func (ec *executionContext) unmarshalInputActivityLogFilter(ctx context.Context, asMap[k] = v } - fieldsInOrder := [...]string{"activityTypes", "resourceTypes", "environments"} + fieldsInOrder := [...]string{"activityTypes", "resourceTypes", "environments", "from", "to"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -468,6 +500,20 @@ func (ec *executionContext) unmarshalInputActivityLogFilter(ctx context.Context, 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 @@ -1212,6 +1258,11 @@ func (ec *executionContext) _ActivityLogFacets(ctx context.Context, sel ast.Sele if out.Values[i] == graphql.Null { out.Invalids++ } + case "teams": + out.Values[i] = ec._ActivityLogFacets_teams(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } diff --git a/internal/graph/gengql/complexity.go b/internal/graph/gengql/complexity.go index 31032e5bd..3f697586a 100644 --- a/internal/graph/gengql/complexity.go +++ b/internal/graph/gengql/complexity.go @@ -145,6 +145,9 @@ func NewComplexityRoot() ComplexityRoot { c.Query.Teams = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *team.TeamOrder, filter *team.TeamFilter) int { return cursorComplexity(first, last) * childComplexity } + c.Query.TenantActivityLog = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *activitylog.ActivityLogFilter) int { + return cursorComplexity(first, last) * childComplexity + } c.Query.UserSyncLog = 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/root_.generated.go b/internal/graph/gengql/root_.generated.go index 55ecedcd3..f98708720 100644 --- a/internal/graph/gengql/root_.generated.go +++ b/internal/graph/gengql/root_.generated.go @@ -181,6 +181,7 @@ type ComplexityRoot struct { ActivityTypes func(childComplexity int) int Environments func(childComplexity int) int ResourceTypes func(childComplexity int) int + Teams func(childComplexity int) int } ActivityLogResourceTypeFacetItem struct { @@ -1875,6 +1876,7 @@ type ComplexityRoot struct { Team func(childComplexity int, slug slug.Slug) int Teams func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *team.TeamOrder, filter *team.TeamFilter) int TeamsUtilization func(childComplexity int, resourceType utilization.UtilizationResourceType) int + TenantActivityLog func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *activitylog.ActivityLogFilter) int UnleashReleaseChannels func(childComplexity int) int User func(childComplexity int, email *string) int UserSyncLog func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int @@ -3766,6 +3768,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.ActivityLogFacets.ResourceTypes(childComplexity), true + case "ActivityLogFacets.teams": + if e.ComplexityRoot.ActivityLogFacets.Teams == nil { + break + } + + return e.ComplexityRoot.ActivityLogFacets.Teams(childComplexity), true + case "ActivityLogResourceTypeFacetItem.count": if e.ComplexityRoot.ActivityLogResourceTypeFacetItem.Count == nil { break @@ -11302,6 +11311,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.Query.TeamsUtilization(childComplexity, args["resourceType"].(utilization.UtilizationResourceType)), true + case "Query.tenantActivityLog": + if e.ComplexityRoot.Query.TenantActivityLog == nil { + break + } + + args, err := ec.field_Query_tenantActivityLog_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Query.TenantActivityLog(childComplexity, args["first"].(*int), args["after"].(*pagination.Cursor), args["last"].(*int), args["before"].(*pagination.Cursor), args["filter"].(*activitylog.ActivityLogFilter)), true + case "Query.unleashReleaseChannels": if e.ComplexityRoot.Query.UnleashReleaseChannels == nil { break @@ -19278,7 +19299,39 @@ func newExecutionContext( } var sources = []*ast.Source{ - {Name: "../schema/activitylog.graphqls", Input: `extend type Team implements ActivityLogger { + {Name: "../schema/activitylog.graphqls", Input: `extend type Query { + """ + Activity log across all teams in the tenant. + """ + tenantActivityLog( + """ + 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 + + """ + Filter items. + """ + filter: ActivityLogFilter + ): ActivityLogEntryConnection! +} + +extend type Team implements ActivityLogger { """ Activity log associated with the team. """ @@ -19454,6 +19507,18 @@ input ActivityLogFilter { When combined with other fields in this input, entries must match this filter as well as the other selected filters. """ environments: [String!] + + """ + Only include entries created at or after this timestamp. + When combined with other fields in this input, entries must match this filter as well as the other selected filters. + """ + from: Time + + """ + Only include entries created before this timestamp. + When combined with other fields in this input, entries must match this filter as well as the other selected filters. + """ + to: Time } enum ActivityLogActivityType @@ -19557,6 +19622,11 @@ type ActivityLogFacets { Distribution of entries by environment. """ environments: [StringFacetItem!]! + + """ + Distribution of entries by team slug. + """ + teams: [StringFacetItem!]! } """ @@ -32476,6 +32546,8 @@ func (ec *executionContext) childFields_ActivityLogFacets(ctx context.Context, f return ec.fieldContext_ActivityLogFacets_resourceTypes(ctx, field) case "environments": return ec.fieldContext_ActivityLogFacets_environments(ctx, field) + case "teams": + return ec.fieldContext_ActivityLogFacets_teams(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type ActivityLogFacets", field.Name) } diff --git a/internal/graph/gengql/schema.generated.go b/internal/graph/gengql/schema.generated.go index 6a4f92eca..343cb1050 100644 --- a/internal/graph/gengql/schema.generated.go +++ b/internal/graph/gengql/schema.generated.go @@ -12,7 +12,7 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql/introspection" - activitylog1 "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/activitylog" "github.com/nais/api/internal/alerts" "github.com/nais/api/internal/auth/authz" "github.com/nais/api/internal/cost" @@ -42,7 +42,7 @@ import ( "github.com/nais/api/internal/search" "github.com/nais/api/internal/serviceaccount" "github.com/nais/api/internal/servicemaintenance" - "github.com/nais/api/internal/servicemaintenance/activitylog" + activitylog1 "github.com/nais/api/internal/servicemaintenance/activitylog" "github.com/nais/api/internal/slug" "github.com/nais/api/internal/team" "github.com/nais/api/internal/tunnel" @@ -133,6 +133,7 @@ type MutationResolver interface { } type QueryResolver interface { Node(ctx context.Context, id ident.Ident) (model.Node, error) + TenantActivityLog(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *activitylog.ActivityLogFilter) (*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) @@ -1547,6 +1548,52 @@ func (ec *executionContext) field_Query_teams_args(ctx context.Context, rawArgs return args, nil } +func (ec *executionContext) field_Query_tenantActivityLog_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, "filter", + func(ctx context.Context, v any) (*activitylog.ActivityLogFilter, error) { + return ec.unmarshalOActivityLogFilter2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐActivityLogFilter(ctx, v) + }) + if err != nil { + return nil, err + } + args["filter"] = arg4 + return args, nil +} + func (ec *executionContext) field_Query_userSyncLog_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -4760,6 +4807,50 @@ func (ec *executionContext) fieldContext_Query_node(ctx context.Context, field g return fc, nil } +func (ec *executionContext) _Query_tenantActivityLog(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_tenantActivityLog(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().TenantActivityLog(ctx, fc.Args["first"].(*int), fc.Args["after"].(*pagination.Cursor), fc.Args["last"].(*int), fc.Args["before"].(*pagination.Cursor), fc.Args["filter"].(*activitylog.ActivityLogFilter)) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *activitylog.ActivityLogEntryConnection) graphql.Marshaler { + return ec.marshalNActivityLogEntryConnection2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐActivityLogEntryConnection(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Query_tenantActivityLog(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_ActivityLogEntryConnection(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_tenantActivityLog_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Query_roles(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -6166,9 +6257,9 @@ func (ec *executionContext) _Node(ctx context.Context, sel ast.SelectionSet, obj return graphql.Null } return ec._SqlDatabase(ctx, sel, obj) - case activitylog.ServiceMaintenanceActivityLogEntry: + case activitylog1.ServiceMaintenanceActivityLogEntry: return ec._ServiceMaintenanceActivityLogEntry(ctx, sel, &obj) - case *activitylog.ServiceMaintenanceActivityLogEntry: + case *activitylog1.ServiceMaintenanceActivityLogEntry: if obj == nil { return graphql.Null } @@ -6502,9 +6593,9 @@ func (ec *executionContext) _Node(ctx context.Context, sel ast.SelectionSet, obj return graphql.Null } return ec._InvalidSpecIssue(ctx, sel, obj) - case activitylog1.GenericKubernetesResourceActivityLogEntry: + case activitylog.GenericKubernetesResourceActivityLogEntry: return ec._GenericKubernetesResourceActivityLogEntry(ctx, sel, &obj) - case *activitylog1.GenericKubernetesResourceActivityLogEntry: + case *activitylog.GenericKubernetesResourceActivityLogEntry: if obj == nil { return graphql.Null } @@ -6880,7 +6971,7 @@ func (ec *executionContext) _Node(ctx context.Context, sel ast.SelectionSet, obj return graphql.Null } return ec._Alert(ctx, sel, obj) - case activitylog1.ActivityLogEntry: + case activitylog.ActivityLogEntry: if obj == nil { return graphql.Null } @@ -7489,6 +7580,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 "tenantActivityLog": + 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_tenantActivityLog(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 "roles": field := field From 2eddb9a3dfa1cd2583142d82132080ca1c10995d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Thu, 25 Jun 2026 15:39:18 +0200 Subject: [PATCH 03/24] Fix service account team validation --- integration_tests/serviceaccounts_queries.lua | 2 +- internal/serviceaccount/queries.go | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/integration_tests/serviceaccounts_queries.lua b/integration_tests/serviceaccounts_queries.lua index 6381bdb74..54e2cf563 100644 --- a/integration_tests/serviceaccounts_queries.lua +++ b/integration_tests/serviceaccounts_queries.lua @@ -437,7 +437,7 @@ Test.gql("Create service account with non-existent team slug", function(t) errors = { { locations = NotNull(), - message = Contains("database encountered an error"), + message = Contains("The specified team was not found."), path = { "createServiceAccount", }, diff --git a/internal/serviceaccount/queries.go b/internal/serviceaccount/queries.go index 48478875d..9bbccf225 100644 --- a/internal/serviceaccount/queries.go +++ b/internal/serviceaccount/queries.go @@ -14,6 +14,7 @@ import ( "github.com/nais/api/internal/graph/pagination" "github.com/nais/api/internal/serviceaccount/serviceaccountsql" "github.com/nais/api/internal/slug" + "github.com/nais/api/internal/team" ) func Get(ctx context.Context, serviceAccountID uuid.UUID) (*ServiceAccount, error) { @@ -71,6 +72,12 @@ func Create(ctx context.Context, input CreateServiceAccountInput) (*ServiceAccou return nil, err } + if input.TeamSlug != nil { + if _, err := team.Get(ctx, *input.TeamSlug); err != nil { + return nil, err + } + } + var sa *serviceaccountsql.ServiceAccount err := database.Transaction(ctx, func(ctx context.Context) error { var err error @@ -402,12 +409,12 @@ func ListTokensForServiceAccount(ctx context.Context, page *pagination.Paginatio }), nil } -// TODO: Remove once static service accounts has been removed +// DeleteStaticServiceAccounts removes all static service accounts. func DeleteStaticServiceAccounts(ctx context.Context) error { return db(ctx).DeleteStaticServiceAccounts(ctx) } -// TODO: Remove once static service accounts has been removed +// CreateStaticServiceAccount creates a static service account. func CreateStaticServiceAccount(ctx context.Context, name string, roles []string, secret string) error { return database.Transaction(ctx, func(ctx context.Context) error { sa, err := db(ctx).Create(ctx, serviceaccountsql.CreateParams{ From 958d3829bf3b96f17c1d18e56fcdc8fbdf4b035d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Thu, 25 Jun 2026 16:13:54 +0200 Subject: [PATCH 04/24] Refine tenant activity log access --- integration_tests/tenant_activitylog.lua | 26 ------------------------ internal/graph/activitylog.resolvers.go | 5 ----- 2 files changed, 31 deletions(-) diff --git a/integration_tests/tenant_activitylog.lua b/integration_tests/tenant_activitylog.lua index 27a252d1c..a31228e17 100644 --- a/integration_tests/tenant_activitylog.lua +++ b/integration_tests/tenant_activitylog.lua @@ -1,7 +1,6 @@ local admin = User.new() admin:admin(true) local member = User.new() -local nonMember = User.new() local teamOne = Team.new("slug-1", "purpose", "#channel") local teamTwo = Team.new("slug-2", "purpose", "#channel") teamOne:addMember(member) @@ -168,28 +167,3 @@ Test.gql("Tenant activity log supports time filtering", function(t) }, } end) - -Test.gql("Tenant activity log requires activity_logs read authorization", function(t) - t.addHeader("x-user-email", nonMember:email()) - - t.query [[ - query { - tenantActivityLog(first: 1) { - nodes { - id - } - } - } - ]] - - t.check { - data = Null, - errors = { - { - locations = NotNull(), - message = Contains('you need the "activity_logs:read"'), - path = { "tenantActivityLog" }, - }, - }, - } -end) diff --git a/internal/graph/activitylog.resolvers.go b/internal/graph/activitylog.resolvers.go index b03d256d4..9d92c46d4 100644 --- a/internal/graph/activitylog.resolvers.go +++ b/internal/graph/activitylog.resolvers.go @@ -4,7 +4,6 @@ import ( "context" "github.com/nais/api/internal/activitylog" - "github.com/nais/api/internal/auth/authz" "github.com/nais/api/internal/environmentmapper" "github.com/nais/api/internal/graph/gengql" "github.com/nais/api/internal/graph/pagination" @@ -36,10 +35,6 @@ func (r *openSearchResolver) ActivityLog(ctx context.Context, obj *opensearch.Op } func (r *queryResolver) TenantActivityLog(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *activitylog.ActivityLogFilter) (*activitylog.ActivityLogEntryConnection, error) { - if err := authz.CanReadActivityLogs(ctx); err != nil { - return nil, err - } - page, err := pagination.ParsePage(first, after, last, before) if err != nil { return nil, err From 128638a631d1c39c16e80952a71852d5ac038847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Thu, 25 Jun 2026 16:13:56 +0200 Subject: [PATCH 05/24] Handle legacy vulnerability activity log rows --- internal/vulnerability/activitylog.go | 6 ++++++ internal/workload/config/queries.go | 8 ++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/vulnerability/activitylog.go b/internal/vulnerability/activitylog.go index 16fe963dc..f7dffe623 100644 --- a/internal/vulnerability/activitylog.go +++ b/internal/vulnerability/activitylog.go @@ -14,6 +14,12 @@ func init() { activitylog.RegisterTransformer(activityLogEntryResourceTypeVulnerability, func(entry activitylog.GenericActivityLogEntry) (activitylog.ActivityLogEntry, error) { switch entry.Action { case activitylog.ActivityLogEntryActionUpdated: + if len(entry.Data) == 0 { + return VulnerabilityUpdatedActivityLogEntry{ + GenericActivityLogEntry: entry.WithMessage("Updated vulnerability"), + }, nil + } + data, err := activitylog.UnmarshalData[VulnerabilityActivityLogEntryData](entry) if err != nil { return nil, fmt.Errorf("failed to unmarshal vulnerability updated activity log entry data: %w", err) diff --git a/internal/workload/config/queries.go b/internal/workload/config/queries.go index 3c9716824..49718509c 100644 --- a/internal/workload/config/queries.go +++ b/internal/workload/config/queries.go @@ -838,12 +838,8 @@ func valuesUpdatedFields(existing *Config, newData, newBinaryData map[string]any // Combine existing values into a single map for comparison oldValues := make(map[string]string, len(existing.Data)+len(existing.BinaryData)) - for k, v := range existing.Data { - oldValues[k] = v - } - for k, v := range existing.BinaryData { - oldValues[k] = v - } + maps.Copy(oldValues, existing.Data) + maps.Copy(oldValues, existing.BinaryData) // Combine new values into a single map newValues := make(map[string]string, len(newData)+len(newBinaryData)) From 67cc5ac17ff3200260b625fcc086c5666b547300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Thu, 25 Jun 2026 16:17:26 +0200 Subject: [PATCH 06/24] Upgrade bbolt to v1.5.0 to fix vulnerability --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 6a2944307..18323ad79 100644 --- a/go.mod +++ b/go.mod @@ -406,7 +406,7 @@ require ( github.com/zeebo/xxh3 v1.1.0 // indirect github.com/zitadel/logging v0.6.1 // indirect github.com/zitadel/schema v1.3.0 // indirect - go.etcd.io/bbolt v1.4.3 // indirect + go.etcd.io/bbolt v1.5.0 // indirect go.etcd.io/etcd/api/v3 v3.6.7 // indirect go.etcd.io/etcd/client/pkg/v3 v3.6.7 // indirect go.etcd.io/etcd/client/v3 v3.6.7 // indirect diff --git a/go.sum b/go.sum index 998dccb94..3a74e7b08 100644 --- a/go.sum +++ b/go.sum @@ -1121,6 +1121,8 @@ go.einride.tech/aip v0.79.0 h1:19zdPlZzlUvxOA8syAFw4LkdJdXepzyTl6gt9XEeqdU= go.einride.tech/aip v0.79.0/go.mod h1:E8+wdTApA70odnpFzJgsGogHozC2JCIhFJBKPr8bVig= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= +go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= +go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= go.etcd.io/etcd/api/v3 v3.6.7 h1:7BNJ2gQmc3DNM+9cRkv7KkGQDayElg8x3X+tFDYS+E0= go.etcd.io/etcd/api/v3 v3.6.7/go.mod h1:xJ81TLj9hxrYYEDmXTeKURMeY3qEDN24hqe+q7KhbnI= go.etcd.io/etcd/client/pkg/v3 v3.6.7 h1:vvzgyozz46q+TyeGBuFzVuI53/yd133CHceNb/AhBVs= From 90b691136b1346c71b18c163a177d82c7ec79be8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Thu, 25 Jun 2026 16:28:38 +0200 Subject: [PATCH 07/24] Rename tenantActivityLog to activityLog --- integration_tests/tenant_activitylog.lua | 8 +- internal/graph/activitylog.resolvers.go | 2 +- internal/graph/gengql/complexity.go | 6 +- internal/graph/gengql/root_.generated.go | 28 +++--- internal/graph/gengql/schema.generated.go | 108 ++++++++++----------- internal/graph/schema/activitylog.graphqls | 2 +- 6 files changed, 77 insertions(+), 77 deletions(-) diff --git a/integration_tests/tenant_activitylog.lua b/integration_tests/tenant_activitylog.lua index a31228e17..5c083d495 100644 --- a/integration_tests/tenant_activitylog.lua +++ b/integration_tests/tenant_activitylog.lua @@ -59,7 +59,7 @@ Test.gql("Tenant activity log returns facets with teams and pagination metadata" t.query [[ query { - tenantActivityLog(first: 10, filter: { activityTypes: [REPOSITORY_ADDED] }) { + activityLog(first: 10, filter: { activityTypes: [REPOSITORY_ADDED] }) { nodes { resourceName teamSlug @@ -92,7 +92,7 @@ Test.gql("Tenant activity log returns facets with teams and pagination metadata" t.check { data = { - tenantActivityLog = { + activityLog = { nodes = { { resourceName = "nais/api-team-two", @@ -142,7 +142,7 @@ Test.gql("Tenant activity log supports time filtering", function(t) t.query [[ query { - tenantActivityLog( + activityLog( first: 10 filter: { activityTypes: [REPOSITORY_ADDED], from: "9999-01-01T00:00:00Z" } ) { @@ -158,7 +158,7 @@ Test.gql("Tenant activity log supports time filtering", function(t) t.check { data = { - tenantActivityLog = { + activityLog = { nodes = {}, pageInfo = { totalCount = 0, diff --git a/internal/graph/activitylog.resolvers.go b/internal/graph/activitylog.resolvers.go index 9d92c46d4..ffc7baa00 100644 --- a/internal/graph/activitylog.resolvers.go +++ b/internal/graph/activitylog.resolvers.go @@ -34,7 +34,7 @@ func (r *openSearchResolver) ActivityLog(ctx context.Context, obj *opensearch.Op ) } -func (r *queryResolver) TenantActivityLog(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.ActivityLogFilter) (*activitylog.ActivityLogEntryConnection, error) { page, err := pagination.ParsePage(first, after, last, before) if err != nil { return nil, err diff --git a/internal/graph/gengql/complexity.go b/internal/graph/gengql/complexity.go index 3f697586a..8069f3e30 100644 --- a/internal/graph/gengql/complexity.go +++ b/internal/graph/gengql/complexity.go @@ -124,6 +124,9 @@ func NewComplexityRoot() ComplexityRoot { c.PostgresInstance.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 { + return cursorComplexity(first, last) * childComplexity + } c.Query.Cves = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *vulnerability.CVEOrder) int { return cursorComplexity(first, last) * childComplexity } @@ -145,9 +148,6 @@ func NewComplexityRoot() ComplexityRoot { c.Query.Teams = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *team.TeamOrder, filter *team.TeamFilter) int { return cursorComplexity(first, last) * childComplexity } - c.Query.TenantActivityLog = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *activitylog.ActivityLogFilter) int { - return cursorComplexity(first, last) * childComplexity - } c.Query.UserSyncLog = 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/root_.generated.go b/internal/graph/gengql/root_.generated.go index f98708720..b56e2c11b 100644 --- a/internal/graph/gengql/root_.generated.go +++ b/internal/graph/gengql/root_.generated.go @@ -1857,6 +1857,7 @@ type ComplexityRoot struct { } Query struct { + ActivityLog func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *activitylog.ActivityLogFilter) int CVE func(childComplexity int, identifier string) int CostMonthlySummary func(childComplexity int, from scalar.Date, to scalar.Date) int CurrentUnitPrices func(childComplexity int) int @@ -1876,7 +1877,6 @@ type ComplexityRoot struct { Team func(childComplexity int, slug slug.Slug) int Teams func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *team.TeamOrder, filter *team.TeamFilter) int TeamsUtilization func(childComplexity int, resourceType utilization.UtilizationResourceType) int - TenantActivityLog func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *activitylog.ActivityLogFilter) int UnleashReleaseChannels func(childComplexity int) int User func(childComplexity int, email *string) int UserSyncLog func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int @@ -11098,6 +11098,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.PrometheusAlert.TeamEnvironment(childComplexity), true + case "Query.activityLog": + if e.ComplexityRoot.Query.ActivityLog == nil { + break + } + + args, err := ec.field_Query_activityLog_args(ctx, rawArgs) + if err != nil { + 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 + case "Query.cve": if e.ComplexityRoot.Query.CVE == nil { break @@ -11311,18 +11323,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.Query.TeamsUtilization(childComplexity, args["resourceType"].(utilization.UtilizationResourceType)), true - case "Query.tenantActivityLog": - if e.ComplexityRoot.Query.TenantActivityLog == nil { - break - } - - args, err := ec.field_Query_tenantActivityLog_args(ctx, rawArgs) - if err != nil { - return 0, false - } - - return e.ComplexityRoot.Query.TenantActivityLog(childComplexity, args["first"].(*int), args["after"].(*pagination.Cursor), args["last"].(*int), args["before"].(*pagination.Cursor), args["filter"].(*activitylog.ActivityLogFilter)), true - case "Query.unleashReleaseChannels": if e.ComplexityRoot.Query.UnleashReleaseChannels == nil { break @@ -19303,7 +19303,7 @@ var sources = []*ast.Source{ """ Activity log across all teams in the tenant. """ - tenantActivityLog( + activityLog( """ Get the first n items in the connection. This can be used in combination with the after parameter. """ diff --git a/internal/graph/gengql/schema.generated.go b/internal/graph/gengql/schema.generated.go index 343cb1050..98dbd2564 100644 --- a/internal/graph/gengql/schema.generated.go +++ b/internal/graph/gengql/schema.generated.go @@ -133,7 +133,7 @@ type MutationResolver interface { } type QueryResolver interface { Node(ctx context.Context, id ident.Ident) (model.Node, error) - TenantActivityLog(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.ActivityLogFilter) (*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) @@ -1092,6 +1092,52 @@ func (ec *executionContext) field_Query___type_args(ctx context.Context, rawArgs return args, nil } +func (ec *executionContext) field_Query_activityLog_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, "filter", + func(ctx context.Context, v any) (*activitylog.ActivityLogFilter, error) { + return ec.unmarshalOActivityLogFilter2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐActivityLogFilter(ctx, v) + }) + if err != nil { + return nil, err + } + args["filter"] = arg4 + return args, nil +} + func (ec *executionContext) field_Query_costMonthlySummary_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1548,52 +1594,6 @@ func (ec *executionContext) field_Query_teams_args(ctx context.Context, rawArgs return args, nil } -func (ec *executionContext) field_Query_tenantActivityLog_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, "filter", - func(ctx context.Context, v any) (*activitylog.ActivityLogFilter, error) { - return ec.unmarshalOActivityLogFilter2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐActivityLogFilter(ctx, v) - }) - if err != nil { - return nil, err - } - args["filter"] = arg4 - return args, nil -} - func (ec *executionContext) field_Query_userSyncLog_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -4807,17 +4807,17 @@ func (ec *executionContext) fieldContext_Query_node(ctx context.Context, field g return fc, nil } -func (ec *executionContext) _Query_tenantActivityLog(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { +func (ec *executionContext) _Query_activityLog(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_tenantActivityLog(ctx, field) + return ec.fieldContext_Query_activityLog(ctx, field) }, func(ctx context.Context) (any, error) { fc := graphql.GetFieldContext(ctx) - return ec.Resolvers.Query().TenantActivityLog(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.ActivityLogFilter)) }, nil, func(ctx context.Context, selections ast.SelectionSet, v *activitylog.ActivityLogEntryConnection) graphql.Marshaler { @@ -4827,7 +4827,7 @@ func (ec *executionContext) _Query_tenantActivityLog(ctx context.Context, field true, ) } -func (ec *executionContext) fieldContext_Query_tenantActivityLog(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Query_activityLog(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Query", Field: field, @@ -4844,7 +4844,7 @@ func (ec *executionContext) fieldContext_Query_tenantActivityLog(ctx context.Con } }() ctx = graphql.WithFieldContext(ctx, fc) - if fc.Args, err = ec.field_Query_tenantActivityLog_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + if fc.Args, err = ec.field_Query_activityLog_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { ec.Error(ctx, err) return fc, err } @@ -7581,7 +7581,7 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) - case "tenantActivityLog": + case "activityLog": field := field innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { @@ -7590,7 +7590,7 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._Query_tenantActivityLog(ctx, field) + res = ec._Query_activityLog(ctx, field) if res == graphql.Null { atomic.AddUint32(&fs.Invalids, 1) } diff --git a/internal/graph/schema/activitylog.graphqls b/internal/graph/schema/activitylog.graphqls index 5d58d6f18..dd68637d3 100644 --- a/internal/graph/schema/activitylog.graphqls +++ b/internal/graph/schema/activitylog.graphqls @@ -2,7 +2,7 @@ extend type Query { """ Activity log across all teams in the tenant. """ - tenantActivityLog( + activityLog( """ Get the first n items in the connection. This can be used in combination with the after parameter. """ From fe8d26c15acb2d5aaa99ea23085b87e5acd07db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Thu, 25 Jun 2026 16:35:50 +0200 Subject: [PATCH 08/24] Fix team facet pollution by filtering empty slugs --- .../activitylog/activitylogsql/activitylog.sql.go | 2 +- internal/activitylog/facets.go | 11 ++++++++--- internal/activitylog/queries/activitylog.sql | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/internal/activitylog/activitylogsql/activitylog.sql.go b/internal/activitylog/activitylogsql/activitylog.sql.go index 6c200698e..40becdfe7 100644 --- a/internal/activitylog/activitylogsql/activitylog.sql.go +++ b/internal/activitylog/activitylogsql/activitylog.sql.go @@ -61,7 +61,7 @@ const facets = `-- name: Facets :many SELECT resource_type, action, - COALESCE(team_slug, '') AS team_slug, + team_slug, COALESCE(environment, '') AS environment, COUNT(*) AS total_count, COUNT(*) FILTER ( diff --git a/internal/activitylog/facets.go b/internal/activitylog/facets.go index 7bf238240..30356db9e 100644 --- a/internal/activitylog/facets.go +++ b/internal/activitylog/facets.go @@ -60,8 +60,10 @@ func buildFacets(rows []*activitylogsql.FacetsRow) *ActivityLogFacets { if row.TeamSlug != nil { teamSlug := row.TeamSlug.String() - if _, ok := teamCounts[teamSlug]; !ok { - teamCounts[teamSlug] = 0 + if teamSlug != "" { + if _, ok := teamCounts[teamSlug]; !ok { + teamCounts[teamSlug] = 0 + } } } @@ -80,7 +82,10 @@ func buildFacets(rows []*activitylogsql.FacetsRow) *ActivityLogFacets { } if row.TeamSlug != nil { - teamCounts[row.TeamSlug.String()] += filteredCount + teamSlug := row.TeamSlug.String() + if teamSlug != "" { + teamCounts[teamSlug] += filteredCount + } } for _, at := range LookupActivityTypes(row.ResourceType, row.Action) { diff --git a/internal/activitylog/queries/activitylog.sql b/internal/activitylog/queries/activitylog.sql index a41d46475..bf4250ec2 100644 --- a/internal/activitylog/queries/activitylog.sql +++ b/internal/activitylog/queries/activitylog.sql @@ -192,7 +192,7 @@ ORDER BY SELECT resource_type, action, - COALESCE(team_slug, '') AS team_slug, + team_slug, COALESCE(environment, '') AS environment, COUNT(*) AS total_count, COUNT(*) FILTER ( From 55436df756462af6a9a250bde3534fe08258ccd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Thu, 25 Jun 2026 17:02:12 +0200 Subject: [PATCH 09/24] Remove dead activity log authorization code --- internal/auth/authz/authzsql/querier.go | 2 - internal/auth/authz/authzsql/roles.sql.go | 66 ----------------------- internal/auth/authz/queries.go | 30 ----------- internal/auth/authz/queries/roles.sql | 42 --------------- 4 files changed, 140 deletions(-) diff --git a/internal/auth/authz/authzsql/querier.go b/internal/auth/authz/authzsql/querier.go index 2aeeaef51..872dac734 100644 --- a/internal/auth/authz/authzsql/querier.go +++ b/internal/auth/authz/authzsql/querier.go @@ -19,7 +19,6 @@ type Querier interface { // TODO: This should be rewritten to fetch rows from the roles table instead as it uses the authz.Role struct, which reflects rows from the roles table. GetRolesForUsers(ctx context.Context, userIds []uuid.UUID) ([]*GetRolesForUsersRow, error) GitHubAuthorizationRoleCheck(ctx context.Context, arg GitHubAuthorizationRoleCheckParams) (bool, error) - HasAnyAuthorization(ctx context.Context, arg HasAnyAuthorizationParams) (bool, error) HasGlobalAuthorization(ctx context.Context, arg HasGlobalAuthorizationParams) (bool, error) HasTeamAuthorization(ctx context.Context, arg HasTeamAuthorizationParams) (bool, error) // Strict team membership check WITHOUT admin bypass @@ -29,7 +28,6 @@ type Querier interface { ListRolesForServiceAccount(ctx context.Context, arg ListRolesForServiceAccountParams) ([]*Role, error) RevokeRoleFromServiceAccount(ctx context.Context, arg RevokeRoleFromServiceAccountParams) error ServiceAccountCanAssignRole(ctx context.Context, arg ServiceAccountCanAssignRoleParams) (bool, error) - ServiceAccountHasAnyAuthorization(ctx context.Context, arg ServiceAccountHasAnyAuthorizationParams) (bool, error) ServiceAccountHasGlobalAuthorization(ctx context.Context, arg ServiceAccountHasGlobalAuthorizationParams) (bool, error) ServiceAccountHasRole(ctx context.Context, arg ServiceAccountHasRoleParams) (bool, error) ServiceAccountHasTeamAuthorization(ctx context.Context, arg ServiceAccountHasTeamAuthorizationParams) (bool, error) diff --git a/internal/auth/authz/authzsql/roles.sql.go b/internal/auth/authz/authzsql/roles.sql.go index 6762f9f14..8199594e0 100644 --- a/internal/auth/authz/authzsql/roles.sql.go +++ b/internal/auth/authz/authzsql/roles.sql.go @@ -235,44 +235,6 @@ func (q *Queries) GitHubAuthorizationRoleCheck(ctx context.Context, arg GitHubAu return column_1, err } -const hasAnyAuthorization = `-- name: HasAnyAuthorization :one -SELECT - ( - EXISTS ( - SELECT - 1 - FROM - authorizations a - INNER JOIN role_authorizations ra ON ra.authorization_name = a.name - INNER JOIN user_roles ur ON ur.role_name = ra.role_name - WHERE - ur.user_id = $1 - AND a.name = $2 - ) - OR EXISTS ( - SELECT - 1 - FROM - users - WHERE - id = $1 - AND admin = TRUE - ) - )::BOOLEAN -` - -type HasAnyAuthorizationParams struct { - UserID uuid.UUID - AuthorizationName string -} - -func (q *Queries) HasAnyAuthorization(ctx context.Context, arg HasAnyAuthorizationParams) (bool, error) { - row := q.db.QueryRow(ctx, hasAnyAuthorization, arg.UserID, arg.AuthorizationName) - var column_1 bool - err := row.Scan(&column_1) - return column_1, err -} - const hasGlobalAuthorization = `-- name: HasGlobalAuthorization :one SELECT ( @@ -544,34 +506,6 @@ func (q *Queries) ServiceAccountCanAssignRole(ctx context.Context, arg ServiceAc return column_1, err } -const serviceAccountHasAnyAuthorization = `-- name: ServiceAccountHasAnyAuthorization :one -SELECT - ( - EXISTS ( - SELECT - 1 - FROM - role_authorizations ra - INNER JOIN service_account_roles sar ON sar.role_name = ra.role_name - WHERE - sar.service_account_id = $1 - AND ra.authorization_name = $2 - ) - )::BOOLEAN -` - -type ServiceAccountHasAnyAuthorizationParams struct { - ServiceAccountID uuid.UUID - AuthorizationName string -} - -func (q *Queries) ServiceAccountHasAnyAuthorization(ctx context.Context, arg ServiceAccountHasAnyAuthorizationParams) (bool, error) { - row := q.db.QueryRow(ctx, serviceAccountHasAnyAuthorization, arg.ServiceAccountID, arg.AuthorizationName) - var column_1 bool - err := row.Scan(&column_1) - return column_1, err -} - const serviceAccountHasGlobalAuthorization = `-- name: ServiceAccountHasGlobalAuthorization :one SELECT ( diff --git a/internal/auth/authz/queries.go b/internal/auth/authz/queries.go index d6fae040f..7527e99bf 100644 --- a/internal/auth/authz/queries.go +++ b/internal/auth/authz/queries.go @@ -174,36 +174,6 @@ func CanCreateServiceAccounts(ctx context.Context, teamSlug *slug.Slug) error { return requireAuthorization(ctx, "service_accounts:create", teamSlug) } -func CanReadActivityLogs(ctx context.Context) error { - user := ActorFromContext(ctx).User - var ( - authorized bool - err error - ) - - if user.IsServiceAccount() { - authorized, err = db(ctx).ServiceAccountHasAnyAuthorization(ctx, authzsql.ServiceAccountHasAnyAuthorizationParams{ - ServiceAccountID: user.GetID(), - AuthorizationName: "activity_logs:read", - }) - } else { - authorized, err = db(ctx).HasAnyAuthorization(ctx, authzsql.HasAnyAuthorizationParams{ - UserID: user.GetID(), - AuthorizationName: "activity_logs:read", - }) - } - - if err != nil { - return err - } - - if authorized { - return nil - } - - return newMissingAuthorizationError("activity_logs:read") -} - func CanUpdateServiceAccounts(ctx context.Context, teamSlug *slug.Slug) error { return requireAuthorization(ctx, "service_accounts:update", teamSlug) } diff --git a/internal/auth/authz/queries/roles.sql b/internal/auth/authz/queries/roles.sql index 7cc5092a2..22c3e637b 100644 --- a/internal/auth/authz/queries/roles.sql +++ b/internal/auth/authz/queries/roles.sql @@ -227,32 +227,6 @@ SELECT )::BOOLEAN ; --- name: HasAnyAuthorization :one -SELECT - ( - EXISTS ( - SELECT - 1 - FROM - authorizations a - INNER JOIN role_authorizations ra ON ra.authorization_name = a.name - INNER JOIN user_roles ur ON ur.role_name = ra.role_name - WHERE - ur.user_id = @user_id - AND a.name = @authorization_name - ) - OR EXISTS ( - SELECT - 1 - FROM - users - WHERE - id = @user_id - AND admin = TRUE - ) - )::BOOLEAN -; - -- name: ServiceAccountHasTeamAuthorization :one SELECT ( @@ -292,22 +266,6 @@ SELECT )::BOOLEAN ; --- name: ServiceAccountHasAnyAuthorization :one -SELECT - ( - EXISTS ( - SELECT - 1 - FROM - role_authorizations ra - INNER JOIN service_account_roles sar ON sar.role_name = ra.role_name - WHERE - sar.service_account_id = @service_account_id - AND ra.authorization_name = @authorization_name - ) - )::BOOLEAN -; - -- name: ServiceAccountHasRole :one SELECT EXISTS ( From 9bb8fa1a53468f7e982c142d0361a704bf1d1c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Thu, 25 Jun 2026 17:12:34 +0200 Subject: [PATCH 10/24] Fix service account race condition and remove unused Tenant scope field --- internal/activitylog/model.go | 1 - internal/activitylog/queries.go | 2 +- internal/serviceaccount/queries.go | 12 ++++++------ 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/internal/activitylog/model.go b/internal/activitylog/model.go index 8ecfea1ff..d53fd5e1b 100644 --- a/internal/activitylog/model.go +++ b/internal/activitylog/model.go @@ -45,7 +45,6 @@ func (c *ActivityLogEntryConnection) GetFilter() *ActivityLogFilter { return c.f // ActivityLogScope defines the base scope for an activity log query. type ActivityLogScope struct { - Tenant bool TeamSlug *slug.Slug ResourceType *string ResourceName *string diff --git a/internal/activitylog/queries.go b/internal/activitylog/queries.go index f06f6f314..ac61512a9 100644 --- a/internal/activitylog/queries.go +++ b/internal/activitylog/queries.go @@ -142,7 +142,7 @@ func ListForTenant(ctx context.Context, page *pagination.Pagination, filter *Act return &ActivityLogEntryConnection{ Connection: *conn, - scope: &ActivityLogScope{Tenant: true}, + scope: &ActivityLogScope{}, filter: filter, }, nil } diff --git a/internal/serviceaccount/queries.go b/internal/serviceaccount/queries.go index 9bbccf225..9d9554c5e 100644 --- a/internal/serviceaccount/queries.go +++ b/internal/serviceaccount/queries.go @@ -72,14 +72,14 @@ func Create(ctx context.Context, input CreateServiceAccountInput) (*ServiceAccou return nil, err } - if input.TeamSlug != nil { - if _, err := team.Get(ctx, *input.TeamSlug); err != nil { - return nil, err - } - } - var sa *serviceaccountsql.ServiceAccount err := database.Transaction(ctx, func(ctx context.Context) error { + if input.TeamSlug != nil { + if _, err := team.Get(ctx, *input.TeamSlug); err != nil { + return err + } + } + var err error sa, err = db(ctx).Create(ctx, serviceaccountsql.CreateParams{ Name: input.Name, From dafaa20445f177b2ee887ee3baa1c53b144de617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Fri, 26 Jun 2026 10:33:26 +0200 Subject: [PATCH 11/24] Remove empty data fallback from vulnerability transformer Deleted 554 corrupt activity_log_entries rows where resource_type='VULNERABILITY' and data is null. Now the schema constraint (data: VulnerabilityActivityLogEntryData!) is enforced genuinely instead of being paper-over-ed with empty structs. --- internal/vulnerability/activitylog.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/internal/vulnerability/activitylog.go b/internal/vulnerability/activitylog.go index f7dffe623..16fe963dc 100644 --- a/internal/vulnerability/activitylog.go +++ b/internal/vulnerability/activitylog.go @@ -14,12 +14,6 @@ func init() { activitylog.RegisterTransformer(activityLogEntryResourceTypeVulnerability, func(entry activitylog.GenericActivityLogEntry) (activitylog.ActivityLogEntry, error) { switch entry.Action { case activitylog.ActivityLogEntryActionUpdated: - if len(entry.Data) == 0 { - return VulnerabilityUpdatedActivityLogEntry{ - GenericActivityLogEntry: entry.WithMessage("Updated vulnerability"), - }, nil - } - data, err := activitylog.UnmarshalData[VulnerabilityActivityLogEntryData](entry) if err != nil { return nil, fmt.Errorf("failed to unmarshal vulnerability updated activity log entry data: %w", err) From 124fcf774805365b3c8e41789634a4c5009b316b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Fri, 26 Jun 2026 11:46:07 +0200 Subject: [PATCH 12/24] Address review feedback: comment on FilterFrom/FilterTo intent, remove unused member variable --- integration_tests/tenant_activitylog.lua | 3 --- internal/activitylog/facets.go | 3 +++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/integration_tests/tenant_activitylog.lua b/integration_tests/tenant_activitylog.lua index 5c083d495..935085e36 100644 --- a/integration_tests/tenant_activitylog.lua +++ b/integration_tests/tenant_activitylog.lua @@ -1,10 +1,7 @@ local admin = User.new() admin:admin(true) -local member = User.new() local teamOne = Team.new("slug-1", "purpose", "#channel") local teamTwo = Team.new("slug-2", "purpose", "#channel") -teamOne:addMember(member) -teamTwo:addMember(member) Test.gql("Create repository event for team one", function(t) t.addHeader("x-user-email", admin:email()) diff --git a/internal/activitylog/facets.go b/internal/activitylog/facets.go index 30356db9e..5fd5c835b 100644 --- a/internal/activitylog/facets.go +++ b/internal/activitylog/facets.go @@ -17,6 +17,9 @@ func ComputeFacets(ctx context.Context, scope *ActivityLogScope, filter *Activit ResourceType: scopeField(scope, func(s *ActivityLogScope) *string { return s.ResourceType }), ResourceName: scopeField(scope, func(s *ActivityLogScope) *string { return s.ResourceName }), EnvironmentName: scopeField(scope, func(s *ActivityLogScope) *string { return s.EnvironmentName }), + // From/To narrow the outer WHERE scope (which rows are candidates). + // FilterFrom/FilterTo narrow the inner COUNT(*) FILTER (which rows count as "selected"). + // Both are set to the same time range: the user's time filter applies to both. From: withFrom(filter), To: withTo(filter), Filter: withFilters(filter), From 4df521e7fcd2897e97847b291bdb70208c8ac9b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Fri, 26 Jun 2026 11:49:25 +0200 Subject: [PATCH 13/24] go mod tidy --- go.sum | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.sum b/go.sum index 3a74e7b08..072ae6985 100644 --- a/go.sum +++ b/go.sum @@ -1119,8 +1119,6 @@ github.com/zitadel/zitadel-go/v3 v3.4.3 h1:Ph+5wkXmxOpykCBjiUSjbhXidbDGgkeZF2/B9 github.com/zitadel/zitadel-go/v3 v3.4.3/go.mod h1:hylnXtN3ARxTDRO7OzgQTyRyGXFQ8gAIwTC3NAniRds= go.einride.tech/aip v0.79.0 h1:19zdPlZzlUvxOA8syAFw4LkdJdXepzyTl6gt9XEeqdU= go.einride.tech/aip v0.79.0/go.mod h1:E8+wdTApA70odnpFzJgsGogHozC2JCIhFJBKPr8bVig= -go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= -go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= go.etcd.io/etcd/api/v3 v3.6.7 h1:7BNJ2gQmc3DNM+9cRkv7KkGQDayElg8x3X+tFDYS+E0= From f192e30ac2525ad42454ede14ae4573d7ea5976a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Fri, 26 Jun 2026 11:55:25 +0200 Subject: [PATCH 14/24] fmt --- internal/activitylog/facets.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/activitylog/facets.go b/internal/activitylog/facets.go index 5fd5c835b..cc4ff4b95 100644 --- a/internal/activitylog/facets.go +++ b/internal/activitylog/facets.go @@ -13,10 +13,10 @@ func ComputeFacets(ctx context.Context, scope *ActivityLogScope, filter *Activit q := db(ctx) rows, err := q.Facets(ctx, activitylogsql.FacetsParams{ - TeamSlug: scopeField(scope, func(s *ActivityLogScope) *string { return (*string)(s.TeamSlug) }), - ResourceType: scopeField(scope, func(s *ActivityLogScope) *string { return s.ResourceType }), - ResourceName: scopeField(scope, func(s *ActivityLogScope) *string { return s.ResourceName }), - EnvironmentName: scopeField(scope, func(s *ActivityLogScope) *string { return s.EnvironmentName }), + TeamSlug: scopeField(scope, func(s *ActivityLogScope) *string { return (*string)(s.TeamSlug) }), + ResourceType: scopeField(scope, func(s *ActivityLogScope) *string { return s.ResourceType }), + ResourceName: scopeField(scope, func(s *ActivityLogScope) *string { return s.ResourceName }), + EnvironmentName: scopeField(scope, func(s *ActivityLogScope) *string { return s.EnvironmentName }), // From/To narrow the outer WHERE scope (which rows are candidates). // FilterFrom/FilterTo narrow the inner COUNT(*) FILTER (which rows count as "selected"). // Both are set to the same time range: the user's time filter applies to both. From e768a0ba7180e9a209a2d1fea51de30c9d5f5a99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Fri, 26 Jun 2026 12:17:09 +0200 Subject: [PATCH 15/24] Split Facets query to avoid cardinality explosion with many teams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously GROUP BY (resource_type, action, team_slug, environment) produced O(teams × resource_types × actions × environments) rows. With 200+ teams this could easily be 100k+ rows per request. Now uses two separate queries: - FacetsForActivityTypes: groups by (resource_type, action, environment) - FacetsForTeams: groups by (team_slug) only, skipped for non-tenant scope Worst case is now O(resource_types × actions × environments) + O(teams). --- .../activitylogsql/activitylog.sql.go | 126 ++++++++++++++++-- .../activitylog/activitylogsql/querier.go | 3 +- internal/activitylog/facets.go | 77 +++++++---- internal/activitylog/queries/activitylog.sql | 62 ++++++++- 4 files changed, 222 insertions(+), 46 deletions(-) diff --git a/internal/activitylog/activitylogsql/activitylog.sql.go b/internal/activitylog/activitylogsql/activitylog.sql.go index 40becdfe7..9c5722e58 100644 --- a/internal/activitylog/activitylogsql/activitylog.sql.go +++ b/internal/activitylog/activitylogsql/activitylog.sql.go @@ -57,11 +57,10 @@ func (q *Queries) Create(ctx context.Context, arg CreateParams) error { return err } -const facets = `-- name: Facets :many +const facetsForActivityTypes = `-- name: FacetsForActivityTypes :many SELECT resource_type, action, - team_slug, COALESCE(environment, '') AS environment, COUNT(*) AS total_count, COUNT(*) FILTER ( @@ -117,16 +116,14 @@ WHERE GROUP BY resource_type, action, - team_slug, environment ORDER BY resource_type, action, - team_slug, environment ` -type FacetsParams struct { +type FacetsForActivityTypesParams struct { Filter []string FilterResourceTypes []string FilterEnvironments []string @@ -140,17 +137,16 @@ type FacetsParams struct { To pgtype.Timestamptz } -type FacetsRow struct { +type FacetsForActivityTypesRow struct { ResourceType string Action string - TeamSlug *slug.Slug Environment string TotalCount int64 FilteredCount int64 } -func (q *Queries) Facets(ctx context.Context, arg FacetsParams) ([]*FacetsRow, error) { - rows, err := q.db.Query(ctx, facets, +func (q *Queries) FacetsForActivityTypes(ctx context.Context, arg FacetsForActivityTypesParams) ([]*FacetsForActivityTypesRow, error) { + rows, err := q.db.Query(ctx, facetsForActivityTypes, arg.Filter, arg.FilterResourceTypes, arg.FilterEnvironments, @@ -167,13 +163,12 @@ func (q *Queries) Facets(ctx context.Context, arg FacetsParams) ([]*FacetsRow, e return nil, err } defer rows.Close() - items := []*FacetsRow{} + items := []*FacetsForActivityTypesRow{} for rows.Next() { - var i FacetsRow + var i FacetsForActivityTypesRow if err := rows.Scan( &i.ResourceType, &i.Action, - &i.TeamSlug, &i.Environment, &i.TotalCount, &i.FilteredCount, @@ -188,6 +183,113 @@ func (q *Queries) Facets(ctx context.Context, arg FacetsParams) ([]*FacetsRow, e return items, nil } +const facetsForTeams = `-- name: FacetsForTeams :many +SELECT + team_slug, + COUNT(*) AS total_count, + COUNT(*) FILTER ( + WHERE + ( + $1::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY ($1::TEXT[]) + ) + AND ( + $2::TEXT[] IS NULL + OR resource_type = ANY ($2::TEXT[]) + ) + AND ( + $3::TEXT[] IS NULL + OR environment = ANY ($3::TEXT[]) + ) + AND ( + $4::TIMESTAMPTZ IS NULL + OR created_at >= $4::TIMESTAMPTZ + ) + AND ( + $5::TIMESTAMPTZ IS NULL + OR created_at < $5::TIMESTAMPTZ + ) + ) AS filtered_count +FROM + activity_log_combined_view +WHERE + team_slug IS NOT NULL + AND ( + $6::TEXT IS NULL + OR resource_type = $6 + ) + AND ( + $7::TEXT IS NULL + OR resource_name = $7 + ) + AND ( + $8::TEXT IS NULL + OR environment = $8 + ) + AND ( + $9::TIMESTAMPTZ IS NULL + OR created_at >= $9::TIMESTAMPTZ + ) + AND ( + $10::TIMESTAMPTZ IS NULL + OR created_at < $10::TIMESTAMPTZ + ) +GROUP BY + team_slug +ORDER BY + team_slug +` + +type FacetsForTeamsParams struct { + Filter []string + FilterResourceTypes []string + FilterEnvironments []string + FilterFrom pgtype.Timestamptz + FilterTo pgtype.Timestamptz + ResourceType *string + ResourceName *string + EnvironmentName *string + From pgtype.Timestamptz + To pgtype.Timestamptz +} + +type FacetsForTeamsRow struct { + TeamSlug *slug.Slug + TotalCount int64 + FilteredCount int64 +} + +func (q *Queries) FacetsForTeams(ctx context.Context, arg FacetsForTeamsParams) ([]*FacetsForTeamsRow, error) { + rows, err := q.db.Query(ctx, facetsForTeams, + arg.Filter, + arg.FilterResourceTypes, + arg.FilterEnvironments, + arg.FilterFrom, + arg.FilterTo, + arg.ResourceType, + arg.ResourceName, + arg.EnvironmentName, + arg.From, + arg.To, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*FacetsForTeamsRow{} + for rows.Next() { + var i FacetsForTeamsRow + if err := rows.Scan(&i.TeamSlug, &i.TotalCount, &i.FilteredCount); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const get = `-- name: Get :one SELECT id, created_at, actor, action, resource_type, resource_name, team_slug, data, environment diff --git a/internal/activitylog/activitylogsql/querier.go b/internal/activitylog/activitylogsql/querier.go index 25752ea44..4bc0efb6b 100644 --- a/internal/activitylog/activitylogsql/querier.go +++ b/internal/activitylog/activitylogsql/querier.go @@ -10,7 +10,8 @@ import ( type Querier interface { Create(ctx context.Context, arg CreateParams) error - Facets(ctx context.Context, arg FacetsParams) ([]*FacetsRow, error) + FacetsForActivityTypes(ctx context.Context, arg FacetsForActivityTypesParams) ([]*FacetsForActivityTypesRow, error) + FacetsForTeams(ctx context.Context, arg FacetsForTeamsParams) ([]*FacetsForTeamsRow, error) Get(ctx context.Context, id uuid.UUID) (*ActivityLogCombinedView, error) ListByIDs(ctx context.Context, ids []uuid.UUID) ([]*ActivityLogCombinedView, error) ListForResource(ctx context.Context, arg ListForResourceParams) ([]*ListForResourceRow, error) diff --git a/internal/activitylog/facets.go b/internal/activitylog/facets.go index cc4ff4b95..b20f694d0 100644 --- a/internal/activitylog/facets.go +++ b/internal/activitylog/facets.go @@ -12,14 +12,14 @@ import ( func ComputeFacets(ctx context.Context, scope *ActivityLogScope, filter *ActivityLogFilter) (*ActivityLogFacets, error) { q := db(ctx) - rows, err := q.Facets(ctx, activitylogsql.FacetsParams{ - TeamSlug: scopeField(scope, func(s *ActivityLogScope) *string { return (*string)(s.TeamSlug) }), - ResourceType: scopeField(scope, func(s *ActivityLogScope) *string { return s.ResourceType }), - ResourceName: scopeField(scope, func(s *ActivityLogScope) *string { return s.ResourceName }), - EnvironmentName: scopeField(scope, func(s *ActivityLogScope) *string { return s.EnvironmentName }), - // From/To narrow the outer WHERE scope (which rows are candidates). - // FilterFrom/FilterTo narrow the inner COUNT(*) FILTER (which rows count as "selected"). - // Both are set to the same time range: the user's time filter applies to both. + // From/To narrow the outer WHERE scope (which rows are candidates). + // FilterFrom/FilterTo narrow the inner COUNT(*) FILTER (which rows count as "selected"). + // Both are set to the same time range: the user's time filter applies to both. + activityTypeRows, err := q.FacetsForActivityTypes(ctx, activitylogsql.FacetsForActivityTypesParams{ + TeamSlug: scopeField(scope, func(s *ActivityLogScope) *string { return (*string)(s.TeamSlug) }), + ResourceType: scopeField(scope, func(s *ActivityLogScope) *string { return s.ResourceType }), + ResourceName: scopeField(scope, func(s *ActivityLogScope) *string { return s.ResourceName }), + EnvironmentName: scopeField(scope, func(s *ActivityLogScope) *string { return s.EnvironmentName }), From: withFrom(filter), To: withTo(filter), Filter: withFilters(filter), @@ -32,7 +32,29 @@ func ComputeFacets(ctx context.Context, scope *ActivityLogScope, filter *Activit return nil, err } - return buildFacets(rows), nil + // Team facets are only computed at tenant scope (when no team is specified), + // since a single-team scope always produces a trivial single-entry result. + // This avoids a multiplicative cardinality explosion (teams × resource_types × actions × environments). + var teamRows []*activitylogsql.FacetsForTeamsRow + if scope == nil || scope.TeamSlug == nil { + teamRows, err = q.FacetsForTeams(ctx, activitylogsql.FacetsForTeamsParams{ + ResourceType: scopeField(scope, func(s *ActivityLogScope) *string { return s.ResourceType }), + ResourceName: scopeField(scope, func(s *ActivityLogScope) *string { return s.ResourceName }), + EnvironmentName: scopeField(scope, func(s *ActivityLogScope) *string { return s.EnvironmentName }), + From: withFrom(filter), + To: withTo(filter), + Filter: withFilters(filter), + FilterResourceTypes: withResourceTypes(filter), + FilterEnvironments: withEnvironments(filter), + FilterFrom: withFrom(filter), + FilterTo: withTo(filter), + }) + if err != nil { + return nil, err + } + } + + return buildFacets(activityTypeRows, teamRows), nil } func scopeField(scope *ActivityLogScope, fn func(*ActivityLogScope) *string) *string { @@ -42,14 +64,14 @@ func scopeField(scope *ActivityLogScope, fn func(*ActivityLogScope) *string) *st return fn(scope) } -func buildFacets(rows []*activitylogsql.FacetsRow) *ActivityLogFacets { +func buildFacets(activityTypeRows []*activitylogsql.FacetsForActivityTypesRow, teamRows []*activitylogsql.FacetsForTeamsRow) *ActivityLogFacets { activityTypeCounts := map[ActivityLogActivityType]int{} resourceTypeCounts := map[ActivityLogEntryResourceType]int{} environmentCounts := map[string]int{} teamCounts := map[string]int{} - for _, row := range rows { - // Seed with total_count to ensure all values that exist in this scope are present + for _, row := range activityTypeRows { + // Seed with 0 to ensure all values that exist in this scope are present rt := ActivityLogEntryResourceType(row.ResourceType) if _, ok := resourceTypeCounts[rt]; !ok { resourceTypeCounts[rt] = 0 @@ -61,22 +83,12 @@ func buildFacets(rows []*activitylogsql.FacetsRow) *ActivityLogFacets { } } - if row.TeamSlug != nil { - teamSlug := row.TeamSlug.String() - if teamSlug != "" { - if _, ok := teamCounts[teamSlug]; !ok { - teamCounts[teamSlug] = 0 - } - } - } - for _, at := range LookupActivityTypes(row.ResourceType, row.Action) { if _, ok := activityTypeCounts[at]; !ok { activityTypeCounts[at] = 0 } } - // Now add the filtered counts filteredCount := int(row.FilteredCount) resourceTypeCounts[rt] += filteredCount @@ -84,18 +96,25 @@ func buildFacets(rows []*activitylogsql.FacetsRow) *ActivityLogFacets { environmentCounts[row.Environment] += filteredCount } - if row.TeamSlug != nil { - teamSlug := row.TeamSlug.String() - if teamSlug != "" { - teamCounts[teamSlug] += filteredCount - } - } - for _, at := range LookupActivityTypes(row.ResourceType, row.Action) { activityTypeCounts[at] += filteredCount } } + for _, row := range teamRows { + if row.TeamSlug == nil { + continue + } + teamSlug := row.TeamSlug.String() + if teamSlug == "" { + continue + } + if _, ok := teamCounts[teamSlug]; !ok { + teamCounts[teamSlug] = 0 + } + teamCounts[teamSlug] += int(row.FilteredCount) + } + return assembleFacets(activityTypeCounts, resourceTypeCounts, environmentCounts, teamCounts) } diff --git a/internal/activitylog/queries/activitylog.sql b/internal/activitylog/queries/activitylog.sql index bf4250ec2..13c260765 100644 --- a/internal/activitylog/queries/activitylog.sql +++ b/internal/activitylog/queries/activitylog.sql @@ -188,11 +188,10 @@ ORDER BY created_at DESC ; --- name: Facets :many +-- name: FacetsForActivityTypes :many SELECT resource_type, action, - team_slug, COALESCE(environment, '') AS environment, COUNT(*) AS total_count, COUNT(*) FILTER ( @@ -248,15 +247,70 @@ WHERE GROUP BY resource_type, action, - team_slug, environment ORDER BY resource_type, action, - team_slug, environment ; +-- name: FacetsForTeams :many +SELECT + team_slug, + COUNT(*) AS total_count, + COUNT(*) FILTER ( + WHERE + ( + sqlc.narg('filter')::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY (sqlc.narg('filter')::TEXT[]) + ) + AND ( + sqlc.narg('filter_resource_types')::TEXT[] IS NULL + OR resource_type = ANY (sqlc.narg('filter_resource_types')::TEXT[]) + ) + AND ( + sqlc.narg('filter_environments')::TEXT[] IS NULL + OR environment = ANY (sqlc.narg('filter_environments')::TEXT[]) + ) + AND ( + sqlc.narg('filter_from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('filter_from')::TIMESTAMPTZ + ) + AND ( + sqlc.narg('filter_to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('filter_to')::TIMESTAMPTZ + ) + ) AS filtered_count +FROM + activity_log_combined_view +WHERE + team_slug IS NOT NULL + AND ( + sqlc.narg('resource_type')::TEXT IS NULL + OR resource_type = sqlc.narg('resource_type') + ) + AND ( + sqlc.narg('resource_name')::TEXT IS NULL + OR resource_name = sqlc.narg('resource_name') + ) + AND ( + sqlc.narg('environment_name')::TEXT IS NULL + OR environment = sqlc.narg('environment_name') + ) + AND ( + sqlc.narg('from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('from')::TIMESTAMPTZ + ) + AND ( + sqlc.narg('to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('to')::TIMESTAMPTZ + ) +GROUP BY + team_slug +ORDER BY + team_slug +; + -- name: RefreshMaterializedView :exec REFRESH MATERIALIZED VIEW CONCURRENTLY activity_log_subset_mat_view ; From 5803435818ffc6fbb3a2505694ba041175b48d26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Fri, 26 Jun 2026 12:26:28 +0200 Subject: [PATCH 16/24] Add created_at index for tenant-level activity log time range queries --- .../0072_activity_log_created_at_index.sql | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 internal/database/migrations/0072_activity_log_created_at_index.sql diff --git a/internal/database/migrations/0072_activity_log_created_at_index.sql b/internal/database/migrations/0072_activity_log_created_at_index.sql new file mode 100644 index 000000000..28dbfe0ff --- /dev/null +++ b/internal/database/migrations/0072_activity_log_created_at_index.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- Add created_at index for tenant-level activity log queries that filter by time range +-- without a leading team_slug or resource_type predicate. +CREATE INDEX activity_log_entries_created_at_idx ON activity_log_entries (created_at DESC) +; + +CREATE INDEX activity_log_subset_mat_view_created_at_idx ON activity_log_subset_mat_view (created_at DESC) +; + +-- +goose Down +DROP INDEX IF EXISTS activity_log_entries_created_at_idx +; + +DROP INDEX IF EXISTS activity_log_subset_mat_view_created_at_idx +; From 524a41b7f7b041ffd50cfafcd3c56d160ca62b6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Fri, 26 Jun 2026 12:36:01 +0200 Subject: [PATCH 17/24] Remove redundant migration 0072 - created_at indexes already exist from migrations 0011 and 0037 --- .../0072_activity_log_created_at_index.sql | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 internal/database/migrations/0072_activity_log_created_at_index.sql diff --git a/internal/database/migrations/0072_activity_log_created_at_index.sql b/internal/database/migrations/0072_activity_log_created_at_index.sql deleted file mode 100644 index 28dbfe0ff..000000000 --- a/internal/database/migrations/0072_activity_log_created_at_index.sql +++ /dev/null @@ -1,15 +0,0 @@ --- +goose Up --- Add created_at index for tenant-level activity log queries that filter by time range --- without a leading team_slug or resource_type predicate. -CREATE INDEX activity_log_entries_created_at_idx ON activity_log_entries (created_at DESC) -; - -CREATE INDEX activity_log_subset_mat_view_created_at_idx ON activity_log_subset_mat_view (created_at DESC) -; - --- +goose Down -DROP INDEX IF EXISTS activity_log_entries_created_at_idx -; - -DROP INDEX IF EXISTS activity_log_subset_mat_view_created_at_idx -; From 208c3bf40f9fc3f5883ea1de1f6b24dc60cdb2c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Fri, 26 Jun 2026 12:52:19 +0200 Subject: [PATCH 18/24] Fix conflicting semconv schema URLs and bump go directive to 1.26.4 Upgrade semconv from v1.40.0 to v1.41.0 in http.go, metrics.go and pubsub.go to match otel/sdk which uses schema v1.41.0, resolving startup error: 'conflicting Schema URL: https://opentelemetry.io/schemas/1.41.0 and 1.40.0' --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 18323ad79..b48044248 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/nais/api -go 1.26.3 +go 1.26.4 tool ( github.com/99designs/gqlgen From 364136b7492266c7348aa19ba538a0b0c6c749a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Fri, 26 Jun 2026 13:24:38 +0200 Subject: [PATCH 19/24] Remove team facets from activity log --- integration_tests/tenant_activitylog.lua | 16 +-- .../activitylogsql/activitylog.sql.go | 107 ------------------ .../activitylog/activitylogsql/querier.go | 1 - internal/activitylog/facets.go | 54 +-------- internal/activitylog/model.go | 1 - internal/activitylog/queries/activitylog.sql | 57 ---------- .../graph/gengql/activitylog.generated.go | 37 ------ internal/graph/gengql/root_.generated.go | 14 --- internal/graph/schema/activitylog.graphqls | 4 - 9 files changed, 5 insertions(+), 286 deletions(-) diff --git a/integration_tests/tenant_activitylog.lua b/integration_tests/tenant_activitylog.lua index 935085e36..02b752baf 100644 --- a/integration_tests/tenant_activitylog.lua +++ b/integration_tests/tenant_activitylog.lua @@ -51,7 +51,7 @@ Test.gql("Create repository event for team two", function(t) } end) -Test.gql("Tenant activity log returns facets with teams and pagination metadata", function(t) +Test.gql("Tenant activity log returns facets and pagination metadata", function(t) t.addHeader("x-user-email", admin:email()) t.query [[ @@ -78,10 +78,6 @@ Test.gql("Tenant activity log returns facets with teams and pagination metadata" value count } - teams { - value - count - } } } } @@ -118,16 +114,6 @@ Test.gql("Tenant activity log returns facets with teams and pagination metadata" }, }, environments = {}, - teams = { - { - value = "slug-1", - count = 1, - }, - { - value = "slug-2", - count = 1, - }, - }, }, }, }, diff --git a/internal/activitylog/activitylogsql/activitylog.sql.go b/internal/activitylog/activitylogsql/activitylog.sql.go index 9c5722e58..56823d0cf 100644 --- a/internal/activitylog/activitylogsql/activitylog.sql.go +++ b/internal/activitylog/activitylogsql/activitylog.sql.go @@ -183,113 +183,6 @@ func (q *Queries) FacetsForActivityTypes(ctx context.Context, arg FacetsForActiv return items, nil } -const facetsForTeams = `-- name: FacetsForTeams :many -SELECT - team_slug, - COUNT(*) AS total_count, - COUNT(*) FILTER ( - WHERE - ( - $1::TEXT[] IS NULL - OR (resource_type || ':' || action) = ANY ($1::TEXT[]) - ) - AND ( - $2::TEXT[] IS NULL - OR resource_type = ANY ($2::TEXT[]) - ) - AND ( - $3::TEXT[] IS NULL - OR environment = ANY ($3::TEXT[]) - ) - AND ( - $4::TIMESTAMPTZ IS NULL - OR created_at >= $4::TIMESTAMPTZ - ) - AND ( - $5::TIMESTAMPTZ IS NULL - OR created_at < $5::TIMESTAMPTZ - ) - ) AS filtered_count -FROM - activity_log_combined_view -WHERE - team_slug IS NOT NULL - AND ( - $6::TEXT IS NULL - OR resource_type = $6 - ) - AND ( - $7::TEXT IS NULL - OR resource_name = $7 - ) - AND ( - $8::TEXT IS NULL - OR environment = $8 - ) - AND ( - $9::TIMESTAMPTZ IS NULL - OR created_at >= $9::TIMESTAMPTZ - ) - AND ( - $10::TIMESTAMPTZ IS NULL - OR created_at < $10::TIMESTAMPTZ - ) -GROUP BY - team_slug -ORDER BY - team_slug -` - -type FacetsForTeamsParams struct { - Filter []string - FilterResourceTypes []string - FilterEnvironments []string - FilterFrom pgtype.Timestamptz - FilterTo pgtype.Timestamptz - ResourceType *string - ResourceName *string - EnvironmentName *string - From pgtype.Timestamptz - To pgtype.Timestamptz -} - -type FacetsForTeamsRow struct { - TeamSlug *slug.Slug - TotalCount int64 - FilteredCount int64 -} - -func (q *Queries) FacetsForTeams(ctx context.Context, arg FacetsForTeamsParams) ([]*FacetsForTeamsRow, error) { - rows, err := q.db.Query(ctx, facetsForTeams, - arg.Filter, - arg.FilterResourceTypes, - arg.FilterEnvironments, - arg.FilterFrom, - arg.FilterTo, - arg.ResourceType, - arg.ResourceName, - arg.EnvironmentName, - arg.From, - arg.To, - ) - if err != nil { - return nil, err - } - defer rows.Close() - items := []*FacetsForTeamsRow{} - for rows.Next() { - var i FacetsForTeamsRow - if err := rows.Scan(&i.TeamSlug, &i.TotalCount, &i.FilteredCount); err != nil { - return nil, err - } - items = append(items, &i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const get = `-- name: Get :one SELECT id, created_at, actor, action, resource_type, resource_name, team_slug, data, environment diff --git a/internal/activitylog/activitylogsql/querier.go b/internal/activitylog/activitylogsql/querier.go index 4bc0efb6b..1c3879eb9 100644 --- a/internal/activitylog/activitylogsql/querier.go +++ b/internal/activitylog/activitylogsql/querier.go @@ -11,7 +11,6 @@ import ( type Querier interface { Create(ctx context.Context, arg CreateParams) error FacetsForActivityTypes(ctx context.Context, arg FacetsForActivityTypesParams) ([]*FacetsForActivityTypesRow, error) - FacetsForTeams(ctx context.Context, arg FacetsForTeamsParams) ([]*FacetsForTeamsRow, error) Get(ctx context.Context, id uuid.UUID) (*ActivityLogCombinedView, error) ListByIDs(ctx context.Context, ids []uuid.UUID) ([]*ActivityLogCombinedView, error) ListForResource(ctx context.Context, arg ListForResourceParams) ([]*ListForResourceRow, error) diff --git a/internal/activitylog/facets.go b/internal/activitylog/facets.go index b20f694d0..1135643ef 100644 --- a/internal/activitylog/facets.go +++ b/internal/activitylog/facets.go @@ -32,29 +32,7 @@ func ComputeFacets(ctx context.Context, scope *ActivityLogScope, filter *Activit return nil, err } - // Team facets are only computed at tenant scope (when no team is specified), - // since a single-team scope always produces a trivial single-entry result. - // This avoids a multiplicative cardinality explosion (teams × resource_types × actions × environments). - var teamRows []*activitylogsql.FacetsForTeamsRow - if scope == nil || scope.TeamSlug == nil { - teamRows, err = q.FacetsForTeams(ctx, activitylogsql.FacetsForTeamsParams{ - ResourceType: scopeField(scope, func(s *ActivityLogScope) *string { return s.ResourceType }), - ResourceName: scopeField(scope, func(s *ActivityLogScope) *string { return s.ResourceName }), - EnvironmentName: scopeField(scope, func(s *ActivityLogScope) *string { return s.EnvironmentName }), - From: withFrom(filter), - To: withTo(filter), - Filter: withFilters(filter), - FilterResourceTypes: withResourceTypes(filter), - FilterEnvironments: withEnvironments(filter), - FilterFrom: withFrom(filter), - FilterTo: withTo(filter), - }) - if err != nil { - return nil, err - } - } - - return buildFacets(activityTypeRows, teamRows), nil + return buildFacets(activityTypeRows), nil } func scopeField(scope *ActivityLogScope, fn func(*ActivityLogScope) *string) *string { @@ -64,11 +42,10 @@ func scopeField(scope *ActivityLogScope, fn func(*ActivityLogScope) *string) *st return fn(scope) } -func buildFacets(activityTypeRows []*activitylogsql.FacetsForActivityTypesRow, teamRows []*activitylogsql.FacetsForTeamsRow) *ActivityLogFacets { +func buildFacets(activityTypeRows []*activitylogsql.FacetsForActivityTypesRow) *ActivityLogFacets { activityTypeCounts := map[ActivityLogActivityType]int{} resourceTypeCounts := map[ActivityLogEntryResourceType]int{} environmentCounts := map[string]int{} - teamCounts := map[string]int{} for _, row := range activityTypeRows { // Seed with 0 to ensure all values that exist in this scope are present @@ -101,29 +78,14 @@ func buildFacets(activityTypeRows []*activitylogsql.FacetsForActivityTypesRow, t } } - for _, row := range teamRows { - if row.TeamSlug == nil { - continue - } - teamSlug := row.TeamSlug.String() - if teamSlug == "" { - continue - } - if _, ok := teamCounts[teamSlug]; !ok { - teamCounts[teamSlug] = 0 - } - teamCounts[teamSlug] += int(row.FilteredCount) - } - - return assembleFacets(activityTypeCounts, resourceTypeCounts, environmentCounts, teamCounts) + return assembleFacets(activityTypeCounts, resourceTypeCounts, environmentCounts) } -func assembleFacets(activityTypeCounts map[ActivityLogActivityType]int, resourceTypeCounts map[ActivityLogEntryResourceType]int, environmentCounts map[string]int, teamCounts map[string]int) *ActivityLogFacets { +func assembleFacets(activityTypeCounts map[ActivityLogActivityType]int, resourceTypeCounts map[ActivityLogEntryResourceType]int, environmentCounts map[string]int) *ActivityLogFacets { facets := &ActivityLogFacets{ ActivityTypes: make([]ActivityLogActivityTypeFacetItem, 0, len(activityTypeCounts)), ResourceTypes: make([]ActivityLogResourceTypeFacetItem, 0, len(resourceTypeCounts)), Environments: make([]model.StringFacetItem, 0, len(environmentCounts)), - Teams: make([]model.StringFacetItem, 0, len(teamCounts)), } for at, count := range activityTypeCounts { @@ -147,13 +109,6 @@ func assembleFacets(activityTypeCounts map[ActivityLogActivityType]int, resource }) } - for teamSlug, count := range teamCounts { - facets.Teams = append(facets.Teams, model.StringFacetItem{ - Value: teamSlug, - Count: count, - }) - } - // Sort alphabetically for stable ordering (items don't jump around when filters change) slices.SortFunc(facets.ActivityTypes, func(a, b ActivityLogActivityTypeFacetItem) int { return strings.Compare(string(a.ActivityType), string(b.ActivityType)) @@ -164,7 +119,6 @@ func assembleFacets(activityTypeCounts map[ActivityLogActivityType]int, resource }) model.SortStringFacetItems(facets.Environments) - model.SortStringFacetItems(facets.Teams) return facets } diff --git a/internal/activitylog/model.go b/internal/activitylog/model.go index d53fd5e1b..ae51292cf 100644 --- a/internal/activitylog/model.go +++ b/internal/activitylog/model.go @@ -57,7 +57,6 @@ type ActivityLogFacets struct { ActivityTypes []ActivityLogActivityTypeFacetItem `json:"activityTypes"` ResourceTypes []ActivityLogResourceTypeFacetItem `json:"resourceTypes"` Environments []model.StringFacetItem `json:"environments"` - Teams []model.StringFacetItem `json:"teams"` } type ActivityLogActivityTypeFacetItem struct { diff --git a/internal/activitylog/queries/activitylog.sql b/internal/activitylog/queries/activitylog.sql index 13c260765..71ecfa992 100644 --- a/internal/activitylog/queries/activitylog.sql +++ b/internal/activitylog/queries/activitylog.sql @@ -254,63 +254,6 @@ ORDER BY environment ; --- name: FacetsForTeams :many -SELECT - team_slug, - COUNT(*) AS total_count, - COUNT(*) FILTER ( - WHERE - ( - sqlc.narg('filter')::TEXT[] IS NULL - OR (resource_type || ':' || action) = ANY (sqlc.narg('filter')::TEXT[]) - ) - AND ( - sqlc.narg('filter_resource_types')::TEXT[] IS NULL - OR resource_type = ANY (sqlc.narg('filter_resource_types')::TEXT[]) - ) - AND ( - sqlc.narg('filter_environments')::TEXT[] IS NULL - OR environment = ANY (sqlc.narg('filter_environments')::TEXT[]) - ) - AND ( - sqlc.narg('filter_from')::TIMESTAMPTZ IS NULL - OR created_at >= sqlc.narg('filter_from')::TIMESTAMPTZ - ) - AND ( - sqlc.narg('filter_to')::TIMESTAMPTZ IS NULL - OR created_at < sqlc.narg('filter_to')::TIMESTAMPTZ - ) - ) AS filtered_count -FROM - activity_log_combined_view -WHERE - team_slug IS NOT NULL - AND ( - sqlc.narg('resource_type')::TEXT IS NULL - OR resource_type = sqlc.narg('resource_type') - ) - AND ( - sqlc.narg('resource_name')::TEXT IS NULL - OR resource_name = sqlc.narg('resource_name') - ) - AND ( - sqlc.narg('environment_name')::TEXT IS NULL - OR environment = sqlc.narg('environment_name') - ) - AND ( - sqlc.narg('from')::TIMESTAMPTZ IS NULL - OR created_at >= sqlc.narg('from')::TIMESTAMPTZ - ) - AND ( - sqlc.narg('to')::TIMESTAMPTZ IS NULL - OR created_at < sqlc.narg('to')::TIMESTAMPTZ - ) -GROUP BY - team_slug -ORDER BY - team_slug -; - -- name: RefreshMaterializedView :exec REFRESH MATERIALIZED VIEW CONCURRENTLY activity_log_subset_mat_view ; diff --git a/internal/graph/gengql/activitylog.generated.go b/internal/graph/gengql/activitylog.generated.go index a49c0f40e..9acee42f4 100644 --- a/internal/graph/gengql/activitylog.generated.go +++ b/internal/graph/gengql/activitylog.generated.go @@ -379,38 +379,6 @@ func (ec *executionContext) fieldContext_ActivityLogFacets_environments(_ contex return fc, nil } -func (ec *executionContext) _ActivityLogFacets_teams(ctx context.Context, field graphql.CollectedField, obj *activitylog.ActivityLogFacets) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return ec.fieldContext_ActivityLogFacets_teams(ctx, field) - }, - func(ctx context.Context) (any, error) { - return obj.Teams, nil - }, - nil, - func(ctx context.Context, selections ast.SelectionSet, v []model.StringFacetItem) graphql.Marshaler { - return ec.marshalNStringFacetItem2ᚕgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋmodelᚐStringFacetItemᚄ(ctx, selections, v) - }, - true, - true, - ) -} -func (ec *executionContext) fieldContext_ActivityLogFacets_teams(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "ActivityLogFacets", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return ec.childFields_StringFacetItem(ctx, field) - }, - } - return fc, nil -} - func (ec *executionContext) _ActivityLogResourceTypeFacetItem_resourceType(ctx context.Context, field graphql.CollectedField, obj *activitylog.ActivityLogResourceTypeFacetItem) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -1258,11 +1226,6 @@ func (ec *executionContext) _ActivityLogFacets(ctx context.Context, sel ast.Sele if out.Values[i] == graphql.Null { out.Invalids++ } - case "teams": - out.Values[i] = ec._ActivityLogFacets_teams(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ - } default: panic("unknown field " + strconv.Quote(field.Name)) } diff --git a/internal/graph/gengql/root_.generated.go b/internal/graph/gengql/root_.generated.go index b56e2c11b..f69863c2d 100644 --- a/internal/graph/gengql/root_.generated.go +++ b/internal/graph/gengql/root_.generated.go @@ -181,7 +181,6 @@ type ComplexityRoot struct { ActivityTypes func(childComplexity int) int Environments func(childComplexity int) int ResourceTypes func(childComplexity int) int - Teams func(childComplexity int) int } ActivityLogResourceTypeFacetItem struct { @@ -3768,13 +3767,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.ActivityLogFacets.ResourceTypes(childComplexity), true - case "ActivityLogFacets.teams": - if e.ComplexityRoot.ActivityLogFacets.Teams == nil { - break - } - - return e.ComplexityRoot.ActivityLogFacets.Teams(childComplexity), true - case "ActivityLogResourceTypeFacetItem.count": if e.ComplexityRoot.ActivityLogResourceTypeFacetItem.Count == nil { break @@ -19623,10 +19615,6 @@ type ActivityLogFacets { """ environments: [StringFacetItem!]! - """ - Distribution of entries by team slug. - """ - teams: [StringFacetItem!]! } """ @@ -32546,8 +32534,6 @@ func (ec *executionContext) childFields_ActivityLogFacets(ctx context.Context, f return ec.fieldContext_ActivityLogFacets_resourceTypes(ctx, field) case "environments": return ec.fieldContext_ActivityLogFacets_environments(ctx, field) - case "teams": - return ec.fieldContext_ActivityLogFacets_teams(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type ActivityLogFacets", field.Name) } diff --git a/internal/graph/schema/activitylog.graphqls b/internal/graph/schema/activitylog.graphqls index dd68637d3..3ae737990 100644 --- a/internal/graph/schema/activitylog.graphqls +++ b/internal/graph/schema/activitylog.graphqls @@ -322,10 +322,6 @@ type ActivityLogFacets { """ environments: [StringFacetItem!]! - """ - Distribution of entries by team slug. - """ - teams: [StringFacetItem!]! } """ From a19d551930e159b1ffd9cd76ebae1f5afa49e919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Fri, 26 Jun 2026 13:36:37 +0200 Subject: [PATCH 20/24] Fix team.Get dataloader usage in service account create transaction Replace team.Get() check before INSERT with FK violation detection after INSERT. team.Get() uses the team dataloader which requires the loader to be set up in the context (panics otherwise) and also runs against the connection pool rather than the transaction. Detecting the FK violation (SQLSTATE 23503) after the INSERT is both safer and idiomatic. --- internal/serviceaccount/queries.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/internal/serviceaccount/queries.go b/internal/serviceaccount/queries.go index 9d9554c5e..373163d3d 100644 --- a/internal/serviceaccount/queries.go +++ b/internal/serviceaccount/queries.go @@ -2,9 +2,11 @@ package serviceaccount import ( "context" + "errors" "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" "github.com/nais/api/internal/activitylog" "github.com/nais/api/internal/auth/authz" @@ -14,7 +16,6 @@ import ( "github.com/nais/api/internal/graph/pagination" "github.com/nais/api/internal/serviceaccount/serviceaccountsql" "github.com/nais/api/internal/slug" - "github.com/nais/api/internal/team" ) func Get(ctx context.Context, serviceAccountID uuid.UUID) (*ServiceAccount, error) { @@ -74,12 +75,6 @@ func Create(ctx context.Context, input CreateServiceAccountInput) (*ServiceAccou var sa *serviceaccountsql.ServiceAccount err := database.Transaction(ctx, func(ctx context.Context) error { - if input.TeamSlug != nil { - if _, err := team.Get(ctx, *input.TeamSlug); err != nil { - return err - } - } - var err error sa, err = db(ctx).Create(ctx, serviceaccountsql.CreateParams{ Name: input.Name, @@ -87,6 +82,10 @@ func Create(ctx context.Context, input CreateServiceAccountInput) (*ServiceAccou TeamSlug: input.TeamSlug, }) if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23503" && input.TeamSlug != nil { + return apierror.Errorf("The specified team was not found.") + } return err } From 1de4ee522685baaf85ef277a7b1dcc8c33f82806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Fri, 26 Jun 2026 13:39:45 +0200 Subject: [PATCH 21/24] Remove redundant comments in ComputeFacets function for clarity --- internal/activitylog/facets.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/activitylog/facets.go b/internal/activitylog/facets.go index 1135643ef..299a3fbd7 100644 --- a/internal/activitylog/facets.go +++ b/internal/activitylog/facets.go @@ -12,9 +12,6 @@ import ( func ComputeFacets(ctx context.Context, scope *ActivityLogScope, filter *ActivityLogFilter) (*ActivityLogFacets, error) { q := db(ctx) - // From/To narrow the outer WHERE scope (which rows are candidates). - // FilterFrom/FilterTo narrow the inner COUNT(*) FILTER (which rows count as "selected"). - // Both are set to the same time range: the user's time filter applies to both. activityTypeRows, err := q.FacetsForActivityTypes(ctx, activitylogsql.FacetsForActivityTypesParams{ TeamSlug: scopeField(scope, func(s *ActivityLogScope) *string { return (*string)(s.TeamSlug) }), ResourceType: scopeField(scope, func(s *ActivityLogScope) *string { return s.ResourceType }), From a5cc832a4d31099e316485b63c9b70bac28e7ae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Fri, 26 Jun 2026 13:40:18 +0200 Subject: [PATCH 22/24] Remove redundant newline in ActivityLogFacets type definition --- internal/graph/schema/activitylog.graphqls | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/graph/schema/activitylog.graphqls b/internal/graph/schema/activitylog.graphqls index 3ae737990..62949ea67 100644 --- a/internal/graph/schema/activitylog.graphqls +++ b/internal/graph/schema/activitylog.graphqls @@ -321,7 +321,6 @@ type ActivityLogFacets { Distribution of entries by environment. """ environments: [StringFacetItem!]! - } """ From 7da22bcb605e455c92227dc0cdb3924badb724fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Fri, 26 Jun 2026 13:42:23 +0200 Subject: [PATCH 23/24] Mark static service account functions for removal --- internal/serviceaccount/queries.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/serviceaccount/queries.go b/internal/serviceaccount/queries.go index 373163d3d..0543fdbba 100644 --- a/internal/serviceaccount/queries.go +++ b/internal/serviceaccount/queries.go @@ -408,12 +408,12 @@ func ListTokensForServiceAccount(ctx context.Context, page *pagination.Paginatio }), nil } -// DeleteStaticServiceAccounts removes all static service accounts. +// TODO: Remove once static service accounts has been removed func DeleteStaticServiceAccounts(ctx context.Context) error { return db(ctx).DeleteStaticServiceAccounts(ctx) } -// CreateStaticServiceAccount creates a static service account. +// TODO: Remove once static service accounts has been removed func CreateStaticServiceAccount(ctx context.Context, name string, roles []string, secret string) error { return database.Transaction(ctx, func(ctx context.Context) error { sa, err := db(ctx).Create(ctx, serviceaccountsql.CreateParams{ From 09ea7aa2d0df306225328458b26a5cf3d18593b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Fri, 26 Jun 2026 13:48:39 +0200 Subject: [PATCH 24/24] Remove redundant newline in ActivityLogFacets type definition --- internal/graph/gengql/root_.generated.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/graph/gengql/root_.generated.go b/internal/graph/gengql/root_.generated.go index f69863c2d..31eb121a4 100644 --- a/internal/graph/gengql/root_.generated.go +++ b/internal/graph/gengql/root_.generated.go @@ -19614,7 +19614,6 @@ type ActivityLogFacets { Distribution of entries by environment. """ environments: [StringFacetItem!]! - } """