diff --git a/go.mod b/go.mod index 6a2944307..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 @@ -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..072ae6985 100644 --- a/go.sum +++ b/go.sum @@ -1119,8 +1119,8 @@ 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= 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= 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/integration_tests/tenant_activitylog.lua b/integration_tests/tenant_activitylog.lua new file mode 100644 index 000000000..02b752baf --- /dev/null +++ b/integration_tests/tenant_activitylog.lua @@ -0,0 +1,152 @@ +local admin = User.new() +admin:admin(true) +local teamOne = Team.new("slug-1", "purpose", "#channel") +local teamTwo = Team.new("slug-2", "purpose", "#channel") + +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 and pagination metadata", function(t) + t.addHeader("x-user-email", admin:email()) + + t.query [[ + query { + activityLog(first: 10, filter: { activityTypes: [REPOSITORY_ADDED] }) { + nodes { + resourceName + teamSlug + } + pageInfo { + totalCount + hasNextPage + } + facets { + activityTypes { + activityType + count + } + resourceTypes { + resourceType + count + } + environments { + value + count + } + } + } + } + ]] + + t.check { + data = { + activityLog = { + 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 = {}, + }, + }, + }, + } +end) + +Test.gql("Tenant activity log supports time filtering", function(t) + t.addHeader("x-user-email", admin:email()) + + t.query [[ + query { + activityLog( + first: 10 + filter: { activityTypes: [REPOSITORY_ADDED], from: "9999-01-01T00:00:00Z" } + ) { + nodes { + resourceName + } + pageInfo { + totalCount + } + } + } + ]] + + t.check { + data = { + activityLog = { + nodes = {}, + pageInfo = { + totalCount = 0, + }, + }, + }, + } +end) diff --git a/internal/activitylog/activitylogsql/activitylog.sql.go b/internal/activitylog/activitylogsql/activitylog.sql.go index 9a500922a..56823d0cf 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" ) @@ -56,7 +57,7 @@ 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, @@ -76,25 +77,41 @@ 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, @@ -106,17 +123,21 @@ ORDER BY environment ` -type FacetsParams struct { +type FacetsForActivityTypesParams 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 { +type FacetsForActivityTypesRow struct { ResourceType string Action string Environment string @@ -124,23 +145,27 @@ type FacetsRow struct { 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, + arg.FilterFrom, + arg.FilterTo, arg.TeamSlug, arg.ResourceType, arg.ResourceName, arg.EnvironmentName, + arg.From, + arg.To, ) if err != nil { 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, @@ -246,12 +271,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 +293,8 @@ type ListForResourceParams struct { Filter []string ResourceTypes []string Environments []string + From pgtype.Timestamptz + To pgtype.Timestamptz Offset int32 Limit int32 } @@ -276,6 +311,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 +368,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 +392,8 @@ type ListForResourceTeamAndEnvironmentParams struct { Filter []string ResourceTypes []string Environments []string + From pgtype.Timestamptz + To pgtype.Timestamptz Offset int32 Limit int32 } @@ -365,6 +412,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 +466,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 +487,8 @@ type ListForTeamParams struct { Filter []string ResourceTypes []string Environments []string + From pgtype.Timestamptz + To pgtype.Timestamptz Offset int32 Limit int32 } @@ -445,6 +504,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 +538,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..1c3879eb9 100644 --- a/internal/activitylog/activitylogsql/querier.go +++ b/internal/activitylog/activitylogsql/querier.go @@ -10,12 +10,13 @@ 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) 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) 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/activitylog/facets.go b/internal/activitylog/facets.go index 74d922fb8..299a3fbd7 100644 --- a/internal/activitylog/facets.go +++ b/internal/activitylog/facets.go @@ -12,20 +12,24 @@ import ( func ComputeFacets(ctx context.Context, scope *ActivityLogScope, filter *ActivityLogFilter) (*ActivityLogFacets, error) { q := db(ctx) - rows, err := q.Facets(ctx, activitylogsql.FacetsParams{ + 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), FilterResourceTypes: withResourceTypes(filter), FilterEnvironments: withEnvironments(filter), + FilterFrom: withFrom(filter), + FilterTo: withTo(filter), }) if err != nil { return nil, err } - return buildFacets(rows), nil + return buildFacets(activityTypeRows), nil } func scopeField(scope *ActivityLogScope, fn func(*ActivityLogScope) *string) *string { @@ -35,13 +39,13 @@ func scopeField(scope *ActivityLogScope, fn func(*ActivityLogScope) *string) *st return fn(scope) } -func buildFacets(rows []*activitylogsql.FacetsRow) *ActivityLogFacets { +func buildFacets(activityTypeRows []*activitylogsql.FacetsForActivityTypesRow) *ActivityLogFacets { activityTypeCounts := map[ActivityLogActivityType]int{} resourceTypeCounts := map[ActivityLogEntryResourceType]int{} environmentCounts := 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 @@ -59,7 +63,6 @@ func buildFacets(rows []*activitylogsql.FacetsRow) *ActivityLogFacets { } } - // Now add the filtered counts filteredCount := int(row.FilteredCount) resourceTypeCounts[rt] += filteredCount 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..ae51292cf 100644 --- a/internal/activitylog/model.go +++ b/internal/activitylog/model.go @@ -121,6 +121,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..ac61512a9 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{}, + 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..71ecfa992 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 @@ -129,7 +188,7 @@ ORDER BY created_at DESC ; --- name: Facets :many +-- name: FacetsForActivityTypes :many SELECT resource_type, action, @@ -149,6 +208,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,6 +236,14 @@ 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, diff --git a/internal/graph/activitylog.resolvers.go b/internal/graph/activitylog.resolvers.go index 2dc0c107e..ffc7baa00 100644 --- a/internal/graph/activitylog.resolvers.go +++ b/internal/graph/activitylog.resolvers.go @@ -34,6 +34,15 @@ func (r *openSearchResolver) ActivityLog(ctx context.Context, obj *opensearch.Op ) } +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 + } + + 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/gengql/activitylog.generated.go b/internal/graph/gengql/activitylog.generated.go index 49749210d..9acee42f4 100644 --- a/internal/graph/gengql/activitylog.generated.go +++ b/internal/graph/gengql/activitylog.generated.go @@ -440,7 +440,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 +468,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 diff --git a/internal/graph/gengql/complexity.go b/internal/graph/gengql/complexity.go index 31032e5bd..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 } diff --git a/internal/graph/gengql/root_.generated.go b/internal/graph/gengql/root_.generated.go index 55ecedcd3..31eb121a4 100644 --- a/internal/graph/gengql/root_.generated.go +++ b/internal/graph/gengql/root_.generated.go @@ -1856,6 +1856,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 @@ -11089,6 +11090,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 @@ -19278,7 +19291,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. + """ + activityLog( + """ + 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 +19499,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 diff --git a/internal/graph/gengql/schema.generated.go b/internal/graph/gengql/schema.generated.go index 6a4f92eca..98dbd2564 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) + 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) @@ -1091,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{} @@ -4760,6 +4807,50 @@ func (ec *executionContext) fieldContext_Query_node(ctx context.Context, field g return fc, nil } +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_activityLog(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().ActivityLog(ctx, fc.Args["first"].(*int), fc.Args["after"].(*pagination.Cursor), fc.Args["last"].(*int), fc.Args["before"].(*pagination.Cursor), fc.Args["filter"].(*activitylog.ActivityLogFilter)) + }, + 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_activityLog(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_activityLog_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 "activityLog": + 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_activityLog(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 diff --git a/internal/graph/schema/activitylog.graphqls b/internal/graph/schema/activitylog.graphqls index ee3f1368d..62949ea67 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. + """ + activityLog( + """ + 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 diff --git a/internal/serviceaccount/queries.go b/internal/serviceaccount/queries.go index 48478875d..0543fdbba 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" @@ -80,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 } 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))