mirror of https://github.com/coder/coder.git
feat: allow auditors to read template insights (#10860)
- Adds a template_insights pseudo-resource - Grants auditor and template admin roles read access on template_insights - Updates existing RBAC checks to check for read template_insights, falling back to template update permissions where necessary - Updates TemplateLayout to show Insights tab if can read template_insights or can update template
This commit is contained in:
parent
e73901cf56
commit
dd161b172e
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -20,6 +20,7 @@ func AllResources() []Object {
|
|||
ResourceSystem,
|
||||
ResourceTailnetCoordinator,
|
||||
ResourceTemplate,
|
||||
ResourceTemplateInsights,
|
||||
ResourceUser,
|
||||
ResourceUserData,
|
||||
ResourceWildcard,
|
||||
|
|
|
@ -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{},
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -3994,6 +3994,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
|||
| `replicas` |
|
||||
| `debug_info` |
|
||||
| `system` |
|
||||
| `template_insights` |
|
||||
|
||||
## codersdk.RateLimitConfig
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 (
|
||||
|
|
Loading…
Reference in New Issue