diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index ed7968577b455..9bdb7181f48f7 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9673,7 +9673,8 @@ const docTemplate = `{ "deployment_stats", "replicas", "debug_info", - "system" + "system", + "template_insights" ], "x-enum-varnames": [ "ResourceWorkspace", @@ -9697,7 +9698,8 @@ const docTemplate = `{ "ResourceDeploymentStats", "ResourceReplicas", "ResourceDebugInfo", - "ResourceSystem" + "ResourceSystem", + "ResourceTemplateInsights" ] }, "codersdk.RateLimitConfig": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index f92e11b92609f..d215de92eee20 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8701,7 +8701,8 @@ "deployment_stats", "replicas", "debug_info", - "system" + "system", + "template_insights" ], "x-enum-varnames": [ "ResourceWorkspace", @@ -8725,7 +8726,8 @@ "ResourceDeploymentStats", "ResourceReplicas", "ResourceDebugInfo", - "ResourceSystem" + "ResourceSystem", + "ResourceTemplateInsights" ] }, "codersdk.RateLimitConfig": { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 347f22f7aac0f..83ea22b628779 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1294,26 +1294,31 @@ func (q *querier) GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUID) } func (q *querier) GetTemplateAppInsights(ctx context.Context, arg database.GetTemplateAppInsightsParams) ([]database.GetTemplateAppInsightsRow, error) { - for _, templateID := range arg.TemplateIDs { - template, err := q.db.GetTemplateByID(ctx, templateID) - if err != nil { - return nil, err - } + // Used by TemplateAppInsights endpoint + // For auditors, check read template_insights, and fall back to update template. + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceTemplateInsights); IsNotAuthorizedError(err) { + for _, templateID := range arg.TemplateIDs { + template, err := q.db.GetTemplateByID(ctx, templateID) + if err != nil { + return nil, err + } - if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil { - return nil, err + if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil { + return nil, err + } } - } - if len(arg.TemplateIDs) == 0 { - if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil { - return nil, err + if len(arg.TemplateIDs) == 0 { + if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil { + return nil, err + } } } return q.db.GetTemplateAppInsights(ctx, arg) } func (q *querier) GetTemplateAppInsightsByTemplate(ctx context.Context, arg database.GetTemplateAppInsightsByTemplateParams) ([]database.GetTemplateAppInsightsByTemplateRow, error) { - if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil { + // Only used by prometheus metrics, so we don't strictly need to check update template perms. + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceTemplateInsights); err != nil { return nil, err } return q.db.GetTemplateAppInsightsByTemplate(ctx, arg) @@ -1344,64 +1349,77 @@ func (q *querier) GetTemplateDAUs(ctx context.Context, arg database.GetTemplateD } func (q *querier) GetTemplateInsights(ctx context.Context, arg database.GetTemplateInsightsParams) (database.GetTemplateInsightsRow, error) { - for _, templateID := range arg.TemplateIDs { - template, err := q.db.GetTemplateByID(ctx, templateID) - if err != nil { - return database.GetTemplateInsightsRow{}, err - } + // Used by TemplateInsights endpoint + // For auditors, check read template_insights, and fall back to update template. + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceTemplateInsights); IsNotAuthorizedError(err) { + for _, templateID := range arg.TemplateIDs { + template, err := q.db.GetTemplateByID(ctx, templateID) + if err != nil { + return database.GetTemplateInsightsRow{}, err + } - if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil { - return database.GetTemplateInsightsRow{}, err + if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil { + return database.GetTemplateInsightsRow{}, err + } } - } - if len(arg.TemplateIDs) == 0 { - if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil { - return database.GetTemplateInsightsRow{}, err + if len(arg.TemplateIDs) == 0 { + if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil { + return database.GetTemplateInsightsRow{}, err + } } } return q.db.GetTemplateInsights(ctx, arg) } func (q *querier) GetTemplateInsightsByInterval(ctx context.Context, arg database.GetTemplateInsightsByIntervalParams) ([]database.GetTemplateInsightsByIntervalRow, error) { - for _, templateID := range arg.TemplateIDs { - template, err := q.db.GetTemplateByID(ctx, templateID) - if err != nil { - return nil, err - } + // Used by TemplateInsights endpoint + // For auditors, check read template_insights, and fall back to update template. + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceTemplateInsights); IsNotAuthorizedError(err) { + for _, templateID := range arg.TemplateIDs { + template, err := q.db.GetTemplateByID(ctx, templateID) + if err != nil { + return nil, err + } - if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil { - return nil, err + if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil { + return nil, err + } } - } - if len(arg.TemplateIDs) == 0 { - if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil { - return nil, err + if len(arg.TemplateIDs) == 0 { + if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil { + return nil, err + } } } return q.db.GetTemplateInsightsByInterval(ctx, arg) } func (q *querier) GetTemplateInsightsByTemplate(ctx context.Context, arg database.GetTemplateInsightsByTemplateParams) ([]database.GetTemplateInsightsByTemplateRow, error) { - if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil { + // Only used by prometheus metrics collector. No need to check update template perms. + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceTemplateInsights); err != nil { return nil, err } return q.db.GetTemplateInsightsByTemplate(ctx, arg) } func (q *querier) GetTemplateParameterInsights(ctx context.Context, arg database.GetTemplateParameterInsightsParams) ([]database.GetTemplateParameterInsightsRow, error) { - for _, templateID := range arg.TemplateIDs { - template, err := q.db.GetTemplateByID(ctx, templateID) - if err != nil { - return nil, err - } + // Used by both insights endpoint and prometheus collector. + // For auditors, check read template_insights, and fall back to update template. + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceTemplateInsights); IsNotAuthorizedError(err) { + for _, templateID := range arg.TemplateIDs { + template, err := q.db.GetTemplateByID(ctx, templateID) + if err != nil { + return nil, err + } - if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil { - return nil, err + if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil { + return nil, err + } } - } - if len(arg.TemplateIDs) == 0 { - if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil { - return nil, err + if len(arg.TemplateIDs) == 0 { + if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil { + return nil, err + } } } return q.db.GetTemplateParameterInsights(ctx, arg) @@ -1559,19 +1577,22 @@ func (q *querier) GetUnexpiredLicenses(ctx context.Context) ([]database.License, } func (q *querier) GetUserActivityInsights(ctx context.Context, arg database.GetUserActivityInsightsParams) ([]database.GetUserActivityInsightsRow, error) { - for _, templateID := range arg.TemplateIDs { - template, err := q.db.GetTemplateByID(ctx, templateID) - if err != nil { - return nil, err - } + // Used by insights endpoints. Need to check both for auditors and for regular users with template acl perms. + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceTemplateInsights); IsNotAuthorizedError(err) { + for _, templateID := range arg.TemplateIDs { + template, err := q.db.GetTemplateByID(ctx, templateID) + if err != nil { + return nil, err + } - if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil { - return nil, err + if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil { + return nil, err + } } - } - if len(arg.TemplateIDs) == 0 { - if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil { - return nil, err + if len(arg.TemplateIDs) == 0 { + if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil { + return nil, err + } } } return q.db.GetUserActivityInsights(ctx, arg) @@ -1593,19 +1614,22 @@ func (q *querier) GetUserCount(ctx context.Context) (int64, error) { } func (q *querier) GetUserLatencyInsights(ctx context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) { - for _, templateID := range arg.TemplateIDs { - template, err := q.db.GetTemplateByID(ctx, templateID) - if err != nil { - return nil, err - } + // Used by insights endpoints. Need to check both for auditors and for regular users with template acl perms. + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceTemplateInsights); IsNotAuthorizedError(err) { + for _, templateID := range arg.TemplateIDs { + template, err := q.db.GetTemplateByID(ctx, templateID) + if err != nil { + return nil, err + } - if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil { - return nil, err + if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil { + return nil, err + } } - } - if len(arg.TemplateIDs) == 0 { - if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil { - return nil, err + if len(arg.TemplateIDs) == 0 { + if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil { + return nil, err + } } } return q.db.GetUserLatencyInsights(ctx, arg) diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 1e3f1f45e59ea..fa2a7938b47c8 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -193,6 +193,11 @@ var ( ResourceTailnetCoordinator = Object{ Type: "tailnet_coordinator", } + + // ResourceTemplateInsights is a pseudo-resource for reading template insights data. + ResourceTemplateInsights = Object{ + Type: "template_insights", + } ) // ResourceUserObject is a helper function to create a user object for authz checks. diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 86a03d4552d45..69e6f54a6f588 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -20,6 +20,7 @@ func AllResources() []Object { ResourceSystem, ResourceTailnetCoordinator, ResourceTemplate, + ResourceTemplateInsights, ResourceUser, ResourceUserData, ResourceWildcard, diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index de120363142c4..3a2a0d74eaed3 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -165,10 +165,11 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Site: Permissions(map[string][]Action{ // Should be able to read all template details, even in orgs they // are not in. - ResourceTemplate.Type: {ActionRead}, - ResourceAuditLog.Type: {ActionRead}, - ResourceUser.Type: {ActionRead}, - ResourceGroup.Type: {ActionRead}, + ResourceTemplate.Type: {ActionRead}, + ResourceTemplateInsights.Type: {ActionRead}, + ResourceAuditLog.Type: {ActionRead}, + ResourceUser.Type: {ActionRead}, + ResourceGroup.Type: {ActionRead}, // Allow auditors to query deployment stats and insights. ResourceDeploymentStats.Type: {ActionRead}, ResourceDeploymentValues.Type: {ActionRead}, @@ -195,6 +196,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) { ResourceGroup.Type: {ActionRead}, // Org roles are not really used yet, so grant the perm at the site level. ResourceOrganizationMember.Type: {ActionRead}, + // Template admins can read all template insights data + ResourceTemplateInsights.Type: {ActionRead}, }), Org: map[string][]Permission{}, User: []Permission{}, diff --git a/codersdk/rbacresources.go b/codersdk/rbacresources.go index fc1a7b209b393..8854568525058 100644 --- a/codersdk/rbacresources.go +++ b/codersdk/rbacresources.go @@ -25,6 +25,7 @@ const ( ResourceReplicas RBACResource = "replicas" ResourceDebugInfo RBACResource = "debug_info" ResourceSystem RBACResource = "system" + ResourceTemplateInsights RBACResource = "template_insights" ) const ( @@ -58,6 +59,7 @@ var ( ResourceReplicas, ResourceDebugInfo, ResourceSystem, + ResourceTemplateInsights, } AllRBACActions = []string{ diff --git a/docs/api/schemas.md b/docs/api/schemas.md index c4dc42883987d..435486404aebd 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3994,6 +3994,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `replicas` | | `debug_info` | | `system` | +| `template_insights` | ## codersdk.RateLimitConfig diff --git a/enterprise/coderd/insights_test.go b/enterprise/coderd/insights_test.go index 22753ee60fa6a..c2d97fea913e3 100644 --- a/enterprise/coderd/insights_test.go +++ b/enterprise/coderd/insights_test.go @@ -3,6 +3,7 @@ package coderd_test import ( "context" "fmt" + "net/http" "testing" "time" @@ -68,3 +69,60 @@ func TestTemplateInsightsWithTemplateAdminACL(t *testing.T) { }) } } + +func TestTemplateInsightsWithRole(t *testing.T) { + t.Parallel() + + y, m, d := time.Now().UTC().Date() + today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) + + type test struct { + interval codersdk.InsightsReportInterval + role string + allowed bool + } + + tests := []test{ + {codersdk.InsightsReportIntervalDay, rbac.RoleTemplateAdmin(), true}, + {"", rbac.RoleTemplateAdmin(), true}, + {codersdk.InsightsReportIntervalDay, "auditor", true}, + {"", "auditor", true}, + {codersdk.InsightsReportIntervalDay, rbac.RoleUserAdmin(), false}, + {"", rbac.RoleUserAdmin(), false}, + {codersdk.InsightsReportIntervalDay, rbac.RoleMember(), false}, + {"", rbac.RoleMember(), false}, + } + + for _, tt := range tests { + tt := tt + t.Run(fmt.Sprintf("with interval=%q role=%q", tt.interval, tt.role), func(t *testing.T) { + t.Parallel() + + client, admin := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }}) + version := coderdtest.CreateTemplateVersion(t, client, admin.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID) + + aud, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID, tt.role) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + _, err := aud.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ + StartTime: today.AddDate(0, 0, -1), + EndTime: today, + TemplateIDs: []uuid.UUID{template.ID}, + }) + if tt.allowed { + require.NoError(t, err) + } else { + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, sdkErr.StatusCode(), http.StatusNotFound) + } + }) + } +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ccf06da3f8b47..cf7f523bc97e7 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1882,6 +1882,7 @@ export type RBACResource = | "replicas" | "system" | "template" + | "template_insights" | "user" | "user_data" | "workspace" @@ -1905,6 +1906,7 @@ export const RBACResources: RBACResource[] = [ "replicas", "system", "template", + "template_insights", "user", "user_data", "workspace", diff --git a/site/src/pages/TemplatePage/TemplateLayout.tsx b/site/src/pages/TemplatePage/TemplateLayout.tsx index 1c83dee3e5c57..d24fea745274e 100644 --- a/site/src/pages/TemplatePage/TemplateLayout.tsx +++ b/site/src/pages/TemplatePage/TemplateLayout.tsx @@ -24,6 +24,12 @@ const templatePermissions = ( }, action: "update", }, + canReadInsights: { + object: { + resource_type: "template_insights", + }, + action: "read", + }, }); const fetchTemplate = async (orgId: string, templateName: string) => { @@ -68,7 +74,10 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({ queryKey: ["template", templateName], queryFn: () => fetchTemplate(orgId, templateName), }); - const shouldShowInsights = data?.permissions?.canUpdateTemplate; + // Auditors should also be able to view insights, but do not automatically + // have permission to update templates. Need both checks. + const shouldShowInsights = + data?.permissions?.canUpdateTemplate || data?.permissions?.canReadInsights; if (error) { return (