From 5640dc8ebc5c76750d05ad88de7824b68dab4ac7 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 15:02:14 -0500 Subject: [PATCH 01/26] chore: add migration to create a custom_roles table --- coderd/database/dbauthz/dbauthz.go | 4 + coderd/database/dbmem/dbmem.go | 4 + coderd/database/dbmetrics/dbmetrics.go | 7 + coderd/database/dbmock/dbmock.go | 15 + coderd/database/dump.sql | 17 + .../migrations/000209_custom_roles.down.sql | 0 .../migrations/000209_custom_roles.up.sql | 26 ++ coderd/database/models.go | 11 + coderd/database/querier.go | 441 ------------------ coderd/database/queries.sql.go | 41 ++ coderd/database/queries/roles.sql | 9 + coderd/database/unique_constraint.go | 2 + coderd/rbac/storedroles/store.go | 75 +++ 13 files changed, 211 insertions(+), 441 deletions(-) create mode 100644 coderd/database/migrations/000209_custom_roles.down.sql create mode 100644 coderd/database/migrations/000209_custom_roles.up.sql delete mode 100644 coderd/database/querier.go create mode 100644 coderd/database/queries/roles.sql create mode 100644 coderd/rbac/storedroles/store.go diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index a096346f57064..cb6382498bfe0 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -773,6 +773,10 @@ func (q *querier) CleanTailnetTunnels(ctx context.Context) error { return q.db.CleanTailnetTunnels(ctx) } +func (q *querier) CustomRoles(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { + panic("not implemented") +} + func (q *querier) DeleteAPIKeyByID(ctx context.Context, id string) error { return deleteQ(q.log, q.auth, q.db.GetAPIKeyByID, q.db.DeleteAPIKeyByID)(ctx, id) } diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 8a2ce25b34367..c847d196074d2 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -1172,6 +1172,10 @@ func (*FakeQuerier) CleanTailnetTunnels(context.Context) error { return ErrUnimplemented } +func (q *FakeQuerier) CustomRoles(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { + panic("not implemented") +} + func (q *FakeQuerier) DeleteAPIKeyByID(_ context.Context, id string) error { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 77ebfd6718757..99a59a1fe5401 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -144,6 +144,13 @@ func (m metricsStore) CleanTailnetTunnels(ctx context.Context) error { return r0 } +func (m metricsStore) CustomRoles(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { + start := time.Now() + r0, r1 := m.s.CustomRoles(ctx, lookupRoles) + m.queryLatencies.WithLabelValues("CustomRoles").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) DeleteAPIKeyByID(ctx context.Context, id string) error { start := time.Now() err := m.s.DeleteAPIKeyByID(ctx, id) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index e651c8301c933..7364f9a6c3c83 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -173,6 +173,21 @@ func (mr *MockStoreMockRecorder) CleanTailnetTunnels(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanTailnetTunnels", reflect.TypeOf((*MockStore)(nil).CleanTailnetTunnels), arg0) } +// CustomRoles mocks base method. +func (m *MockStore) CustomRoles(arg0 context.Context, arg1 string) ([]database.CustomRole, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CustomRoles", arg0, arg1) + ret0, _ := ret[0].([]database.CustomRole) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CustomRoles indicates an expected call of CustomRoles. +func (mr *MockStoreMockRecorder) CustomRoles(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomRoles", reflect.TypeOf((*MockStore)(nil).CustomRoles), arg0, arg1) +} + // DeleteAPIKeyByID mocks base method. func (m *MockStore) DeleteAPIKeyByID(arg0 context.Context, arg1 string) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index ed400cf82198f..dd9e69e275b10 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -404,6 +404,18 @@ CREATE TABLE audit_logs ( resource_icon text NOT NULL ); +CREATE TABLE custom_roles ( + name text NOT NULL, + display_name text, + site_permissions jsonb DEFAULT '[]'::jsonb NOT NULL, + org_permissions jsonb DEFAULT '{}'::jsonb NOT NULL, + user_permissions jsonb DEFAULT '[]'::jsonb NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + last_updated timestamp with time zone DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE custom_roles IS 'Custom roles allow dynamic roles expanded at runtime'; + CREATE TABLE dbcrypt_keys ( number integer NOT NULL, active_key_digest text, @@ -1398,6 +1410,9 @@ ALTER TABLE ONLY api_keys ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); +ALTER TABLE ONLY custom_roles + ADD CONSTRAINT custom_roles_pkey PRIMARY KEY (name); + ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_active_key_digest_key UNIQUE (active_key_digest); @@ -1606,6 +1621,8 @@ CREATE INDEX idx_audit_log_user_id ON audit_logs USING btree (user_id); CREATE INDEX idx_audit_logs_time_desc ON audit_logs USING btree ("time" DESC); +CREATE UNIQUE INDEX idx_custom_roles_name_lower ON custom_roles USING btree (lower(name)); + CREATE INDEX idx_organization_member_organization_id_uuid ON organization_members USING btree (organization_id); CREATE INDEX idx_organization_member_user_id_uuid ON organization_members USING btree (user_id); diff --git a/coderd/database/migrations/000209_custom_roles.down.sql b/coderd/database/migrations/000209_custom_roles.down.sql new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/coderd/database/migrations/000209_custom_roles.up.sql b/coderd/database/migrations/000209_custom_roles.up.sql new file mode 100644 index 0000000000000..64b76644c3b4f --- /dev/null +++ b/coderd/database/migrations/000209_custom_roles.up.sql @@ -0,0 +1,26 @@ +CREATE TABLE custom_roles ( + -- name is globally unique. Org scoped roles have their orgid prepended + -- like: "name":"organization-admin:bbe8c156-c61e-4d36-b91e-697c6b1477e8" + name text primary key, + -- display_name is the actual name of the role displayed to the user. + display_name text, + + -- Unfortunately these values are schemaless json documents. + -- If there was a permission table for these, that would involve + -- many necessary joins to accomplish this simple json. + + -- site_permissions is '[]Permission' + site_permissions jsonb NOT NULL default '[]', + -- org_permissions is 'map[][]Permission' + org_permissions jsonb NOT NULL default '{}', + -- user_permissions is '[]Permission' + user_permissions jsonb NOT NULL default '[]', + + -- extra convenience meta data. + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + last_updated timestamp with time zone DEFAULT CURRENT_TIMESTAMP +); + +-- Ensure no case variants of the same roles +CREATE UNIQUE INDEX idx_custom_roles_name_lower ON custom_roles USING btree (lower(name)); +COMMENT ON TABLE custom_roles IS 'Custom roles allow dynamic roles expanded at runtime'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 18587b05ade1a..e257894d0f174 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1781,6 +1781,17 @@ type AuditLog struct { ResourceIcon string `db:"resource_icon" json:"resource_icon"` } +// Custom roles allow dynamic roles expanded at runtime +type CustomRole struct { + Name string `db:"name" json:"name"` + DisplayName sql.NullString `db:"display_name" json:"display_name"` + SitePermissions json.RawMessage `db:"site_permissions" json:"site_permissions"` + OrgPermissions json.RawMessage `db:"org_permissions" json:"org_permissions"` + UserPermissions json.RawMessage `db:"user_permissions" json:"user_permissions"` + CreatedAt sql.NullTime `db:"created_at" json:"created_at"` + LastUpdated sql.NullTime `db:"last_updated" json:"last_updated"` +} + // A table used to store the keys used to encrypt the database. type DBCryptKey struct { // An integer used to identify the key. diff --git a/coderd/database/querier.go b/coderd/database/querier.go deleted file mode 100644 index 405f86bf47688..0000000000000 --- a/coderd/database/querier.go +++ /dev/null @@ -1,441 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.25.0 - -package database - -import ( - "context" - "time" - - "github.com/google/uuid" -) - -type sqlcQuerier interface { - // Blocks until the lock is acquired. - // - // This must be called from within a transaction. The lock will be automatically - // released when the transaction ends. - AcquireLock(ctx context.Context, pgAdvisoryXactLock int64) error - // Acquires the lock for a single job that isn't started, completed, - // canceled, and that matches an array of provisioner types. - // - // SKIP LOCKED is used to jump over locked rows. This prevents - // multiple provisioners from acquiring the same jobs. See: - // https://www.postgresql.org/docs/9.5/sql-select.html#SQL-FOR-UPDATE-SHARE - AcquireProvisionerJob(ctx context.Context, arg AcquireProvisionerJobParams) (ProvisionerJob, error) - // Bumps the workspace deadline by the template's configured "activity_bump" - // duration (default 1h). If the workspace bump will cross an autostart - // threshold, then the bump is autostart + TTL. This is the deadline behavior if - // the workspace was to autostart from a stopped state. - // - // Max deadline is respected, and the deadline will never be bumped past it. - // The deadline will never decrease. - // We only bump if the template has an activity bump duration set. - // We only bump if the raw interval is positive and non-zero. - // We only bump if workspace shutdown is manual. - // We only bump when 5% of the deadline has elapsed. - ActivityBumpWorkspace(ctx context.Context, arg ActivityBumpWorkspaceParams) error - // AllUserIDs returns all UserIDs regardless of user status or deletion. - AllUserIDs(ctx context.Context) ([]uuid.UUID, error) - // Archiving templates is a soft delete action, so is reversible. - // Archiving prevents the version from being used and discovered - // by listing. - // Only unused template versions will be archived, which are any versions not - // referenced by the latest build of a workspace. - ArchiveUnusedTemplateVersions(ctx context.Context, arg ArchiveUnusedTemplateVersionsParams) ([]uuid.UUID, error) - BatchUpdateWorkspaceLastUsedAt(ctx context.Context, arg BatchUpdateWorkspaceLastUsedAtParams) error - CleanTailnetCoordinators(ctx context.Context) error - CleanTailnetLostPeers(ctx context.Context) error - CleanTailnetTunnels(ctx context.Context) error - DeleteAPIKeyByID(ctx context.Context, id string) error - DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error - DeleteAllTailnetClientSubscriptions(ctx context.Context, arg DeleteAllTailnetClientSubscriptionsParams) error - DeleteAllTailnetTunnels(ctx context.Context, arg DeleteAllTailnetTunnelsParams) error - DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error - DeleteCoordinator(ctx context.Context, id uuid.UUID) error - DeleteExternalAuthLink(ctx context.Context, arg DeleteExternalAuthLinkParams) error - DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error - DeleteGroupByID(ctx context.Context, id uuid.UUID) error - DeleteGroupMemberFromGroup(ctx context.Context, arg DeleteGroupMemberFromGroupParams) error - DeleteLicense(ctx context.Context, id int32) (int32, error) - DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error - DeleteOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) error - DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx context.Context, arg DeleteOAuth2ProviderAppCodesByAppAndUserIDParams) error - DeleteOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) error - DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx context.Context, arg DeleteOAuth2ProviderAppTokensByAppAndUserIDParams) error - // Delete provisioner daemons that have been created at least a week ago - // and have not connected to coderd since a week. - // A provisioner daemon with "zeroed" last_seen_at column indicates possible - // connectivity issues (no provisioner daemon activity since registration). - DeleteOldProvisionerDaemons(ctx context.Context) error - // If an agent hasn't connected in the last 7 days, we purge it's logs. - // Logs can take up a lot of space, so it's important we clean up frequently. - DeleteOldWorkspaceAgentLogs(ctx context.Context) error - DeleteOldWorkspaceAgentStats(ctx context.Context) error - DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error - DeleteTailnetAgent(ctx context.Context, arg DeleteTailnetAgentParams) (DeleteTailnetAgentRow, error) - DeleteTailnetClient(ctx context.Context, arg DeleteTailnetClientParams) (DeleteTailnetClientRow, error) - DeleteTailnetClientSubscription(ctx context.Context, arg DeleteTailnetClientSubscriptionParams) error - DeleteTailnetPeer(ctx context.Context, arg DeleteTailnetPeerParams) (DeleteTailnetPeerRow, error) - DeleteTailnetTunnel(ctx context.Context, arg DeleteTailnetTunnelParams) (DeleteTailnetTunnelRow, error) - DeleteWorkspaceAgentPortShare(ctx context.Context, arg DeleteWorkspaceAgentPortShareParams) error - DeleteWorkspaceAgentPortSharesByTemplate(ctx context.Context, templateID uuid.UUID) error - FavoriteWorkspace(ctx context.Context, id uuid.UUID) error - GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) - // there is no unique constraint on empty token names - GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error) - GetAPIKeysByLoginType(ctx context.Context, loginType LoginType) ([]APIKey, error) - GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUserIDParams) ([]APIKey, error) - GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) - GetActiveUserCount(ctx context.Context) (int64, error) - GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceBuild, error) - GetAllTailnetAgents(ctx context.Context) ([]TailnetAgent, error) - // For PG Coordinator HTMLDebug - GetAllTailnetCoordinators(ctx context.Context) ([]TailnetCoordinator, error) - GetAllTailnetPeers(ctx context.Context) ([]TailnetPeer, error) - GetAllTailnetTunnels(ctx context.Context) ([]TailnetTunnel, error) - GetAppSecurityKey(ctx context.Context) (string, error) - GetApplicationName(ctx context.Context) (string, error) - // GetAuditLogsBefore retrieves `row_limit` number of audit logs before the provided - // ID. - GetAuditLogsOffset(ctx context.Context, arg GetAuditLogsOffsetParams) ([]GetAuditLogsOffsetRow, error) - // This function returns roles for authorization purposes. Implied member roles - // are included. - GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error) - GetDBCryptKeys(ctx context.Context) ([]DBCryptKey, error) - GetDERPMeshKey(ctx context.Context) (string, error) - GetDefaultOrganization(ctx context.Context) (Organization, error) - GetDefaultProxyConfig(ctx context.Context) (GetDefaultProxyConfigRow, error) - GetDeploymentDAUs(ctx context.Context, tzOffset int32) ([]GetDeploymentDAUsRow, error) - GetDeploymentID(ctx context.Context) (string, error) - GetDeploymentWorkspaceAgentStats(ctx context.Context, createdAt time.Time) (GetDeploymentWorkspaceAgentStatsRow, error) - GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploymentWorkspaceStatsRow, error) - GetExternalAuthLink(ctx context.Context, arg GetExternalAuthLinkParams) (ExternalAuthLink, error) - GetExternalAuthLinksByUserID(ctx context.Context, userID uuid.UUID) ([]ExternalAuthLink, error) - GetFileByHashAndCreator(ctx context.Context, arg GetFileByHashAndCreatorParams) (File, error) - GetFileByID(ctx context.Context, id uuid.UUID) (File, error) - // Get all templates that use a file. - GetFileTemplates(ctx context.Context, fileID uuid.UUID) ([]GetFileTemplatesRow, error) - GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) - GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error) - GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error) - // If the group is a user made group, then we need to check the group_members table. - // If it is the "Everyone" group, then we need to check the organization_members table. - GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([]User, error) - GetGroupsByOrganizationAndUserID(ctx context.Context, arg GetGroupsByOrganizationAndUserIDParams) ([]Group, error) - GetGroupsByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]Group, error) - GetHealthSettings(ctx context.Context) (string, error) - GetHungProvisionerJobs(ctx context.Context, updatedAt time.Time) ([]ProvisionerJob, error) - GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg GetJFrogXrayScanByWorkspaceAndAgentIDParams) (JfrogXrayScan, error) - GetLastUpdateCheck(ctx context.Context) (string, error) - GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) - GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceBuild, error) - GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) - GetLicenseByID(ctx context.Context, id int32) (License, error) - GetLicenses(ctx context.Context) ([]License, error) - GetLogoURL(ctx context.Context) (string, error) - GetNotificationBanners(ctx context.Context) (string, error) - GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) - GetOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppCode, error) - GetOAuth2ProviderAppCodeByPrefix(ctx context.Context, secretPrefix []byte) (OAuth2ProviderAppCode, error) - GetOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppSecret, error) - GetOAuth2ProviderAppSecretByPrefix(ctx context.Context, secretPrefix []byte) (OAuth2ProviderAppSecret, error) - GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, appID uuid.UUID) ([]OAuth2ProviderAppSecret, error) - GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, hashPrefix []byte) (OAuth2ProviderAppToken, error) - GetOAuth2ProviderApps(ctx context.Context) ([]OAuth2ProviderApp, error) - GetOAuth2ProviderAppsByUserID(ctx context.Context, userID uuid.UUID) ([]GetOAuth2ProviderAppsByUserIDRow, error) - GetOAuthSigningKey(ctx context.Context) (string, error) - GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error) - GetOrganizationByName(ctx context.Context, name string) (Organization, error) - GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uuid.UUID) ([]GetOrganizationIDsByMemberIDsRow, error) - GetOrganizationMemberByUserID(ctx context.Context, arg GetOrganizationMemberByUserIDParams) (OrganizationMember, error) - GetOrganizationMembershipsByUserID(ctx context.Context, userID uuid.UUID) ([]OrganizationMember, error) - GetOrganizations(ctx context.Context) ([]Organization, error) - GetOrganizationsByUserID(ctx context.Context, userID uuid.UUID) ([]Organization, error) - GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]ParameterSchema, error) - GetPreviousTemplateVersion(ctx context.Context, arg GetPreviousTemplateVersionParams) (TemplateVersion, error) - GetProvisionerDaemons(ctx context.Context) ([]ProvisionerDaemon, error) - GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (ProvisionerJob, error) - GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUID) ([]ProvisionerJob, error) - GetProvisionerJobsByIDsWithQueuePosition(ctx context.Context, ids []uuid.UUID) ([]GetProvisionerJobsByIDsWithQueuePositionRow, error) - GetProvisionerJobsCreatedAfter(ctx context.Context, createdAt time.Time) ([]ProvisionerJob, error) - GetProvisionerLogsAfterID(ctx context.Context, arg GetProvisionerLogsAfterIDParams) ([]ProvisionerJobLog, error) - GetQuotaAllowanceForUser(ctx context.Context, userID uuid.UUID) (int64, error) - GetQuotaConsumedForUser(ctx context.Context, ownerID uuid.UUID) (int64, error) - GetReplicaByID(ctx context.Context, id uuid.UUID) (Replica, error) - GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]Replica, error) - GetTailnetAgents(ctx context.Context, id uuid.UUID) ([]TailnetAgent, error) - GetTailnetClientsForAgent(ctx context.Context, agentID uuid.UUID) ([]TailnetClient, error) - GetTailnetPeers(ctx context.Context, id uuid.UUID) ([]TailnetPeer, error) - GetTailnetTunnelPeerBindings(ctx context.Context, srcID uuid.UUID) ([]GetTailnetTunnelPeerBindingsRow, error) - GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUID) ([]GetTailnetTunnelPeerIDsRow, error) - // GetTemplateAppInsights returns the aggregate usage of each app in a given - // timeframe. The result can be filtered on template_ids, meaning only user data - // from workspaces based on those templates will be included. - GetTemplateAppInsights(ctx context.Context, arg GetTemplateAppInsightsParams) ([]GetTemplateAppInsightsRow, error) - // GetTemplateAppInsightsByTemplate is used for Prometheus metrics. Keep - // in sync with GetTemplateAppInsights and UpsertTemplateUsageStats. - GetTemplateAppInsightsByTemplate(ctx context.Context, arg GetTemplateAppInsightsByTemplateParams) ([]GetTemplateAppInsightsByTemplateRow, error) - GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (GetTemplateAverageBuildTimeRow, error) - GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error) - GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error) - GetTemplateDAUs(ctx context.Context, arg GetTemplateDAUsParams) ([]GetTemplateDAUsRow, error) - // GetTemplateInsights returns the aggregate user-produced usage of all - // workspaces in a given timeframe. The template IDs, active users, and - // usage_seconds all reflect any usage in the template, including apps. - // - // When combining data from multiple templates, we must make a guess at - // how the user behaved for the 30 minute interval. In this case we make - // the assumption that if the user used two workspaces for 15 minutes, - // they did so sequentially, thus we sum the usage up to a maximum of - // 30 minutes with LEAST(SUM(n), 30). - GetTemplateInsights(ctx context.Context, arg GetTemplateInsightsParams) (GetTemplateInsightsRow, error) - // GetTemplateInsightsByInterval returns all intervals between start and end - // time, if end time is a partial interval, it will be included in the results and - // that interval will be shorter than a full one. If there is no data for a selected - // interval/template, it will be included in the results with 0 active users. - GetTemplateInsightsByInterval(ctx context.Context, arg GetTemplateInsightsByIntervalParams) ([]GetTemplateInsightsByIntervalRow, error) - // GetTemplateInsightsByTemplate is used for Prometheus metrics. Keep - // in sync with GetTemplateInsights and UpsertTemplateUsageStats. - GetTemplateInsightsByTemplate(ctx context.Context, arg GetTemplateInsightsByTemplateParams) ([]GetTemplateInsightsByTemplateRow, error) - // GetTemplateParameterInsights does for each template in a given timeframe, - // look for the latest workspace build (for every workspace) that has been - // created in the timeframe and return the aggregate usage counts of parameter - // values. - GetTemplateParameterInsights(ctx context.Context, arg GetTemplateParameterInsightsParams) ([]GetTemplateParameterInsightsRow, error) - GetTemplateUsageStats(ctx context.Context, arg GetTemplateUsageStatsParams) ([]TemplateUsageStat, error) - GetTemplateVersionByID(ctx context.Context, id uuid.UUID) (TemplateVersion, error) - GetTemplateVersionByJobID(ctx context.Context, jobID uuid.UUID) (TemplateVersion, error) - GetTemplateVersionByTemplateIDAndName(ctx context.Context, arg GetTemplateVersionByTemplateIDAndNameParams) (TemplateVersion, error) - GetTemplateVersionParameters(ctx context.Context, templateVersionID uuid.UUID) ([]TemplateVersionParameter, error) - GetTemplateVersionVariables(ctx context.Context, templateVersionID uuid.UUID) ([]TemplateVersionVariable, error) - GetTemplateVersionsByIDs(ctx context.Context, ids []uuid.UUID) ([]TemplateVersion, error) - GetTemplateVersionsByTemplateID(ctx context.Context, arg GetTemplateVersionsByTemplateIDParams) ([]TemplateVersion, error) - GetTemplateVersionsCreatedAfter(ctx context.Context, createdAt time.Time) ([]TemplateVersion, error) - GetTemplates(ctx context.Context) ([]Template, error) - GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error) - GetUnexpiredLicenses(ctx context.Context) ([]License, error) - // GetUserActivityInsights returns the ranking with top active users. - // The result can be filtered on template_ids, meaning only user data - // from workspaces based on those templates will be included. - // Note: The usage_seconds and usage_seconds_cumulative differ only when - // requesting deployment-wide (or multiple template) data. Cumulative - // produces a bloated value if a user has used multiple templates - // simultaneously. - GetUserActivityInsights(ctx context.Context, arg GetUserActivityInsightsParams) ([]GetUserActivityInsightsRow, error) - GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error) - GetUserByID(ctx context.Context, id uuid.UUID) (User, error) - GetUserCount(ctx context.Context) (int64, error) - // GetUserLatencyInsights returns the median and 95th percentile connection - // latency that users have experienced. The result can be filtered on - // template_ids, meaning only user data from workspaces based on those templates - // will be included. - GetUserLatencyInsights(ctx context.Context, arg GetUserLatencyInsightsParams) ([]GetUserLatencyInsightsRow, error) - GetUserLinkByLinkedID(ctx context.Context, linkedID string) (UserLink, error) - GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error) - GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]UserLink, error) - GetUserWorkspaceBuildParameters(ctx context.Context, arg GetUserWorkspaceBuildParametersParams) ([]GetUserWorkspaceBuildParametersRow, error) - // This will never return deleted users. - GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUsersRow, error) - // This shouldn't check for deleted, because it's frequently used - // to look up references to actions. eg. a user could build a workspace - // for another user, then be deleted... we still want them to appear! - GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User, error) - GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) - GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) - GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error) - GetWorkspaceAgentLifecycleStateByID(ctx context.Context, id uuid.UUID) (GetWorkspaceAgentLifecycleStateByIDRow, error) - GetWorkspaceAgentLogSourcesByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgentLogSource, error) - GetWorkspaceAgentLogsAfter(ctx context.Context, arg GetWorkspaceAgentLogsAfterParams) ([]WorkspaceAgentLog, error) - GetWorkspaceAgentMetadata(ctx context.Context, arg GetWorkspaceAgentMetadataParams) ([]WorkspaceAgentMetadatum, error) - GetWorkspaceAgentPortShare(ctx context.Context, arg GetWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error) - GetWorkspaceAgentScriptsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgentScript, error) - GetWorkspaceAgentStats(ctx context.Context, createdAt time.Time) ([]GetWorkspaceAgentStatsRow, error) - GetWorkspaceAgentStatsAndLabels(ctx context.Context, createdAt time.Time) ([]GetWorkspaceAgentStatsAndLabelsRow, error) - GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgent, error) - GetWorkspaceAgentsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceAgent, error) - GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgent, error) - GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg GetWorkspaceAppByAgentIDAndSlugParams) (WorkspaceApp, error) - GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]WorkspaceApp, error) - GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceApp, error) - GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceApp, error) - GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (WorkspaceBuild, error) - GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (WorkspaceBuild, error) - GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams) (WorkspaceBuild, error) - GetWorkspaceBuildParameters(ctx context.Context, workspaceBuildID uuid.UUID) ([]WorkspaceBuildParameter, error) - GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg GetWorkspaceBuildsByWorkspaceIDParams) ([]WorkspaceBuild, error) - GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) - GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUID) (GetWorkspaceByAgentIDRow, error) - GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Workspace, error) - GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWorkspaceByOwnerIDAndNameParams) (Workspace, error) - GetWorkspaceByWorkspaceAppID(ctx context.Context, workspaceAppID uuid.UUID) (Workspace, error) - GetWorkspaceProxies(ctx context.Context) ([]WorkspaceProxy, error) - // Finds a workspace proxy that has an access URL or app hostname that matches - // the provided hostname. This is to check if a hostname matches any workspace - // proxy. - // - // The hostname must be sanitized to only contain [a-zA-Z0-9.-] before calling - // this query. The scheme, port and path should be stripped. - // - GetWorkspaceProxyByHostname(ctx context.Context, arg GetWorkspaceProxyByHostnameParams) (WorkspaceProxy, error) - GetWorkspaceProxyByID(ctx context.Context, id uuid.UUID) (WorkspaceProxy, error) - GetWorkspaceProxyByName(ctx context.Context, name string) (WorkspaceProxy, error) - GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error) - GetWorkspaceResourceMetadataByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceResourceMetadatum, error) - GetWorkspaceResourceMetadataCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceResourceMetadatum, error) - GetWorkspaceResourcesByJobID(ctx context.Context, jobID uuid.UUID) ([]WorkspaceResource, error) - GetWorkspaceResourcesByJobIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceResource, error) - GetWorkspaceResourcesCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceResource, error) - GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Context, templateIds []uuid.UUID) ([]GetWorkspaceUniqueOwnerCountByTemplateIDsRow, error) - // build_params is used to filter by build parameters if present. - // It has to be a CTE because the set returning function 'unnest' cannot - // be used in a WHERE clause. - GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error) - GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]Workspace, error) - InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) - // We use the organization_id as the id - // for simplicity since all users is - // every member of the org. - InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error) - InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) - InsertDBCryptKey(ctx context.Context, arg InsertDBCryptKeyParams) error - InsertDERPMeshKey(ctx context.Context, value string) error - InsertDeploymentID(ctx context.Context, value string) error - InsertExternalAuthLink(ctx context.Context, arg InsertExternalAuthLinkParams) (ExternalAuthLink, error) - InsertFile(ctx context.Context, arg InsertFileParams) (File, error) - InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error) - InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error) - InsertGroupMember(ctx context.Context, arg InsertGroupMemberParams) error - InsertLicense(ctx context.Context, arg InsertLicenseParams) (License, error) - // Inserts any group by name that does not exist. All new groups are given - // a random uuid, are inserted into the same organization. They have the default - // values for avatar, display name, and quota allowance (all zero values). - // If the name conflicts, do nothing. - InsertMissingGroups(ctx context.Context, arg InsertMissingGroupsParams) ([]Group, error) - InsertOAuth2ProviderApp(ctx context.Context, arg InsertOAuth2ProviderAppParams) (OAuth2ProviderApp, error) - InsertOAuth2ProviderAppCode(ctx context.Context, arg InsertOAuth2ProviderAppCodeParams) (OAuth2ProviderAppCode, error) - InsertOAuth2ProviderAppSecret(ctx context.Context, arg InsertOAuth2ProviderAppSecretParams) (OAuth2ProviderAppSecret, error) - InsertOAuth2ProviderAppToken(ctx context.Context, arg InsertOAuth2ProviderAppTokenParams) (OAuth2ProviderAppToken, error) - InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error) - InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error) - InsertProvisionerJob(ctx context.Context, arg InsertProvisionerJobParams) (ProvisionerJob, error) - InsertProvisionerJobLogs(ctx context.Context, arg InsertProvisionerJobLogsParams) ([]ProvisionerJobLog, error) - InsertReplica(ctx context.Context, arg InsertReplicaParams) (Replica, error) - InsertTemplate(ctx context.Context, arg InsertTemplateParams) error - InsertTemplateVersion(ctx context.Context, arg InsertTemplateVersionParams) error - InsertTemplateVersionParameter(ctx context.Context, arg InsertTemplateVersionParameterParams) (TemplateVersionParameter, error) - InsertTemplateVersionVariable(ctx context.Context, arg InsertTemplateVersionVariableParams) (TemplateVersionVariable, error) - InsertUser(ctx context.Context, arg InsertUserParams) (User, error) - // InsertUserGroupsByName adds a user to all provided groups, if they exist. - InsertUserGroupsByName(ctx context.Context, arg InsertUserGroupsByNameParams) error - InsertUserLink(ctx context.Context, arg InsertUserLinkParams) (UserLink, error) - InsertWorkspace(ctx context.Context, arg InsertWorkspaceParams) (Workspace, error) - InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspaceAgentParams) (WorkspaceAgent, error) - InsertWorkspaceAgentLogSources(ctx context.Context, arg InsertWorkspaceAgentLogSourcesParams) ([]WorkspaceAgentLogSource, error) - InsertWorkspaceAgentLogs(ctx context.Context, arg InsertWorkspaceAgentLogsParams) ([]WorkspaceAgentLog, error) - InsertWorkspaceAgentMetadata(ctx context.Context, arg InsertWorkspaceAgentMetadataParams) error - InsertWorkspaceAgentScripts(ctx context.Context, arg InsertWorkspaceAgentScriptsParams) ([]WorkspaceAgentScript, error) - InsertWorkspaceAgentStats(ctx context.Context, arg InsertWorkspaceAgentStatsParams) error - InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error) - InsertWorkspaceAppStats(ctx context.Context, arg InsertWorkspaceAppStatsParams) error - InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) error - InsertWorkspaceBuildParameters(ctx context.Context, arg InsertWorkspaceBuildParametersParams) error - InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspaceProxyParams) (WorkspaceProxy, error) - InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error) - InsertWorkspaceResourceMetadata(ctx context.Context, arg InsertWorkspaceResourceMetadataParams) ([]WorkspaceResourceMetadatum, error) - ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgentPortShare, error) - ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error - RegisterWorkspaceProxy(ctx context.Context, arg RegisterWorkspaceProxyParams) (WorkspaceProxy, error) - RemoveUserFromAllGroups(ctx context.Context, userID uuid.UUID) error - RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error - // Non blocking lock. Returns true if the lock was acquired, false otherwise. - // - // This must be called from within a transaction. The lock will be automatically - // released when the transaction ends. - TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock int64) (bool, error) - // This will always work regardless of the current state of the template version. - UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error - UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error - UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error - UpdateExternalAuthLink(ctx context.Context, arg UpdateExternalAuthLinkParams) (ExternalAuthLink, error) - UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error) - UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error) - UpdateInactiveUsersToDormant(ctx context.Context, arg UpdateInactiveUsersToDormantParams) ([]UpdateInactiveUsersToDormantRow, error) - UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error) - UpdateOAuth2ProviderAppByID(ctx context.Context, arg UpdateOAuth2ProviderAppByIDParams) (OAuth2ProviderApp, error) - UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg UpdateOAuth2ProviderAppSecretByIDParams) (OAuth2ProviderAppSecret, error) - UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg UpdateProvisionerDaemonLastSeenAtParams) error - UpdateProvisionerJobByID(ctx context.Context, arg UpdateProvisionerJobByIDParams) error - UpdateProvisionerJobWithCancelByID(ctx context.Context, arg UpdateProvisionerJobWithCancelByIDParams) error - UpdateProvisionerJobWithCompleteByID(ctx context.Context, arg UpdateProvisionerJobWithCompleteByIDParams) error - UpdateReplica(ctx context.Context, arg UpdateReplicaParams) (Replica, error) - UpdateTemplateACLByID(ctx context.Context, arg UpdateTemplateACLByIDParams) error - UpdateTemplateAccessControlByID(ctx context.Context, arg UpdateTemplateAccessControlByIDParams) error - UpdateTemplateActiveVersionByID(ctx context.Context, arg UpdateTemplateActiveVersionByIDParams) error - UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error - UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) error - UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) error - UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error - UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error - UpdateTemplateVersionExternalAuthProvidersByJobID(ctx context.Context, arg UpdateTemplateVersionExternalAuthProvidersByJobIDParams) error - UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg UpdateTemplateWorkspacesLastUsedAtParams) error - UpdateUserAppearanceSettings(ctx context.Context, arg UpdateUserAppearanceSettingsParams) (User, error) - UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error - UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error - UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLastSeenAtParams) (User, error) - UpdateUserLink(ctx context.Context, arg UpdateUserLinkParams) (UserLink, error) - UpdateUserLinkedID(ctx context.Context, arg UpdateUserLinkedIDParams) (UserLink, error) - UpdateUserLoginType(ctx context.Context, arg UpdateUserLoginTypeParams) (User, error) - UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) - UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error) - UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error) - UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error) - UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) - UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error - UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, arg UpdateWorkspaceAgentLifecycleStateByIDParams) error - UpdateWorkspaceAgentLogOverflowByID(ctx context.Context, arg UpdateWorkspaceAgentLogOverflowByIDParams) error - UpdateWorkspaceAgentMetadata(ctx context.Context, arg UpdateWorkspaceAgentMetadataParams) error - UpdateWorkspaceAgentStartupByID(ctx context.Context, arg UpdateWorkspaceAgentStartupByIDParams) error - UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error - UpdateWorkspaceAutomaticUpdates(ctx context.Context, arg UpdateWorkspaceAutomaticUpdatesParams) error - UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error - UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) error - UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg UpdateWorkspaceBuildDeadlineByIDParams) error - UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg UpdateWorkspaceBuildProvisionerStateByIDParams) error - UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error - UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg UpdateWorkspaceDormantDeletingAtParams) (Workspace, error) - UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error - // This allows editing the properties of a workspace proxy. - UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error) - UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error - UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error - UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error - UpsertAppSecurityKey(ctx context.Context, value string) error - UpsertApplicationName(ctx context.Context, value string) error - // The default proxy is implied and not actually stored in the database. - // So we need to store it's configuration here for display purposes. - // The functional values are immutable and controlled implicitly. - UpsertDefaultProxy(ctx context.Context, arg UpsertDefaultProxyParams) error - UpsertHealthSettings(ctx context.Context, value string) error - UpsertJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg UpsertJFrogXrayScanByWorkspaceAndAgentIDParams) error - UpsertLastUpdateCheck(ctx context.Context, value string) error - UpsertLogoURL(ctx context.Context, value string) error - UpsertNotificationBanners(ctx context.Context, value string) error - UpsertOAuthSigningKey(ctx context.Context, value string) error - UpsertProvisionerDaemon(ctx context.Context, arg UpsertProvisionerDaemonParams) (ProvisionerDaemon, error) - UpsertTailnetAgent(ctx context.Context, arg UpsertTailnetAgentParams) (TailnetAgent, error) - UpsertTailnetClient(ctx context.Context, arg UpsertTailnetClientParams) (TailnetClient, error) - UpsertTailnetClientSubscription(ctx context.Context, arg UpsertTailnetClientSubscriptionParams) error - UpsertTailnetCoordinator(ctx context.Context, id uuid.UUID) (TailnetCoordinator, error) - UpsertTailnetPeer(ctx context.Context, arg UpsertTailnetPeerParams) (TailnetPeer, error) - UpsertTailnetTunnel(ctx context.Context, arg UpsertTailnetTunnelParams) (TailnetTunnel, error) - // This query aggregates the workspace_agent_stats and workspace_app_stats data - // into a single table for efficient storage and querying. Half-hour buckets are - // used to store the data, and the minutes are summed for each user and template - // combination. The result is stored in the template_usage_stats table. - UpsertTemplateUsageStats(ctx context.Context) error - UpsertWorkspaceAgentPortShare(ctx context.Context, arg UpsertWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error) -} - -var _ sqlcQuerier = (*sqlQuerier)(nil) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e0fba2dad35bd..0ca7495e24903 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5519,6 +5519,47 @@ func (q *sqlQuerier) UpdateReplica(ctx context.Context, arg UpdateReplicaParams) return i, err } +const customRoles = `-- name: CustomRoles :many +SELECT + name, display_name, site_permissions, org_permissions, user_permissions, created_at, last_updated +FROM + custom_roles +WHERE + -- Case insensitive + name ILIKE ANY($1 :: text []) +` + +func (q *sqlQuerier) CustomRoles(ctx context.Context, lookupRoles []string) ([]CustomRole, error) { + rows, err := q.db.QueryContext(ctx, customRoles, pq.Array(lookupRoles)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CustomRole + for rows.Next() { + var i CustomRole + if err := rows.Scan( + &i.Name, + &i.DisplayName, + &i.SitePermissions, + &i.OrgPermissions, + &i.UserPermissions, + &i.CreatedAt, + &i.LastUpdated, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getAppSecurityKey = `-- name: GetAppSecurityKey :one SELECT value FROM site_configs WHERE key = 'app_signing_key' ` diff --git a/coderd/database/queries/roles.sql b/coderd/database/queries/roles.sql new file mode 100644 index 0000000000000..b5defb7de165c --- /dev/null +++ b/coderd/database/queries/roles.sql @@ -0,0 +1,9 @@ +-- name: CustomRoles :many +SELECT + * +FROM + custom_roles +WHERE + -- Case insensitive + name ILIKE ANY(@lookup_roles :: text []) +; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 9db8af72c8cf6..9dfc8c124aa75 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -9,6 +9,7 @@ const ( UniqueAgentStatsPkey UniqueConstraint = "agent_stats_pkey" // ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id); UniqueAPIKeysPkey UniqueConstraint = "api_keys_pkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id); UniqueAuditLogsPkey UniqueConstraint = "audit_logs_pkey" // ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); + UniqueCustomRolesPkey UniqueConstraint = "custom_roles_pkey" // ALTER TABLE ONLY custom_roles ADD CONSTRAINT custom_roles_pkey PRIMARY KEY (name); UniqueDbcryptKeysActiveKeyDigestKey UniqueConstraint = "dbcrypt_keys_active_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_active_key_digest_key UNIQUE (active_key_digest); UniqueDbcryptKeysPkey UniqueConstraint = "dbcrypt_keys_pkey" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_pkey PRIMARY KEY (number); UniqueDbcryptKeysRevokedKeyDigestKey UniqueConstraint = "dbcrypt_keys_revoked_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_revoked_key_digest_key UNIQUE (revoked_key_digest); @@ -74,6 +75,7 @@ const ( UniqueWorkspaceResourcesPkey UniqueConstraint = "workspace_resources_pkey" // ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_pkey PRIMARY KEY (id); UniqueWorkspacesPkey UniqueConstraint = "workspaces_pkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id); UniqueIndexAPIKeyName UniqueConstraint = "idx_api_key_name" // CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type); + UniqueIndexCustomRolesNameLower UniqueConstraint = "idx_custom_roles_name_lower" // CREATE UNIQUE INDEX idx_custom_roles_name_lower ON custom_roles USING btree (lower(name)); UniqueIndexOrganizationName UniqueConstraint = "idx_organization_name" // CREATE UNIQUE INDEX idx_organization_name ON organizations USING btree (name); UniqueIndexOrganizationNameLower UniqueConstraint = "idx_organization_name_lower" // CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name)); UniqueIndexProvisionerDaemonsNameOwnerKey UniqueConstraint = "idx_provisioner_daemons_name_owner_key" // CREATE UNIQUE INDEX idx_provisioner_daemons_name_owner_key ON provisioner_daemons USING btree (name, lower(COALESCE((tags ->> 'owner'::text), ''::text))); diff --git a/coderd/rbac/storedroles/store.go b/coderd/rbac/storedroles/store.go new file mode 100644 index 0000000000000..37d4e5f3a63ef --- /dev/null +++ b/coderd/rbac/storedroles/store.go @@ -0,0 +1,75 @@ +package storedroles + +import ( + "context" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/rbac" +) + +type StoredRoles struct { + db database.Store +} + +func New(db database.Store) *StoredRoles { + return &StoredRoles{ + db: db, + } +} + +type Roles struct { + sr StoredRoles + names []string + + // Compute once, then serve the cached result. + cached []rbac.Role + cachedError error +} + +// ExpandableRoles returns a struct that expand role names into their rbac.Roles. +// Do not expand at this call time, instead expand lazy when `Expand()` is called. +func (sr StoredRoles) ExpandableRoles(names []string) *Roles { + return &Roles{ + sr: sr, + names: names, + } +} + +// Expand will try to expand built-ins, then it's local cache, then +// it will go the database. +func (r Roles) Expand(ctx context.Context) ([]rbac.Role, error) { + if len(r.names) == 0 { + // That was easy + return []rbac.Role{}, nil + } + + // Use the cache first. + if len(r.cached) != 0 || r.cachedError != nil { + return r.cached, r.cachedError + } + + lookup := make([]string, 0) + roles := make([]rbac.Role, 0, len(r.names)) + + for _, name := range r.names { + // Remove any built in roles + expanded, err := rbac.RoleByName(name) + if err == nil { + roles = append(roles, expanded) + continue + } + + // Defer custom role lookup + lookup = append(lookup, name) + } + + if len(lookup) > 0 { + r.sr.db.CustomRoles(ctx, lookup) + } + + return roles, nil +} + +func (r Roles) Names() []string { + return r.names +} From 60274ad04ade6685f63deb9098ee1bb3c0649ef1 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 15:21:02 -0500 Subject: [PATCH 02/26] add custom role expansion from db --- coderd/database/dbmock/dbmock.go | 2 +- coderd/database/dump.sql | 6 +- .../migrations/000209_custom_roles.up.sql | 6 +- coderd/database/models.go | 6 +- coderd/httpmw/apikey.go | 12 ++- coderd/rbac/rolestore/rolestore.go | 82 +++++++++++++++++++ coderd/rbac/storedroles/store.go | 75 ----------------- 7 files changed, 103 insertions(+), 86 deletions(-) create mode 100644 coderd/rbac/rolestore/rolestore.go delete mode 100644 coderd/rbac/storedroles/store.go diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 7364f9a6c3c83..80901a6e22659 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -174,7 +174,7 @@ func (mr *MockStoreMockRecorder) CleanTailnetTunnels(arg0 any) *gomock.Call { } // CustomRoles mocks base method. -func (m *MockStore) CustomRoles(arg0 context.Context, arg1 string) ([]database.CustomRole, error) { +func (m *MockStore) CustomRoles(arg0 context.Context, arg1 []string) ([]database.CustomRole, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CustomRoles", arg0, arg1) ret0, _ := ret[0].([]database.CustomRole) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index dd9e69e275b10..0495751c45bf2 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -406,12 +406,12 @@ CREATE TABLE audit_logs ( CREATE TABLE custom_roles ( name text NOT NULL, - display_name text, + display_name text NOT NULL, site_permissions jsonb DEFAULT '[]'::jsonb NOT NULL, org_permissions jsonb DEFAULT '{}'::jsonb NOT NULL, user_permissions jsonb DEFAULT '[]'::jsonb NOT NULL, - created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, - last_updated timestamp with time zone DEFAULT CURRENT_TIMESTAMP + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + last_updated timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL ); COMMENT ON TABLE custom_roles IS 'Custom roles allow dynamic roles expanded at runtime'; diff --git a/coderd/database/migrations/000209_custom_roles.up.sql b/coderd/database/migrations/000209_custom_roles.up.sql index 64b76644c3b4f..e3fe8e509ddbf 100644 --- a/coderd/database/migrations/000209_custom_roles.up.sql +++ b/coderd/database/migrations/000209_custom_roles.up.sql @@ -3,7 +3,7 @@ CREATE TABLE custom_roles ( -- like: "name":"organization-admin:bbe8c156-c61e-4d36-b91e-697c6b1477e8" name text primary key, -- display_name is the actual name of the role displayed to the user. - display_name text, + display_name text NOT NULL, -- Unfortunately these values are schemaless json documents. -- If there was a permission table for these, that would involve @@ -17,8 +17,8 @@ CREATE TABLE custom_roles ( user_permissions jsonb NOT NULL default '[]', -- extra convenience meta data. - created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, - last_updated timestamp with time zone DEFAULT CURRENT_TIMESTAMP + created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_updated timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- Ensure no case variants of the same roles diff --git a/coderd/database/models.go b/coderd/database/models.go index e257894d0f174..ca0222bf35f83 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1784,12 +1784,12 @@ type AuditLog struct { // Custom roles allow dynamic roles expanded at runtime type CustomRole struct { Name string `db:"name" json:"name"` - DisplayName sql.NullString `db:"display_name" json:"display_name"` + DisplayName string `db:"display_name" json:"display_name"` SitePermissions json.RawMessage `db:"site_permissions" json:"site_permissions"` OrgPermissions json.RawMessage `db:"org_permissions" json:"org_permissions"` UserPermissions json.RawMessage `db:"user_permissions" json:"user_permissions"` - CreatedAt sql.NullTime `db:"created_at" json:"created_at"` - LastUpdated sql.NullTime `db:"last_updated" json:"last_updated"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + LastUpdated time.Time `db:"last_updated" json:"last_updated"` } // A table used to store the keys used to encrypt the database. diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 0bd064bf8e28a..22613de005b91 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -24,6 +24,7 @@ import ( "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/promoauth" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/rolestore" "github.com/coder/coder/v2/codersdk" ) @@ -437,11 +438,20 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon }) } + rbacRoles, err := rolestore.Expand(ctx, cfg.DB, roles.Roles) + if err != nil { + return write(http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to expand authenticated user roles", + Detail: err.Error(), + Validations: nil, + }) + } + // Actor is the user's authorization context. actor := rbac.Subject{ FriendlyName: roles.Username, ID: key.UserID.String(), - Roles: rbac.RoleNames(roles.Roles), + Roles: rbacRoles, Groups: roles.Groups, Scope: rbac.ScopeName(key.Scope), }.WithCachedASTValue() diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go new file mode 100644 index 0000000000000..3ad4839b17dd4 --- /dev/null +++ b/coderd/rbac/rolestore/rolestore.go @@ -0,0 +1,82 @@ +package rolestore + +import ( + "context" + "encoding/json" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/rbac" +) + +// Expand will expand built in roles, and fetch custom roles from the database. +func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, error) { + if len(names) == 0 { + // That was easy + return []rbac.Role{}, nil + } + + lookup := make([]string, 0) + roles := make([]rbac.Role, 0, len(names)) + + for _, name := range names { + // Remove any built in roles + expanded, err := rbac.RoleByName(name) + if err == nil { + roles = append(roles, expanded) + continue + } + + // Defer custom role lookup + lookup = append(lookup, name) + } + + if len(lookup) > 0 { + // If some roles are missing from the database, they are omitted from + // the expansion. These roles are no-ops. Should we raise some kind of + // warning when this happens? + dbroles, err := db.CustomRoles(ctx, lookup) + if err != nil { + return nil, xerrors.Errorf("fetch custom roles: %w", err) + } + + // convert dbroles -> roles + for _, dbrole := range dbroles { + converted, err := ConvertDBRole(dbrole) + if err != nil { + return nil, xerrors.Errorf("convert db role %q: %w", dbrole, err) + } + roles = append(roles, converted) + } + } + + return roles, nil +} + +func ConvertDBRole(dbRole database.CustomRole) (rbac.Role, error) { + role := rbac.Role{ + Name: dbRole.Name, + DisplayName: dbRole.DisplayName, + Site: nil, + Org: nil, + User: nil, + } + + err := json.Unmarshal(dbRole.SitePermissions, &role.Site) + if err != nil { + return role, xerrors.Errorf("unmarshal site permissions: %w", err) + } + + err = json.Unmarshal(dbRole.OrgPermissions, &role.Org) + if err != nil { + return role, xerrors.Errorf("unmarshal org permissions: %w", err) + } + + err = json.Unmarshal(dbRole.UserPermissions, &role.User) + if err != nil { + return role, xerrors.Errorf("unmarshal user permissions: %w", err) + } + + return role, nil +} diff --git a/coderd/rbac/storedroles/store.go b/coderd/rbac/storedroles/store.go deleted file mode 100644 index 37d4e5f3a63ef..0000000000000 --- a/coderd/rbac/storedroles/store.go +++ /dev/null @@ -1,75 +0,0 @@ -package storedroles - -import ( - "context" - - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/rbac" -) - -type StoredRoles struct { - db database.Store -} - -func New(db database.Store) *StoredRoles { - return &StoredRoles{ - db: db, - } -} - -type Roles struct { - sr StoredRoles - names []string - - // Compute once, then serve the cached result. - cached []rbac.Role - cachedError error -} - -// ExpandableRoles returns a struct that expand role names into their rbac.Roles. -// Do not expand at this call time, instead expand lazy when `Expand()` is called. -func (sr StoredRoles) ExpandableRoles(names []string) *Roles { - return &Roles{ - sr: sr, - names: names, - } -} - -// Expand will try to expand built-ins, then it's local cache, then -// it will go the database. -func (r Roles) Expand(ctx context.Context) ([]rbac.Role, error) { - if len(r.names) == 0 { - // That was easy - return []rbac.Role{}, nil - } - - // Use the cache first. - if len(r.cached) != 0 || r.cachedError != nil { - return r.cached, r.cachedError - } - - lookup := make([]string, 0) - roles := make([]rbac.Role, 0, len(r.names)) - - for _, name := range r.names { - // Remove any built in roles - expanded, err := rbac.RoleByName(name) - if err == nil { - roles = append(roles, expanded) - continue - } - - // Defer custom role lookup - lookup = append(lookup, name) - } - - if len(lookup) > 0 { - r.sr.db.CustomRoles(ctx, lookup) - } - - return roles, nil -} - -func (r Roles) Names() []string { - return r.names -} From 863f4771e8beb38c76f8e31feb6be3f9ca0da9fa Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 15:22:02 -0500 Subject: [PATCH 03/26] commit back querier.go, whoops! --- coderd/database/querier.go | 442 +++++++++++++++++++++++++++++++++++++ 1 file changed, 442 insertions(+) create mode 100644 coderd/database/querier.go diff --git a/coderd/database/querier.go b/coderd/database/querier.go new file mode 100644 index 0000000000000..d55c9d1156167 --- /dev/null +++ b/coderd/database/querier.go @@ -0,0 +1,442 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 + +package database + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +type sqlcQuerier interface { + // Blocks until the lock is acquired. + // + // This must be called from within a transaction. The lock will be automatically + // released when the transaction ends. + AcquireLock(ctx context.Context, pgAdvisoryXactLock int64) error + // Acquires the lock for a single job that isn't started, completed, + // canceled, and that matches an array of provisioner types. + // + // SKIP LOCKED is used to jump over locked rows. This prevents + // multiple provisioners from acquiring the same jobs. See: + // https://www.postgresql.org/docs/9.5/sql-select.html#SQL-FOR-UPDATE-SHARE + AcquireProvisionerJob(ctx context.Context, arg AcquireProvisionerJobParams) (ProvisionerJob, error) + // Bumps the workspace deadline by the template's configured "activity_bump" + // duration (default 1h). If the workspace bump will cross an autostart + // threshold, then the bump is autostart + TTL. This is the deadline behavior if + // the workspace was to autostart from a stopped state. + // + // Max deadline is respected, and the deadline will never be bumped past it. + // The deadline will never decrease. + // We only bump if the template has an activity bump duration set. + // We only bump if the raw interval is positive and non-zero. + // We only bump if workspace shutdown is manual. + // We only bump when 5% of the deadline has elapsed. + ActivityBumpWorkspace(ctx context.Context, arg ActivityBumpWorkspaceParams) error + // AllUserIDs returns all UserIDs regardless of user status or deletion. + AllUserIDs(ctx context.Context) ([]uuid.UUID, error) + // Archiving templates is a soft delete action, so is reversible. + // Archiving prevents the version from being used and discovered + // by listing. + // Only unused template versions will be archived, which are any versions not + // referenced by the latest build of a workspace. + ArchiveUnusedTemplateVersions(ctx context.Context, arg ArchiveUnusedTemplateVersionsParams) ([]uuid.UUID, error) + BatchUpdateWorkspaceLastUsedAt(ctx context.Context, arg BatchUpdateWorkspaceLastUsedAtParams) error + CleanTailnetCoordinators(ctx context.Context) error + CleanTailnetLostPeers(ctx context.Context) error + CleanTailnetTunnels(ctx context.Context) error + CustomRoles(ctx context.Context, lookupRoles []string) ([]CustomRole, error) + DeleteAPIKeyByID(ctx context.Context, id string) error + DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error + DeleteAllTailnetClientSubscriptions(ctx context.Context, arg DeleteAllTailnetClientSubscriptionsParams) error + DeleteAllTailnetTunnels(ctx context.Context, arg DeleteAllTailnetTunnelsParams) error + DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error + DeleteCoordinator(ctx context.Context, id uuid.UUID) error + DeleteExternalAuthLink(ctx context.Context, arg DeleteExternalAuthLinkParams) error + DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error + DeleteGroupByID(ctx context.Context, id uuid.UUID) error + DeleteGroupMemberFromGroup(ctx context.Context, arg DeleteGroupMemberFromGroupParams) error + DeleteLicense(ctx context.Context, id int32) (int32, error) + DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error + DeleteOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) error + DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx context.Context, arg DeleteOAuth2ProviderAppCodesByAppAndUserIDParams) error + DeleteOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) error + DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx context.Context, arg DeleteOAuth2ProviderAppTokensByAppAndUserIDParams) error + // Delete provisioner daemons that have been created at least a week ago + // and have not connected to coderd since a week. + // A provisioner daemon with "zeroed" last_seen_at column indicates possible + // connectivity issues (no provisioner daemon activity since registration). + DeleteOldProvisionerDaemons(ctx context.Context) error + // If an agent hasn't connected in the last 7 days, we purge it's logs. + // Logs can take up a lot of space, so it's important we clean up frequently. + DeleteOldWorkspaceAgentLogs(ctx context.Context) error + DeleteOldWorkspaceAgentStats(ctx context.Context) error + DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error + DeleteTailnetAgent(ctx context.Context, arg DeleteTailnetAgentParams) (DeleteTailnetAgentRow, error) + DeleteTailnetClient(ctx context.Context, arg DeleteTailnetClientParams) (DeleteTailnetClientRow, error) + DeleteTailnetClientSubscription(ctx context.Context, arg DeleteTailnetClientSubscriptionParams) error + DeleteTailnetPeer(ctx context.Context, arg DeleteTailnetPeerParams) (DeleteTailnetPeerRow, error) + DeleteTailnetTunnel(ctx context.Context, arg DeleteTailnetTunnelParams) (DeleteTailnetTunnelRow, error) + DeleteWorkspaceAgentPortShare(ctx context.Context, arg DeleteWorkspaceAgentPortShareParams) error + DeleteWorkspaceAgentPortSharesByTemplate(ctx context.Context, templateID uuid.UUID) error + FavoriteWorkspace(ctx context.Context, id uuid.UUID) error + GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) + // there is no unique constraint on empty token names + GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error) + GetAPIKeysByLoginType(ctx context.Context, loginType LoginType) ([]APIKey, error) + GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUserIDParams) ([]APIKey, error) + GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) + GetActiveUserCount(ctx context.Context) (int64, error) + GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceBuild, error) + GetAllTailnetAgents(ctx context.Context) ([]TailnetAgent, error) + // For PG Coordinator HTMLDebug + GetAllTailnetCoordinators(ctx context.Context) ([]TailnetCoordinator, error) + GetAllTailnetPeers(ctx context.Context) ([]TailnetPeer, error) + GetAllTailnetTunnels(ctx context.Context) ([]TailnetTunnel, error) + GetAppSecurityKey(ctx context.Context) (string, error) + GetApplicationName(ctx context.Context) (string, error) + // GetAuditLogsBefore retrieves `row_limit` number of audit logs before the provided + // ID. + GetAuditLogsOffset(ctx context.Context, arg GetAuditLogsOffsetParams) ([]GetAuditLogsOffsetRow, error) + // This function returns roles for authorization purposes. Implied member roles + // are included. + GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error) + GetDBCryptKeys(ctx context.Context) ([]DBCryptKey, error) + GetDERPMeshKey(ctx context.Context) (string, error) + GetDefaultOrganization(ctx context.Context) (Organization, error) + GetDefaultProxyConfig(ctx context.Context) (GetDefaultProxyConfigRow, error) + GetDeploymentDAUs(ctx context.Context, tzOffset int32) ([]GetDeploymentDAUsRow, error) + GetDeploymentID(ctx context.Context) (string, error) + GetDeploymentWorkspaceAgentStats(ctx context.Context, createdAt time.Time) (GetDeploymentWorkspaceAgentStatsRow, error) + GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploymentWorkspaceStatsRow, error) + GetExternalAuthLink(ctx context.Context, arg GetExternalAuthLinkParams) (ExternalAuthLink, error) + GetExternalAuthLinksByUserID(ctx context.Context, userID uuid.UUID) ([]ExternalAuthLink, error) + GetFileByHashAndCreator(ctx context.Context, arg GetFileByHashAndCreatorParams) (File, error) + GetFileByID(ctx context.Context, id uuid.UUID) (File, error) + // Get all templates that use a file. + GetFileTemplates(ctx context.Context, fileID uuid.UUID) ([]GetFileTemplatesRow, error) + GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) + GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error) + GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error) + // If the group is a user made group, then we need to check the group_members table. + // If it is the "Everyone" group, then we need to check the organization_members table. + GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([]User, error) + GetGroupsByOrganizationAndUserID(ctx context.Context, arg GetGroupsByOrganizationAndUserIDParams) ([]Group, error) + GetGroupsByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]Group, error) + GetHealthSettings(ctx context.Context) (string, error) + GetHungProvisionerJobs(ctx context.Context, updatedAt time.Time) ([]ProvisionerJob, error) + GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg GetJFrogXrayScanByWorkspaceAndAgentIDParams) (JfrogXrayScan, error) + GetLastUpdateCheck(ctx context.Context) (string, error) + GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) + GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceBuild, error) + GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) + GetLicenseByID(ctx context.Context, id int32) (License, error) + GetLicenses(ctx context.Context) ([]License, error) + GetLogoURL(ctx context.Context) (string, error) + GetNotificationBanners(ctx context.Context) (string, error) + GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) + GetOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppCode, error) + GetOAuth2ProviderAppCodeByPrefix(ctx context.Context, secretPrefix []byte) (OAuth2ProviderAppCode, error) + GetOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppSecret, error) + GetOAuth2ProviderAppSecretByPrefix(ctx context.Context, secretPrefix []byte) (OAuth2ProviderAppSecret, error) + GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, appID uuid.UUID) ([]OAuth2ProviderAppSecret, error) + GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, hashPrefix []byte) (OAuth2ProviderAppToken, error) + GetOAuth2ProviderApps(ctx context.Context) ([]OAuth2ProviderApp, error) + GetOAuth2ProviderAppsByUserID(ctx context.Context, userID uuid.UUID) ([]GetOAuth2ProviderAppsByUserIDRow, error) + GetOAuthSigningKey(ctx context.Context) (string, error) + GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error) + GetOrganizationByName(ctx context.Context, name string) (Organization, error) + GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uuid.UUID) ([]GetOrganizationIDsByMemberIDsRow, error) + GetOrganizationMemberByUserID(ctx context.Context, arg GetOrganizationMemberByUserIDParams) (OrganizationMember, error) + GetOrganizationMembershipsByUserID(ctx context.Context, userID uuid.UUID) ([]OrganizationMember, error) + GetOrganizations(ctx context.Context) ([]Organization, error) + GetOrganizationsByUserID(ctx context.Context, userID uuid.UUID) ([]Organization, error) + GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]ParameterSchema, error) + GetPreviousTemplateVersion(ctx context.Context, arg GetPreviousTemplateVersionParams) (TemplateVersion, error) + GetProvisionerDaemons(ctx context.Context) ([]ProvisionerDaemon, error) + GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (ProvisionerJob, error) + GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUID) ([]ProvisionerJob, error) + GetProvisionerJobsByIDsWithQueuePosition(ctx context.Context, ids []uuid.UUID) ([]GetProvisionerJobsByIDsWithQueuePositionRow, error) + GetProvisionerJobsCreatedAfter(ctx context.Context, createdAt time.Time) ([]ProvisionerJob, error) + GetProvisionerLogsAfterID(ctx context.Context, arg GetProvisionerLogsAfterIDParams) ([]ProvisionerJobLog, error) + GetQuotaAllowanceForUser(ctx context.Context, userID uuid.UUID) (int64, error) + GetQuotaConsumedForUser(ctx context.Context, ownerID uuid.UUID) (int64, error) + GetReplicaByID(ctx context.Context, id uuid.UUID) (Replica, error) + GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]Replica, error) + GetTailnetAgents(ctx context.Context, id uuid.UUID) ([]TailnetAgent, error) + GetTailnetClientsForAgent(ctx context.Context, agentID uuid.UUID) ([]TailnetClient, error) + GetTailnetPeers(ctx context.Context, id uuid.UUID) ([]TailnetPeer, error) + GetTailnetTunnelPeerBindings(ctx context.Context, srcID uuid.UUID) ([]GetTailnetTunnelPeerBindingsRow, error) + GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUID) ([]GetTailnetTunnelPeerIDsRow, error) + // GetTemplateAppInsights returns the aggregate usage of each app in a given + // timeframe. The result can be filtered on template_ids, meaning only user data + // from workspaces based on those templates will be included. + GetTemplateAppInsights(ctx context.Context, arg GetTemplateAppInsightsParams) ([]GetTemplateAppInsightsRow, error) + // GetTemplateAppInsightsByTemplate is used for Prometheus metrics. Keep + // in sync with GetTemplateAppInsights and UpsertTemplateUsageStats. + GetTemplateAppInsightsByTemplate(ctx context.Context, arg GetTemplateAppInsightsByTemplateParams) ([]GetTemplateAppInsightsByTemplateRow, error) + GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (GetTemplateAverageBuildTimeRow, error) + GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error) + GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error) + GetTemplateDAUs(ctx context.Context, arg GetTemplateDAUsParams) ([]GetTemplateDAUsRow, error) + // GetTemplateInsights returns the aggregate user-produced usage of all + // workspaces in a given timeframe. The template IDs, active users, and + // usage_seconds all reflect any usage in the template, including apps. + // + // When combining data from multiple templates, we must make a guess at + // how the user behaved for the 30 minute interval. In this case we make + // the assumption that if the user used two workspaces for 15 minutes, + // they did so sequentially, thus we sum the usage up to a maximum of + // 30 minutes with LEAST(SUM(n), 30). + GetTemplateInsights(ctx context.Context, arg GetTemplateInsightsParams) (GetTemplateInsightsRow, error) + // GetTemplateInsightsByInterval returns all intervals between start and end + // time, if end time is a partial interval, it will be included in the results and + // that interval will be shorter than a full one. If there is no data for a selected + // interval/template, it will be included in the results with 0 active users. + GetTemplateInsightsByInterval(ctx context.Context, arg GetTemplateInsightsByIntervalParams) ([]GetTemplateInsightsByIntervalRow, error) + // GetTemplateInsightsByTemplate is used for Prometheus metrics. Keep + // in sync with GetTemplateInsights and UpsertTemplateUsageStats. + GetTemplateInsightsByTemplate(ctx context.Context, arg GetTemplateInsightsByTemplateParams) ([]GetTemplateInsightsByTemplateRow, error) + // GetTemplateParameterInsights does for each template in a given timeframe, + // look for the latest workspace build (for every workspace) that has been + // created in the timeframe and return the aggregate usage counts of parameter + // values. + GetTemplateParameterInsights(ctx context.Context, arg GetTemplateParameterInsightsParams) ([]GetTemplateParameterInsightsRow, error) + GetTemplateUsageStats(ctx context.Context, arg GetTemplateUsageStatsParams) ([]TemplateUsageStat, error) + GetTemplateVersionByID(ctx context.Context, id uuid.UUID) (TemplateVersion, error) + GetTemplateVersionByJobID(ctx context.Context, jobID uuid.UUID) (TemplateVersion, error) + GetTemplateVersionByTemplateIDAndName(ctx context.Context, arg GetTemplateVersionByTemplateIDAndNameParams) (TemplateVersion, error) + GetTemplateVersionParameters(ctx context.Context, templateVersionID uuid.UUID) ([]TemplateVersionParameter, error) + GetTemplateVersionVariables(ctx context.Context, templateVersionID uuid.UUID) ([]TemplateVersionVariable, error) + GetTemplateVersionsByIDs(ctx context.Context, ids []uuid.UUID) ([]TemplateVersion, error) + GetTemplateVersionsByTemplateID(ctx context.Context, arg GetTemplateVersionsByTemplateIDParams) ([]TemplateVersion, error) + GetTemplateVersionsCreatedAfter(ctx context.Context, createdAt time.Time) ([]TemplateVersion, error) + GetTemplates(ctx context.Context) ([]Template, error) + GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error) + GetUnexpiredLicenses(ctx context.Context) ([]License, error) + // GetUserActivityInsights returns the ranking with top active users. + // The result can be filtered on template_ids, meaning only user data + // from workspaces based on those templates will be included. + // Note: The usage_seconds and usage_seconds_cumulative differ only when + // requesting deployment-wide (or multiple template) data. Cumulative + // produces a bloated value if a user has used multiple templates + // simultaneously. + GetUserActivityInsights(ctx context.Context, arg GetUserActivityInsightsParams) ([]GetUserActivityInsightsRow, error) + GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error) + GetUserByID(ctx context.Context, id uuid.UUID) (User, error) + GetUserCount(ctx context.Context) (int64, error) + // GetUserLatencyInsights returns the median and 95th percentile connection + // latency that users have experienced. The result can be filtered on + // template_ids, meaning only user data from workspaces based on those templates + // will be included. + GetUserLatencyInsights(ctx context.Context, arg GetUserLatencyInsightsParams) ([]GetUserLatencyInsightsRow, error) + GetUserLinkByLinkedID(ctx context.Context, linkedID string) (UserLink, error) + GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error) + GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]UserLink, error) + GetUserWorkspaceBuildParameters(ctx context.Context, arg GetUserWorkspaceBuildParametersParams) ([]GetUserWorkspaceBuildParametersRow, error) + // This will never return deleted users. + GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUsersRow, error) + // This shouldn't check for deleted, because it's frequently used + // to look up references to actions. eg. a user could build a workspace + // for another user, then be deleted... we still want them to appear! + GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User, error) + GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) + GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) + GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error) + GetWorkspaceAgentLifecycleStateByID(ctx context.Context, id uuid.UUID) (GetWorkspaceAgentLifecycleStateByIDRow, error) + GetWorkspaceAgentLogSourcesByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgentLogSource, error) + GetWorkspaceAgentLogsAfter(ctx context.Context, arg GetWorkspaceAgentLogsAfterParams) ([]WorkspaceAgentLog, error) + GetWorkspaceAgentMetadata(ctx context.Context, arg GetWorkspaceAgentMetadataParams) ([]WorkspaceAgentMetadatum, error) + GetWorkspaceAgentPortShare(ctx context.Context, arg GetWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error) + GetWorkspaceAgentScriptsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgentScript, error) + GetWorkspaceAgentStats(ctx context.Context, createdAt time.Time) ([]GetWorkspaceAgentStatsRow, error) + GetWorkspaceAgentStatsAndLabels(ctx context.Context, createdAt time.Time) ([]GetWorkspaceAgentStatsAndLabelsRow, error) + GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgent, error) + GetWorkspaceAgentsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceAgent, error) + GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgent, error) + GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg GetWorkspaceAppByAgentIDAndSlugParams) (WorkspaceApp, error) + GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]WorkspaceApp, error) + GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceApp, error) + GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceApp, error) + GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (WorkspaceBuild, error) + GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (WorkspaceBuild, error) + GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams) (WorkspaceBuild, error) + GetWorkspaceBuildParameters(ctx context.Context, workspaceBuildID uuid.UUID) ([]WorkspaceBuildParameter, error) + GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg GetWorkspaceBuildsByWorkspaceIDParams) ([]WorkspaceBuild, error) + GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) + GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUID) (GetWorkspaceByAgentIDRow, error) + GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Workspace, error) + GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWorkspaceByOwnerIDAndNameParams) (Workspace, error) + GetWorkspaceByWorkspaceAppID(ctx context.Context, workspaceAppID uuid.UUID) (Workspace, error) + GetWorkspaceProxies(ctx context.Context) ([]WorkspaceProxy, error) + // Finds a workspace proxy that has an access URL or app hostname that matches + // the provided hostname. This is to check if a hostname matches any workspace + // proxy. + // + // The hostname must be sanitized to only contain [a-zA-Z0-9.-] before calling + // this query. The scheme, port and path should be stripped. + // + GetWorkspaceProxyByHostname(ctx context.Context, arg GetWorkspaceProxyByHostnameParams) (WorkspaceProxy, error) + GetWorkspaceProxyByID(ctx context.Context, id uuid.UUID) (WorkspaceProxy, error) + GetWorkspaceProxyByName(ctx context.Context, name string) (WorkspaceProxy, error) + GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error) + GetWorkspaceResourceMetadataByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceResourceMetadatum, error) + GetWorkspaceResourceMetadataCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceResourceMetadatum, error) + GetWorkspaceResourcesByJobID(ctx context.Context, jobID uuid.UUID) ([]WorkspaceResource, error) + GetWorkspaceResourcesByJobIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceResource, error) + GetWorkspaceResourcesCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceResource, error) + GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Context, templateIds []uuid.UUID) ([]GetWorkspaceUniqueOwnerCountByTemplateIDsRow, error) + // build_params is used to filter by build parameters if present. + // It has to be a CTE because the set returning function 'unnest' cannot + // be used in a WHERE clause. + GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error) + GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]Workspace, error) + InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) + // We use the organization_id as the id + // for simplicity since all users is + // every member of the org. + InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error) + InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) + InsertDBCryptKey(ctx context.Context, arg InsertDBCryptKeyParams) error + InsertDERPMeshKey(ctx context.Context, value string) error + InsertDeploymentID(ctx context.Context, value string) error + InsertExternalAuthLink(ctx context.Context, arg InsertExternalAuthLinkParams) (ExternalAuthLink, error) + InsertFile(ctx context.Context, arg InsertFileParams) (File, error) + InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error) + InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error) + InsertGroupMember(ctx context.Context, arg InsertGroupMemberParams) error + InsertLicense(ctx context.Context, arg InsertLicenseParams) (License, error) + // Inserts any group by name that does not exist. All new groups are given + // a random uuid, are inserted into the same organization. They have the default + // values for avatar, display name, and quota allowance (all zero values). + // If the name conflicts, do nothing. + InsertMissingGroups(ctx context.Context, arg InsertMissingGroupsParams) ([]Group, error) + InsertOAuth2ProviderApp(ctx context.Context, arg InsertOAuth2ProviderAppParams) (OAuth2ProviderApp, error) + InsertOAuth2ProviderAppCode(ctx context.Context, arg InsertOAuth2ProviderAppCodeParams) (OAuth2ProviderAppCode, error) + InsertOAuth2ProviderAppSecret(ctx context.Context, arg InsertOAuth2ProviderAppSecretParams) (OAuth2ProviderAppSecret, error) + InsertOAuth2ProviderAppToken(ctx context.Context, arg InsertOAuth2ProviderAppTokenParams) (OAuth2ProviderAppToken, error) + InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error) + InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error) + InsertProvisionerJob(ctx context.Context, arg InsertProvisionerJobParams) (ProvisionerJob, error) + InsertProvisionerJobLogs(ctx context.Context, arg InsertProvisionerJobLogsParams) ([]ProvisionerJobLog, error) + InsertReplica(ctx context.Context, arg InsertReplicaParams) (Replica, error) + InsertTemplate(ctx context.Context, arg InsertTemplateParams) error + InsertTemplateVersion(ctx context.Context, arg InsertTemplateVersionParams) error + InsertTemplateVersionParameter(ctx context.Context, arg InsertTemplateVersionParameterParams) (TemplateVersionParameter, error) + InsertTemplateVersionVariable(ctx context.Context, arg InsertTemplateVersionVariableParams) (TemplateVersionVariable, error) + InsertUser(ctx context.Context, arg InsertUserParams) (User, error) + // InsertUserGroupsByName adds a user to all provided groups, if they exist. + InsertUserGroupsByName(ctx context.Context, arg InsertUserGroupsByNameParams) error + InsertUserLink(ctx context.Context, arg InsertUserLinkParams) (UserLink, error) + InsertWorkspace(ctx context.Context, arg InsertWorkspaceParams) (Workspace, error) + InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspaceAgentParams) (WorkspaceAgent, error) + InsertWorkspaceAgentLogSources(ctx context.Context, arg InsertWorkspaceAgentLogSourcesParams) ([]WorkspaceAgentLogSource, error) + InsertWorkspaceAgentLogs(ctx context.Context, arg InsertWorkspaceAgentLogsParams) ([]WorkspaceAgentLog, error) + InsertWorkspaceAgentMetadata(ctx context.Context, arg InsertWorkspaceAgentMetadataParams) error + InsertWorkspaceAgentScripts(ctx context.Context, arg InsertWorkspaceAgentScriptsParams) ([]WorkspaceAgentScript, error) + InsertWorkspaceAgentStats(ctx context.Context, arg InsertWorkspaceAgentStatsParams) error + InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error) + InsertWorkspaceAppStats(ctx context.Context, arg InsertWorkspaceAppStatsParams) error + InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) error + InsertWorkspaceBuildParameters(ctx context.Context, arg InsertWorkspaceBuildParametersParams) error + InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspaceProxyParams) (WorkspaceProxy, error) + InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error) + InsertWorkspaceResourceMetadata(ctx context.Context, arg InsertWorkspaceResourceMetadataParams) ([]WorkspaceResourceMetadatum, error) + ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgentPortShare, error) + ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error + RegisterWorkspaceProxy(ctx context.Context, arg RegisterWorkspaceProxyParams) (WorkspaceProxy, error) + RemoveUserFromAllGroups(ctx context.Context, userID uuid.UUID) error + RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error + // Non blocking lock. Returns true if the lock was acquired, false otherwise. + // + // This must be called from within a transaction. The lock will be automatically + // released when the transaction ends. + TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock int64) (bool, error) + // This will always work regardless of the current state of the template version. + UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error + UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error + UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error + UpdateExternalAuthLink(ctx context.Context, arg UpdateExternalAuthLinkParams) (ExternalAuthLink, error) + UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error) + UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error) + UpdateInactiveUsersToDormant(ctx context.Context, arg UpdateInactiveUsersToDormantParams) ([]UpdateInactiveUsersToDormantRow, error) + UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error) + UpdateOAuth2ProviderAppByID(ctx context.Context, arg UpdateOAuth2ProviderAppByIDParams) (OAuth2ProviderApp, error) + UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg UpdateOAuth2ProviderAppSecretByIDParams) (OAuth2ProviderAppSecret, error) + UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg UpdateProvisionerDaemonLastSeenAtParams) error + UpdateProvisionerJobByID(ctx context.Context, arg UpdateProvisionerJobByIDParams) error + UpdateProvisionerJobWithCancelByID(ctx context.Context, arg UpdateProvisionerJobWithCancelByIDParams) error + UpdateProvisionerJobWithCompleteByID(ctx context.Context, arg UpdateProvisionerJobWithCompleteByIDParams) error + UpdateReplica(ctx context.Context, arg UpdateReplicaParams) (Replica, error) + UpdateTemplateACLByID(ctx context.Context, arg UpdateTemplateACLByIDParams) error + UpdateTemplateAccessControlByID(ctx context.Context, arg UpdateTemplateAccessControlByIDParams) error + UpdateTemplateActiveVersionByID(ctx context.Context, arg UpdateTemplateActiveVersionByIDParams) error + UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error + UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) error + UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) error + UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error + UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error + UpdateTemplateVersionExternalAuthProvidersByJobID(ctx context.Context, arg UpdateTemplateVersionExternalAuthProvidersByJobIDParams) error + UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg UpdateTemplateWorkspacesLastUsedAtParams) error + UpdateUserAppearanceSettings(ctx context.Context, arg UpdateUserAppearanceSettingsParams) (User, error) + UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error + UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error + UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLastSeenAtParams) (User, error) + UpdateUserLink(ctx context.Context, arg UpdateUserLinkParams) (UserLink, error) + UpdateUserLinkedID(ctx context.Context, arg UpdateUserLinkedIDParams) (UserLink, error) + UpdateUserLoginType(ctx context.Context, arg UpdateUserLoginTypeParams) (User, error) + UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) + UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error) + UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error) + UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error) + UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) + UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error + UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, arg UpdateWorkspaceAgentLifecycleStateByIDParams) error + UpdateWorkspaceAgentLogOverflowByID(ctx context.Context, arg UpdateWorkspaceAgentLogOverflowByIDParams) error + UpdateWorkspaceAgentMetadata(ctx context.Context, arg UpdateWorkspaceAgentMetadataParams) error + UpdateWorkspaceAgentStartupByID(ctx context.Context, arg UpdateWorkspaceAgentStartupByIDParams) error + UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error + UpdateWorkspaceAutomaticUpdates(ctx context.Context, arg UpdateWorkspaceAutomaticUpdatesParams) error + UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error + UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) error + UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg UpdateWorkspaceBuildDeadlineByIDParams) error + UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg UpdateWorkspaceBuildProvisionerStateByIDParams) error + UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error + UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg UpdateWorkspaceDormantDeletingAtParams) (Workspace, error) + UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error + // This allows editing the properties of a workspace proxy. + UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error) + UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error + UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error + UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error + UpsertAppSecurityKey(ctx context.Context, value string) error + UpsertApplicationName(ctx context.Context, value string) error + // The default proxy is implied and not actually stored in the database. + // So we need to store it's configuration here for display purposes. + // The functional values are immutable and controlled implicitly. + UpsertDefaultProxy(ctx context.Context, arg UpsertDefaultProxyParams) error + UpsertHealthSettings(ctx context.Context, value string) error + UpsertJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg UpsertJFrogXrayScanByWorkspaceAndAgentIDParams) error + UpsertLastUpdateCheck(ctx context.Context, value string) error + UpsertLogoURL(ctx context.Context, value string) error + UpsertNotificationBanners(ctx context.Context, value string) error + UpsertOAuthSigningKey(ctx context.Context, value string) error + UpsertProvisionerDaemon(ctx context.Context, arg UpsertProvisionerDaemonParams) (ProvisionerDaemon, error) + UpsertTailnetAgent(ctx context.Context, arg UpsertTailnetAgentParams) (TailnetAgent, error) + UpsertTailnetClient(ctx context.Context, arg UpsertTailnetClientParams) (TailnetClient, error) + UpsertTailnetClientSubscription(ctx context.Context, arg UpsertTailnetClientSubscriptionParams) error + UpsertTailnetCoordinator(ctx context.Context, id uuid.UUID) (TailnetCoordinator, error) + UpsertTailnetPeer(ctx context.Context, arg UpsertTailnetPeerParams) (TailnetPeer, error) + UpsertTailnetTunnel(ctx context.Context, arg UpsertTailnetTunnelParams) (TailnetTunnel, error) + // This query aggregates the workspace_agent_stats and workspace_app_stats data + // into a single table for efficient storage and querying. Half-hour buckets are + // used to store the data, and the minutes are summed for each user and template + // combination. The result is stored in the template_usage_stats table. + UpsertTemplateUsageStats(ctx context.Context) error + UpsertWorkspaceAgentPortShare(ctx context.Context, arg UpsertWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error) +} + +var _ sqlcQuerier = (*sqlQuerier)(nil) From 27e90cd0ac2b7e52a5944bd0391727b2546934b7 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 17:08:46 -0500 Subject: [PATCH 04/26] Add route to insert new roles --- coderd/apidoc/docs.go | 35 ++++++++++- coderd/apidoc/swagger.json | 35 ++++++++++- coderd/database/db2sdk/db2sdk.go | 58 ++++++++++++++++- coderd/database/dbauthz/dbauthz.go | 4 ++ coderd/database/dbmem/dbmem.go | 9 +++ coderd/database/dbmetrics/dbmetrics.go | 7 +++ coderd/database/dbmock/dbmock.go | 15 +++++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 59 +++++++++++++++++ coderd/database/queries/roles.sql | 31 +++++++++ coderd/rbac/policy/policy.go | 3 +- coderd/rbac/roles.go | 44 +++++++++++++ coderd/rbac/rolestore/rolestore.go | 27 ++++++++ codersdk/deployment.go | 3 + codersdk/roles.go | 18 ++++++ docs/api/members.md | 44 +++++++++++++ docs/api/schemas.md | 1 + enterprise/coderd/coderd.go | 10 +++ enterprise/coderd/roles.go | 87 ++++++++++++++++++++++++++ enterprise/coderd/users.go | 25 ++++++++ site/src/api/typesGenerated.ts | 20 ++++++ 21 files changed, 528 insertions(+), 8 deletions(-) create mode 100644 enterprise/coderd/roles.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 0a22d84d13642..0c6586a5997ba 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4288,6 +4288,34 @@ const docTemplate = `{ } } }, + "/users/roles/": { + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Members" + ], + "summary": "Upsert a custom site-wide role", + "operationId": "upsert-a-custom-site-wide-role", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Role" + } + } + } + } + } + }, "/users/{user}": { "get": { "security": [ @@ -9547,17 +9575,20 @@ const docTemplate = `{ "enum": [ "example", "auto-fill-parameters", - "multi-organization" + "multi-organization", + "custom-roles" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", + "ExperimentCustomRoles": "Allows creating runtime custom roles", "ExperimentExample": "This isn't used for anything.", "ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed." }, "x-enum-varnames": [ "ExperimentExample", "ExperimentAutoFillParameters", - "ExperimentMultiOrganization" + "ExperimentMultiOrganization", + "ExperimentCustomRoles" ] }, "codersdk.ExternalAuth": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 331b1512393f7..21bde99c0e57f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3777,6 +3777,30 @@ } } }, + "/users/roles/": { + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Members"], + "summary": "Upsert a custom site-wide role", + "operationId": "upsert-a-custom-site-wide-role", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Role" + } + } + } + } + } + }, "/users/{user}": { "get": { "security": [ @@ -8545,16 +8569,23 @@ }, "codersdk.Experiment": { "type": "string", - "enum": ["example", "auto-fill-parameters", "multi-organization"], + "enum": [ + "example", + "auto-fill-parameters", + "multi-organization", + "custom-roles" + ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", + "ExperimentCustomRoles": "Allows creating runtime custom roles", "ExperimentExample": "This isn't used for anything.", "ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed." }, "x-enum-varnames": [ "ExperimentExample", "ExperimentAutoFillParameters", - "ExperimentMultiOrganization" + "ExperimentMultiOrganization", + "ExperimentCustomRoles" ] }, "codersdk.ExternalAuth": { diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 14a24e66316d0..a9426a00655da 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -18,6 +18,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/parameter" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk/proto" @@ -28,9 +29,25 @@ import ( // database types to slices of codersdk types. // Only works if the function takes a single argument. func List[F any, T any](list []F, convert func(F) T) []T { - into := make([]T, 0, len(list)) - for _, item := range list { - into = append(into, convert(item)) + return ListLazy(convert)(list) +} + +// ListLazy returns the converter function for a list, but does not eval +// the input. Helpful for combining the Map and the List functions. +func ListLazy[F any, T any](convert func(F) T) func(list []F) []T { + return func(list []F) []T { + into := make([]T, 0, len(list)) + for _, item := range list { + into = append(into, convert(item)) + } + return into + } +} + +func Map[K comparable, F any, T any](params map[K]F, convert func(F) T) map[K]T { + into := make(map[K]T) + for k, item := range params { + into[k] = convert(item) } return into } @@ -500,3 +517,38 @@ func ProvisionerDaemon(dbDaemon database.ProvisionerDaemon) codersdk.Provisioner } return result } + +func RolePermissions(role rbac.Role) codersdk.RolePermissions { + return codersdk.RolePermissions{ + Name: role.Name, + DisplayName: role.DisplayName, + SitePermissions: List(role.Site, Permission), + OrganizationPermissions: Map(role.Org, ListLazy(Permission)), + UserPermissions: List(role.Site, Permission), + } +} + +func Permission(permission rbac.Permission) codersdk.Permission { + return codersdk.Permission{ + Negate: permission.Negate, + ResourceType: codersdk.RBACResource(permission.ResourceType), + Action: codersdk.RBACAction(permission.Action), + } +} + +func RolePermissionsDB(role codersdk.RolePermissions) rbac.Role { + return rbac.Role{ + Name: role.Name, + DisplayName: role.DisplayName, + Site: List(role.SitePermissions, PermissionToDB), + Org: Map(role.OrganizationPermissions, ListLazy(PermissionToDB)), + User: List(role.UserPermissions, PermissionToDB), + } +} +func PermissionToDB(permission codersdk.Permission) rbac.Permission { + return rbac.Permission{ + Negate: permission.Negate, + ResourceType: string(permission.ResourceType), + Action: policy.Action(permission.Action), + } +} diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index cb6382498bfe0..1785202bc70da 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3295,6 +3295,10 @@ func (q *querier) UpsertApplicationName(ctx context.Context, value string) error return q.db.UpsertApplicationName(ctx, value) } +func (q *querier) UpsertCustomRole(ctx context.Context, arg database.UpsertCustomRoleParams) (database.CustomRole, error) { + panic("not implemented") +} + func (q *querier) UpsertDefaultProxy(ctx context.Context, arg database.UpsertDefaultProxyParams) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index c847d196074d2..d6b667da6397a 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -8192,6 +8192,15 @@ func (q *FakeQuerier) UpsertApplicationName(_ context.Context, data string) erro return nil } +func (q *FakeQuerier) UpsertCustomRole(ctx context.Context, arg database.UpsertCustomRoleParams) (database.CustomRole, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.CustomRole{}, err + } + + panic("not implemented") +} + func (q *FakeQuerier) UpsertDefaultProxy(_ context.Context, arg database.UpsertDefaultProxyParams) error { q.defaultProxyDisplayName = arg.DisplayName q.defaultProxyIconURL = arg.IconUrl diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 99a59a1fe5401..962367922852d 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -2160,6 +2160,13 @@ func (m metricsStore) UpsertApplicationName(ctx context.Context, value string) e return r0 } +func (m metricsStore) UpsertCustomRole(ctx context.Context, arg database.UpsertCustomRoleParams) (database.CustomRole, error) { + start := time.Now() + r0, r1 := m.s.UpsertCustomRole(ctx, arg) + m.queryLatencies.WithLabelValues("UpsertCustomRole").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) UpsertDefaultProxy(ctx context.Context, arg database.UpsertDefaultProxyParams) error { start := time.Now() r0 := m.s.UpsertDefaultProxy(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 80901a6e22659..14aaaa7c7aa1b 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -4522,6 +4522,21 @@ func (mr *MockStoreMockRecorder) UpsertApplicationName(arg0, arg1 any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertApplicationName", reflect.TypeOf((*MockStore)(nil).UpsertApplicationName), arg0, arg1) } +// UpsertCustomRole mocks base method. +func (m *MockStore) UpsertCustomRole(arg0 context.Context, arg1 database.UpsertCustomRoleParams) (database.CustomRole, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertCustomRole", arg0, arg1) + ret0, _ := ret[0].(database.CustomRole) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpsertCustomRole indicates an expected call of UpsertCustomRole. +func (mr *MockStoreMockRecorder) UpsertCustomRole(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertCustomRole", reflect.TypeOf((*MockStore)(nil).UpsertCustomRole), arg0, arg1) +} + // UpsertDefaultProxy mocks base method. func (m *MockStore) UpsertDefaultProxy(arg0 context.Context, arg1 database.UpsertDefaultProxyParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index d55c9d1156167..c999546d310bd 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -414,6 +414,7 @@ type sqlcQuerier interface { UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error UpsertAppSecurityKey(ctx context.Context, value string) error UpsertApplicationName(ctx context.Context, value string) error + UpsertCustomRole(ctx context.Context, arg UpsertCustomRoleParams) (CustomRole, error) // The default proxy is implied and not actually stored in the database. // So we need to store it's configuration here for display purposes. // The functional values are immutable and controlled implicitly. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 0ca7495e24903..b7ac15450bd60 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5560,6 +5560,65 @@ func (q *sqlQuerier) CustomRoles(ctx context.Context, lookupRoles []string) ([]C return items, nil } +const upsertCustomRole = `-- name: UpsertCustomRole :one +INSERT INTO + custom_roles ( + name, + display_name, + site_permissions, + org_permissions, + user_permissions, + created_at, + last_updted +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + now(), + now() + ) +ON CONFLICT (name) + DO UPDATE SET + display_name = $2, + site_permissions = $3, + org_permissions = $4, + user_permissions = $5, + last_updated = now() +RETURNING name, display_name, site_permissions, org_permissions, user_permissions, created_at, last_updated +` + +type UpsertCustomRoleParams struct { + Name string `db:"name" json:"name"` + DisplayName string `db:"display_name" json:"display_name"` + SitePermissions json.RawMessage `db:"site_permissions" json:"site_permissions"` + OrgPermissions json.RawMessage `db:"org_permissions" json:"org_permissions"` + UserPermissions json.RawMessage `db:"user_permissions" json:"user_permissions"` +} + +func (q *sqlQuerier) UpsertCustomRole(ctx context.Context, arg UpsertCustomRoleParams) (CustomRole, error) { + row := q.db.QueryRowContext(ctx, upsertCustomRole, + arg.Name, + arg.DisplayName, + arg.SitePermissions, + arg.OrgPermissions, + arg.UserPermissions, + ) + var i CustomRole + err := row.Scan( + &i.Name, + &i.DisplayName, + &i.SitePermissions, + &i.OrgPermissions, + &i.UserPermissions, + &i.CreatedAt, + &i.LastUpdated, + ) + return i, err +} + const getAppSecurityKey = `-- name: GetAppSecurityKey :one SELECT value FROM site_configs WHERE key = 'app_signing_key' ` diff --git a/coderd/database/queries/roles.sql b/coderd/database/queries/roles.sql index b5defb7de165c..28aeb81d1964c 100644 --- a/coderd/database/queries/roles.sql +++ b/coderd/database/queries/roles.sql @@ -7,3 +7,34 @@ WHERE -- Case insensitive name ILIKE ANY(@lookup_roles :: text []) ; + + +-- name: UpsertCustomRole :one +INSERT INTO + custom_roles ( + name, + display_name, + site_permissions, + org_permissions, + user_permissions, + created_at, + last_updted +) +VALUES ( + @name, + @display_name, + @site_permissions, + @org_permissions, + @user_permissions, + now(), + now() + ) +ON CONFLICT (name) + DO UPDATE SET + display_name = @display_name, + site_permissions = @site_permissions, + org_permissions = @org_permissions, + user_permissions = @user_permissions, + last_updated = now() +RETURNING * +; diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 26afb0e011ca7..2d3213264a514 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -209,7 +209,8 @@ var RBACPermissions = map[string]PermissionDefinition{ Actions: map[Action]ActionDefinition{ ActionAssign: actDef("ability to assign roles"), ActionRead: actDef("view what roles are assignable"), - ActionDelete: actDef("ability to delete roles"), + ActionDelete: actDef("ability to unassign roles"), + ActionCreate: actDef("ability to create/delete/edit custom roles"), }, }, "assign_org_role": { diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index cee365d06624c..4148ac9448a80 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -1,6 +1,8 @@ package rbac import ( + "errors" + "fmt" "sort" "strings" @@ -369,6 +371,21 @@ type Permission struct { Action policy.Action `json:"action"` } +func (perm Permission) Valid() error { + resource, ok := policy.RBACPermissions[perm.ResourceType] + if !ok { + return fmt.Errorf("invalid resource type %q", perm.ResourceType) + } + + if perm.Action != policy.WildcardSymbol { + _, ok := resource.Actions[perm.Action] + if !ok { + return fmt.Errorf("invalid action %q for resource %q", perm.Action, perm.ResourceType) + } + } + return nil +} + // Role is a set of permissions at multiple levels: // - Site level permissions apply EVERYWHERE // - Org level permissions apply to EVERYTHING in a given ORG @@ -393,6 +410,33 @@ type Role struct { cachedRegoValue ast.Value } +// Valid will check all it's permissions and ensure they are all correct +// according to the policy. +func (r Role) Valid() error { + var errs []error + for _, perm := range r.Site { + if err := perm.Valid(); err != nil { + errs = append(errs, fmt.Errorf("site: %w", err)) + } + } + + for orgID, permissions := range r.Org { + for _, perm := range permissions { + if err := perm.Valid(); err != nil { + errs = append(errs, fmt.Errorf("org=%q: %w", orgID, err)) + } + } + } + + for _, perm := range r.User { + if err := perm.Valid(); err != nil { + errs = append(errs, fmt.Errorf("user: %w", err)) + } + } + + return errors.Join(errs...) +} + type Roles []Role func (roles Roles) Expand() ([]Role, error) { diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index 3ad4839b17dd4..8751473bd6405 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -80,3 +80,30 @@ func ConvertDBRole(dbRole database.CustomRole) (rbac.Role, error) { return role, nil } + +func ConvertRoleToDB(role rbac.Role) (database.CustomRole, error) { + dbRole := database.CustomRole{ + Name: role.Name, + DisplayName: role.DisplayName, + } + + siteData, err := json.Marshal(role.Site) + if err != nil { + return dbRole, xerrors.Errorf("marshal site permissions: %w", err) + } + dbRole.SitePermissions = siteData + + orgData, err := json.Marshal(role.Org) + if err != nil { + return dbRole, xerrors.Errorf("marshal org permissions: %w", err) + } + dbRole.OrgPermissions = orgData + + userData, err := json.Marshal(role.User) + if err != nil { + return dbRole, xerrors.Errorf("marshal user permissions: %w", err) + } + dbRole.UserPermissions = userData + + return dbRole, nil +} diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 8f8499e51f13b..8fe7b6c4a991f 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -54,6 +54,7 @@ const ( FeatureWorkspaceBatchActions FeatureName = "workspace_batch_actions" FeatureAccessControl FeatureName = "access_control" FeatureControlSharedPorts FeatureName = "control_shared_ports" + FeatureCustomRoles FeatureName = "custom_roles" ) // FeatureNames must be kept in-sync with the Feature enum above. @@ -74,6 +75,7 @@ var FeatureNames = []FeatureName{ FeatureWorkspaceBatchActions, FeatureAccessControl, FeatureControlSharedPorts, + FeatureCustomRoles, } // Humanize returns the feature name in a human-readable format. @@ -2218,6 +2220,7 @@ const ( ExperimentExample Experiment = "example" // This isn't used for anything. ExperimentAutoFillParameters Experiment = "auto-fill-parameters" // This should not be taken out of experiments until we have redesigned the feature. ExperimentMultiOrganization Experiment = "multi-organization" // Requires organization context for interactions, default org is assumed. + ExperimentCustomRoles Experiment = "custom-roles" // Allows creating runtime custom roles ) // ExperimentsAll should include all experiments that are safe for diff --git a/codersdk/roles.go b/codersdk/roles.go index 5ed9a92539654..a198515ab6667 100644 --- a/codersdk/roles.go +++ b/codersdk/roles.go @@ -19,6 +19,24 @@ type AssignableRoles struct { Assignable bool `json:"assignable"` } +// Permission is the format passed into the rego. +type Permission struct { + // Negate makes this a negative permission + Negate bool `json:"negate"` + ResourceType RBACResource `json:"resource_type"` + Action RBACAction `json:"action"` +} + +// RolePermissions is a longer form of Role used to edit custom roles. +type RolePermissions struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + SitePermissions []Permission `json:"site_permissions"` + // map[] -> Permissions + OrganizationPermissions map[string][]Permission `json:"organization_permissions"` + UserPermissions []Permission `json:"user_permissions"` +} + // ListSiteRoles lists all assignable site wide roles. func (c *Client) ListSiteRoles(ctx context.Context) ([]AssignableRoles, error) { res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/roles", nil) diff --git a/docs/api/members.md b/docs/api/members.md index e44056664588a..c9cbbb9a69a3f 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -154,3 +154,47 @@ Status Code **200** | `» name` | string | false | | | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Upsert a custom site-wide role + +### Code samples + +```shell +# Example request using curl +curl -X PATCH http://coder-server:8080/api/v2/users/roles/ \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PATCH /users/roles/` + +### Example responses + +> 200 Response + +```json +[ + { + "display_name": "string", + "name": "string" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.Role](schemas.md#codersdkrole) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| ---------------- | ------ | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» display_name` | string | false | | | +| `» name` | string | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 42f8f43517233..6c81e307e88fc 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2694,6 +2694,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `example` | | `auto-fill-parameters` | | `multi-organization` | +| `custom-roles` | ## codersdk.ExternalAuth diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 56c774911018b..9b2ad0752c37b 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -326,6 +326,16 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Put("/", api.putAppearance) }) }) + + r.Route("/users/roles", func(r chi.Router) { + r.Use( + api.customRolesEnabledMW, + apiKeyMiddleware, + ) + + r.Patch("/", api.patchRole) + }) + r.Route("/users/{user}/quiet-hours", func(r chi.Router) { r.Use( api.autostopRequirementEnabledMW, diff --git a/enterprise/coderd/roles.go b/enterprise/coderd/roles.go new file mode 100644 index 0000000000000..1bf3ee29d88c3 --- /dev/null +++ b/enterprise/coderd/roles.go @@ -0,0 +1,87 @@ +package coderd + +import ( + "net/http" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/rbac/rolestore" + "github.com/coder/coder/v2/codersdk" +) + +// patchRole will allow creating a custom role +// +// @Summary Upsert a custom site-wide role +// @ID upsert-a-custom-site-wide-role +// @Security CoderSessionToken +// @Produce json +// @Tags Members +// @Success 200 {array} codersdk.Role +// @Router /users/roles/ [patch] +func (api *API) patchRole(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var req codersdk.RolePermissions + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + if len(req.OrganizationPermissions) > 0 { + // Org perms should be assigned only in org specific roles. Otherwise, + // it gets complicated to keep track of who can do what. + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request, not allowed to assign organization permissions for a site wide role.", + Detail: "site wide roles may not contain organization specific permissions", + }) + return + } + + rbacRole := db2sdk.RolePermissionsDB(req) + err := rbacRole.Valid() + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request, at least 1 permissions is invalid", + Detail: err.Error(), + }) + return + } + + args, err := rolestore.ConvertRoleToDB(rbacRole) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request", + Detail: err.Error(), + }) + return + } + + inserted, err := api.Database.UpsertCustomRole(ctx, database.UpsertCustomRoleParams{ + Name: args.Name, + DisplayName: args.DisplayName, + SitePermissions: args.SitePermissions, + OrgPermissions: args.OrgPermissions, + UserPermissions: args.UserPermissions, + }) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to update role permissions", + Detail: err.Error(), + }) + return + } + + convertedInsert, err := rolestore.ConvertDBRole(inserted) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Permissions were updated, unable to read them back out of the database.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Role(convertedInsert)) +} diff --git a/enterprise/coderd/users.go b/enterprise/coderd/users.go index 935eeb8f6e689..a29aa1836557d 100644 --- a/enterprise/coderd/users.go +++ b/enterprise/coderd/users.go @@ -14,6 +14,31 @@ import ( "github.com/coder/coder/v2/codersdk" ) +func (api *API) customRolesEnabledMW(next http.Handler) http.Handler { + return httpmw.RequireExperiment(api.AGPL.Experiments, codersdk.ExperimentCustomRoles)( + http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + // Entitlement must be enabled. + api.entitlementsMu.RLock() + entitled := api.entitlements.Features[codersdk.FeatureCustomRoles].Entitlement != codersdk.EntitlementNotEntitled + enabled := api.entitlements.Features[codersdk.FeatureCustomRoles].Enabled + api.entitlementsMu.RUnlock() + if !entitled { + httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{ + Message: "Custom roles is an Enterprise feature. Contact sales!", + }) + return + } + if !enabled { + httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{ + Message: "Custom roles is not enabled", + }) + return + } + + next.ServeHTTP(rw, r) + })) +} + func (api *API) autostopRequirementEnabledMW(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { // Entitlement must be enabled. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9331339ed1aa1..a977832f492f0 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -821,6 +821,13 @@ export interface PatchWorkspaceProxy { readonly regenerate_token: boolean; } +// From codersdk/roles.go +export interface Permission { + readonly negate: boolean; + readonly resource_type: RBACResource; + readonly action: RBACAction; +} + // From codersdk/oauth2.go export interface PostOAuth2ProviderAppRequest { readonly name: string; @@ -972,6 +979,15 @@ export interface Role { readonly display_name: string; } +// From codersdk/roles.go +export interface RolePermissions { + readonly name: string; + readonly display_name: string; + readonly site_permissions: readonly Permission[]; + readonly organization_permissions: Record; + readonly user_permissions: readonly Permission[]; +} + // From codersdk/deployment.go export interface SSHConfig { readonly DeploymentName: string; @@ -1909,10 +1925,12 @@ export const Entitlements: Entitlement[] = [ // From codersdk/deployment.go export type Experiment = | "auto-fill-parameters" + | "custom-roles" | "example" | "multi-organization"; export const Experiments: Experiment[] = [ "auto-fill-parameters", + "custom-roles", "example", "multi-organization", ]; @@ -1925,6 +1943,7 @@ export type FeatureName = | "audit_log" | "browser_only" | "control_shared_ports" + | "custom_roles" | "external_provisioner_daemons" | "external_token_encryption" | "high_availability" @@ -1942,6 +1961,7 @@ export const FeatureNames: FeatureName[] = [ "audit_log", "browser_only", "control_shared_ports", + "custom_roles", "external_provisioner_daemons", "external_token_encryption", "high_availability", From 3ad641cc748756b632d4ba0ad46689c6c852b5ee Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 17:56:07 -0500 Subject: [PATCH 05/26] adding esclation and validation checks on custom roles --- coderd/database/dbauthz/dbauthz.go | 6 ++- coderd/database/dbmem/dbmem.go | 51 +++++++++++++++++++++++-- coderd/database/dbmetrics/dbmetrics.go | 6 +-- coderd/database/dbmock/dbmock.go | 12 +++--- coderd/database/models.go | 2 +- coderd/database/querier.go | 2 +- coderd/database/queries.sql.go | 8 ++-- coderd/database/queries/roles.sql | 5 ++- coderd/rbac/roles.go | 8 ++-- coderd/rbac/roles_test.go | 9 +++++ coderd/rbac/rolestore/rolestore.go | 2 +- coderd/roles.go | 2 + codersdk/rbacresources_gen.go | 30 +++++++++++++++ codersdk/roles.go | 14 +++++++ enterprise/coderd/roles.go | 53 ++++++++++++++++++++++++++ enterprise/coderd/roles_test.go | 52 +++++++++++++++++++++++++ scripts/rbacgen/codersdk.gotmpl | 12 ++++++ 17 files changed, 247 insertions(+), 27 deletions(-) create mode 100644 enterprise/coderd/roles_test.go diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 1785202bc70da..f9f230478ca3a 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -704,6 +704,10 @@ func (q *querier) authorizeTemplateInsights(ctx context.Context, templateIDs []u return nil } +func (q *querier) CustomRoles(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { + panic("not implemented") +} + func (q *querier) AcquireLock(ctx context.Context, id int64) error { return q.db.AcquireLock(ctx, id) } @@ -773,7 +777,7 @@ func (q *querier) CleanTailnetTunnels(ctx context.Context) error { return q.db.CleanTailnetTunnels(ctx) } -func (q *querier) CustomRoles(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { +func (q *querier) CustomRolesByName(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { panic("not implemented") } diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index d6b667da6397a..ecfa58f79b4db 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -75,6 +75,7 @@ func New() database.Store { workspaces: make([]database.Workspace, 0), licenses: make([]database.License, 0), workspaceProxies: make([]database.WorkspaceProxy, 0), + customRoles: make([]database.CustomRole, 0), locks: map[int64]struct{}{}, }, } @@ -179,6 +180,7 @@ type data struct { workspaceResources []database.WorkspaceResource workspaces []database.Workspace workspaceProxies []database.WorkspaceProxy + customRoles []database.CustomRole // Locks is a map of lock names. Any keys within the map are currently // locked. locks map[int64]struct{} @@ -894,6 +896,10 @@ func (q *FakeQuerier) getLatestWorkspaceAppByTemplateIDUserIDSlugNoLock(ctx cont return database.WorkspaceApp{}, sql.ErrNoRows } +func (q *FakeQuerier) CustomRoles(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { + panic("not implemented") +} + func (*FakeQuerier) AcquireLock(_ context.Context, _ int64) error { return xerrors.New("AcquireLock must only be called within a transaction") } @@ -1172,8 +1178,21 @@ func (*FakeQuerier) CleanTailnetTunnels(context.Context) error { return ErrUnimplemented } -func (q *FakeQuerier) CustomRoles(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { - panic("not implemented") +func (q *FakeQuerier) CustomRolesByName(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + found := make([]database.CustomRole, 0) + for _, role := range q.data.customRoles { + if slices.ContainsFunc(lookupRoles, func(s string) bool { + return strings.EqualFold(s, role.Name) + }) { + role := role + found = append(found, role) + } + } + + return found, nil } func (q *FakeQuerier) DeleteAPIKeyByID(_ context.Context, id string) error { @@ -8192,13 +8211,37 @@ func (q *FakeQuerier) UpsertApplicationName(_ context.Context, data string) erro return nil } -func (q *FakeQuerier) UpsertCustomRole(ctx context.Context, arg database.UpsertCustomRoleParams) (database.CustomRole, error) { +func (q *FakeQuerier) UpsertCustomRole(_ context.Context, arg database.UpsertCustomRoleParams) (database.CustomRole, error) { err := validateDatabaseType(arg) if err != nil { return database.CustomRole{}, err } - panic("not implemented") + q.mutex.RLock() + defer q.mutex.RUnlock() + for i := range q.customRoles { + if strings.EqualFold(q.customRoles[i].Name, arg.Name) { + q.customRoles[i].DisplayName = arg.DisplayName + q.customRoles[i].SitePermissions = arg.SitePermissions + q.customRoles[i].OrgPermissions = arg.OrgPermissions + q.customRoles[i].UserPermissions = arg.UserPermissions + q.customRoles[i].LastUpdated = dbtime.Now() + return q.customRoles[i], nil + } + } + + role := database.CustomRole{ + Name: arg.Name, + DisplayName: arg.DisplayName, + SitePermissions: arg.SitePermissions, + OrgPermissions: arg.OrgPermissions, + UserPermissions: arg.UserPermissions, + CreatedAt: dbtime.Now(), + LastUpdated: dbtime.Now(), + } + q.customRoles = append(q.customRoles, role) + + return role, nil } func (q *FakeQuerier) UpsertDefaultProxy(_ context.Context, arg database.UpsertDefaultProxyParams) error { diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 962367922852d..4e0c2b8fed158 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -144,10 +144,10 @@ func (m metricsStore) CleanTailnetTunnels(ctx context.Context) error { return r0 } -func (m metricsStore) CustomRoles(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { +func (m metricsStore) CustomRolesByName(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { start := time.Now() - r0, r1 := m.s.CustomRoles(ctx, lookupRoles) - m.queryLatencies.WithLabelValues("CustomRoles").Observe(time.Since(start).Seconds()) + r0, r1 := m.s.CustomRolesByName(ctx, lookupRoles) + m.queryLatencies.WithLabelValues("CustomRolesByName").Observe(time.Since(start).Seconds()) return r0, r1 } diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 14aaaa7c7aa1b..69558e884c6a6 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -173,19 +173,19 @@ func (mr *MockStoreMockRecorder) CleanTailnetTunnels(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanTailnetTunnels", reflect.TypeOf((*MockStore)(nil).CleanTailnetTunnels), arg0) } -// CustomRoles mocks base method. -func (m *MockStore) CustomRoles(arg0 context.Context, arg1 []string) ([]database.CustomRole, error) { +// CustomRolesByName mocks base method. +func (m *MockStore) CustomRolesByName(arg0 context.Context, arg1 []string) ([]database.CustomRole, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CustomRoles", arg0, arg1) + ret := m.ctrl.Call(m, "CustomRolesByName", arg0, arg1) ret0, _ := ret[0].([]database.CustomRole) ret1, _ := ret[1].(error) return ret0, ret1 } -// CustomRoles indicates an expected call of CustomRoles. -func (mr *MockStoreMockRecorder) CustomRoles(arg0, arg1 any) *gomock.Call { +// CustomRolesByName indicates an expected call of CustomRolesByName. +func (mr *MockStoreMockRecorder) CustomRolesByName(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomRoles", reflect.TypeOf((*MockStore)(nil).CustomRoles), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomRolesByName", reflect.TypeOf((*MockStore)(nil).CustomRolesByName), arg0, arg1) } // DeleteAPIKeyByID mocks base method. diff --git a/coderd/database/models.go b/coderd/database/models.go index ca0222bf35f83..5a4b9708dd13d 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.26.0 package database diff --git a/coderd/database/querier.go b/coderd/database/querier.go index c999546d310bd..2e9e03fbad612 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -48,7 +48,7 @@ type sqlcQuerier interface { CleanTailnetCoordinators(ctx context.Context) error CleanTailnetLostPeers(ctx context.Context) error CleanTailnetTunnels(ctx context.Context) error - CustomRoles(ctx context.Context, lookupRoles []string) ([]CustomRole, error) + CustomRolesByName(ctx context.Context, lookupRoles []string) ([]CustomRole, error) DeleteAPIKeyByID(ctx context.Context, id string) error DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error DeleteAllTailnetClientSubscriptions(ctx context.Context, arg DeleteAllTailnetClientSubscriptionsParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b7ac15450bd60..ea544764c63d7 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.26.0 package database @@ -5519,7 +5519,7 @@ func (q *sqlQuerier) UpdateReplica(ctx context.Context, arg UpdateReplicaParams) return i, err } -const customRoles = `-- name: CustomRoles :many +const customRolesByName = `-- name: CustomRolesByName :many SELECT name, display_name, site_permissions, org_permissions, user_permissions, created_at, last_updated FROM @@ -5529,8 +5529,8 @@ WHERE name ILIKE ANY($1 :: text []) ` -func (q *sqlQuerier) CustomRoles(ctx context.Context, lookupRoles []string) ([]CustomRole, error) { - rows, err := q.db.QueryContext(ctx, customRoles, pq.Array(lookupRoles)) +func (q *sqlQuerier) CustomRolesByName(ctx context.Context, lookupRoles []string) ([]CustomRole, error) { + rows, err := q.db.QueryContext(ctx, customRolesByName, pq.Array(lookupRoles)) if err != nil { return nil, err } diff --git a/coderd/database/queries/roles.sql b/coderd/database/queries/roles.sql index 28aeb81d1964c..57cc051b4af2a 100644 --- a/coderd/database/queries/roles.sql +++ b/coderd/database/queries/roles.sql @@ -1,4 +1,4 @@ --- name: CustomRoles :many +-- name: CustomRolesByName :many SELECT * FROM @@ -21,7 +21,8 @@ INSERT INTO last_updted ) VALUES ( - @name, + -- Always force lowercase names + lower(@name), @display_name, @site_permissions, @org_permissions, diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 4148ac9448a80..75027df04bef9 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -412,15 +412,15 @@ type Role struct { // Valid will check all it's permissions and ensure they are all correct // according to the policy. -func (r Role) Valid() error { +func (role Role) Valid() error { var errs []error - for _, perm := range r.Site { + for _, perm := range role.Site { if err := perm.Valid(); err != nil { errs = append(errs, fmt.Errorf("site: %w", err)) } } - for orgID, permissions := range r.Org { + for orgID, permissions := range role.Org { for _, perm := range permissions { if err := perm.Valid(); err != nil { errs = append(errs, fmt.Errorf("org=%q: %w", orgID, err)) @@ -428,7 +428,7 @@ func (r Role) Valid() error { } } - for _, perm := range r.User { + for _, perm := range role.User { if err := perm.Valid(); err != nil { errs = append(errs, fmt.Errorf("user: %w", err)) } diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 44ef83b74cd20..56f428b061332 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -227,6 +227,15 @@ func TestRolePermissions(t *testing.T) { false: {otherOrgAdmin, otherOrgMember, memberMe, userAdmin}, }, }, + { + Name: "CreateCustomRole", + Actions: []policy.Action{policy.ActionCreate}, + Resource: rbac.ResourceAssignRole, + AuthorizeMap: map[bool][]authSubject{ + true: {owner}, + false: {userAdmin, orgAdmin, orgMemberMe, otherOrgAdmin, otherOrgMember, memberMe, templateAdmin}, + }, + }, { Name: "RoleAssignment", Actions: []policy.Action{policy.ActionAssign, policy.ActionDelete}, diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index 8751473bd6405..c5fcd0f9ef904 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -36,7 +36,7 @@ func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, // If some roles are missing from the database, they are omitted from // the expansion. These roles are no-ops. Should we raise some kind of // warning when this happens? - dbroles, err := db.CustomRoles(ctx, lookup) + dbroles, err := db.CustomRolesByName(ctx, lookup) if err != nil { return nil, xerrors.Errorf("fetch custom roles: %w", err) } diff --git a/coderd/roles.go b/coderd/roles.go index 5665e298f0e5d..d09d61a9bd54b 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -29,6 +29,8 @@ func (api *API) assignableSiteRoles(rw http.ResponseWriter, r *http.Request) { } roles := rbac.SiteRoles() + + httpapi.Write(ctx, rw, http.StatusOK, assignableRoles(actorRoles.Roles, roles)) } diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 9c7d9cc485128..42db5449c29f4 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -48,3 +48,33 @@ const ( ActionWorkspaceStart RBACAction = "start" ActionWorkspaceStop RBACAction = "stop" ) + +// RBACResourceActions is the mapping of resources to which actions are valid for +// said resource type. +var RBACResourceActions = map[RBACResource][]RBACAction{ + ResourceWildcard: []RBACAction{}, + ResourceApiKey: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceAssignOrgRole: []RBACAction{ActionAssign, ActionDelete, ActionRead}, + ResourceAssignRole: []RBACAction{ActionAssign, ActionCreate, ActionDelete, ActionRead}, + ResourceAuditLog: []RBACAction{ActionCreate, ActionRead}, + ResourceDebugInfo: []RBACAction{ActionRead}, + ResourceDeploymentConfig: []RBACAction{ActionRead, ActionUpdate}, + ResourceDeploymentStats: []RBACAction{ActionRead}, + ResourceFile: []RBACAction{ActionCreate, ActionRead}, + ResourceGroup: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceLicense: []RBACAction{ActionCreate, ActionDelete, ActionRead}, + ResourceOauth2App: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceOauth2AppCodeToken: []RBACAction{ActionCreate, ActionDelete, ActionRead}, + ResourceOauth2AppSecret: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceOrganization: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceOrganizationMember: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceProvisionerDaemon: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceReplicas: []RBACAction{ActionRead}, + ResourceSystem: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceTailnetCoordinator: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceTemplate: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate, ActionViewInsights}, + ResourceUser: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionReadPersonal, ActionUpdate, ActionUpdatePersonal}, + ResourceWorkspace: []RBACAction{ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, + ResourceWorkspaceDormant: []RBACAction{ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, + ResourceWorkspaceProxy: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, +} diff --git a/codersdk/roles.go b/codersdk/roles.go index a198515ab6667..a789c31ed79aa 100644 --- a/codersdk/roles.go +++ b/codersdk/roles.go @@ -37,6 +37,20 @@ type RolePermissions struct { UserPermissions []Permission `json:"user_permissions"` } +// UpsertCustomSiteRole will upsert a custom site wide role +func (c *Client) UpsertCustomSiteRole(ctx context.Context, req RolePermissions) (RolePermissions, error) { + res, err := c.Request(ctx, http.MethodPatch, "/api/v2/users/roles", req) + if err != nil { + return RolePermissions{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return RolePermissions{}, ReadBodyAsError(res) + } + var role RolePermissions + return role, json.NewDecoder(res.Body).Decode(&role) +} + // ListSiteRoles lists all assignable site wide roles. func (c *Client) ListSiteRoles(ctx context.Context) ([]AssignableRoles, error) { res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/roles", nil) diff --git a/enterprise/coderd/roles.go b/enterprise/coderd/roles.go index 1bf3ee29d88c3..555d58ac43023 100644 --- a/enterprise/coderd/roles.go +++ b/enterprise/coderd/roles.go @@ -1,11 +1,15 @@ package coderd import ( + "fmt" "net/http" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/rbac/rolestore" "github.com/coder/coder/v2/codersdk" ) @@ -21,6 +25,7 @@ import ( // @Router /users/roles/ [patch] func (api *API) patchRole(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + user := httpmw.UserAuthorization(r) var req codersdk.RolePermissions if !httpapi.Read(ctx, rw, r, &req) { return @@ -36,6 +41,7 @@ func (api *API) patchRole(rw http.ResponseWriter, r *http.Request) { return } + // Make sure all permissions inputted are valid according to our policy. rbacRole := db2sdk.RolePermissionsDB(req) err := rbacRole.Valid() if err != nil { @@ -46,6 +52,22 @@ func (api *API) patchRole(rw http.ResponseWriter, r *http.Request) { return } + // Before we continue, make sure the caller has a superset of permissions. + // If they do not, then creating this role is an escalation. + for _, sitePerm := range rbacRole.Site { + if !api.escalationCheck(r, rw, sitePerm, rbac.Object{Type: sitePerm.ResourceType}) { + return + } + } + + for _, sitePerm := range rbacRole.User { + // This feels a bit weak, since all users have all perms on their own resources. + // So this check is not very strong. + if !api.escalationCheck(r, rw, sitePerm, rbac.Object{Type: sitePerm.ResourceType, Owner: user.ID}) { + return + } + } + args, err := rolestore.ConvertRoleToDB(rbacRole) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -85,3 +107,34 @@ func (api *API) patchRole(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Role(convertedInsert)) } + +func (api *API) escalationCheck(r *http.Request, rw http.ResponseWriter, perm rbac.Permission, object rbac.Object) bool { + ctx := r.Context() + if perm.Negate { + // This is just an arbitrary choice to make things more simple for today. + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request, negative permissions are not allowed in custom roles", + Detail: fmt.Sprintf("permission action %q, permission type %q", perm.Action, perm.ResourceType), + }) + return false + } + + // It is possible to check for supersets with wildcards, but wildcards can also + // include resources and actions that do not exist. Custom roles should only be allowed + // to include permissions for existing resources. + if perm.Action == policy.WildcardSymbol || perm.ResourceType == policy.WildcardSymbol { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request, wildcard symbols are not allows in custom roles", + Detail: fmt.Sprintf("permission action %q, permission type %q", perm.Action, perm.ResourceType), + }) + return false + } + + // Site wide resources only need the type. + if !api.Authorize(r, perm.Action, object) { + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "Invalid request, caller permissions do not contain all request permissions", + Detail: fmt.Sprintf("not allowed to assign action %q on resource type %q", perm.Action, perm.ResourceType), + }) + } +} diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go new file mode 100644 index 0000000000000..c9336d39dad35 --- /dev/null +++ b/enterprise/coderd/roles_test.go @@ -0,0 +1,52 @@ +package coderd_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" +) + +func TestCustomRole(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + owner, first := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{}, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + }, + }, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + original, err := owner.ListSiteRoles(ctx) + require.NoError(t, err, "list available") + + role, err := owner.UpsertCustomSiteRole(ctx, codersdk.RolePermissions{ + Name: "test-role", + DisplayName: "Testing Purposes", + SitePermissions: []codersdk.Permission{ + // Let's make a template admin ourselves + { + Negate: false, + ResourceType: codersdk.ResourceTemplate, + Action: "", + }, + }, + OrganizationPermissions: nil, + UserPermissions: nil, + }) + require.NoError(t, err, "upsert role") + + coderdtest.CreateAnotherUser() + + }) +} diff --git a/scripts/rbacgen/codersdk.gotmpl b/scripts/rbacgen/codersdk.gotmpl index 1492eaf86c2bf..dff4e165b1df5 100644 --- a/scripts/rbacgen/codersdk.gotmpl +++ b/scripts/rbacgen/codersdk.gotmpl @@ -16,3 +16,15 @@ const ( {{ $element.Enum }} RBACAction = "{{ $element.Value }}" {{- end }} ) + +// RBACResourceActions is the mapping of resources to which actions are valid for +// said resource type. +var RBACResourceActions = map[RBACResource][]RBACAction{ + {{- range $element := . }} + Resource{{ pascalCaseName $element.FunctionName }}: []RBACAction{ + {{- range $actionValue, $_ := $element.Actions }} + {{- actionEnum $actionValue -}}, + {{- end -}} + }, + {{- end }} +} From e0b7070fa4e3b6c2f6d0ce896533fbab63c8fde5 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 19:19:29 -0500 Subject: [PATCH 06/26] add test for custom roles --- coderd/coderd.go | 2 + coderd/database/db2sdk/db2sdk.go | 12 ++- coderd/database/dbauthz/dbauthz.go | 143 +++++++++++++++++++++++++++-- coderd/httpmw/apikey.go | 3 +- coderd/members.go | 2 +- coderd/rbac/roles.go | 35 ++++--- coderd/rbac/rolestore/rolestore.go | 43 +++++++++ coderd/users.go | 23 +---- codersdk/deployment.go | 1 + codersdk/roles.go | 14 +++ enterprise/coderd/roles.go | 62 +------------ enterprise/coderd/roles_test.go | 32 ++++--- enterprise/coderd/userauth.go | 3 +- 13 files changed, 253 insertions(+), 122 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index c0631c0752c0c..f30d58f8cd726 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -61,6 +61,7 @@ import ( "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/rbac/rolestore" "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/telemetry" "github.com/coder/coder/v2/coderd/tracing" @@ -631,6 +632,7 @@ func New(options *Options) *API { httpmw.AttachRequestID, httpmw.ExtractRealIP(api.RealIPConfig), httpmw.Logger(api.Logger), + rolestore.CustomRoleMW, prometheusMW, // Build-Version is helpful for debugging. func(next http.Handler) http.Handler { diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index a9426a00655da..37e6d38acc453 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -171,8 +171,16 @@ func User(user database.User, organizationIDs []uuid.UUID) codersdk.User { } for _, roleName := range user.RBACRoles { - rbacRole, _ := rbac.RoleByName(roleName) - convertedUser.Roles = append(convertedUser.Roles, Role(rbacRole)) + rbacRole, err := rbac.RoleByName(roleName) + if err == nil { + convertedUser.Roles = append(convertedUser.Roles, Role(rbacRole)) + } else { + // TODO: Fix this for custom roles to display the actual display_name + // Requires plumbing either a cached role value, or the db. + convertedUser.Roles = append(convertedUser.Roles, codersdk.Role{ + Name: roleName, + }) + } } return convertedUser diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f9f230478ca3a..e0091ed41d845 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "sync/atomic" "time" @@ -17,6 +18,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/rbac/rolestore" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -580,6 +582,7 @@ func (q *querier) authorizeUpdateFileTemplate(ctx context.Context, file database } } +// canAssignRoles handles assigning built in and custom roles. func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, removed []string) error { actor, ok := ActorFromContext(ctx) if !ok { @@ -594,6 +597,7 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r } grantedRoles := append(added, removed...) + notBuiltInRoles := make([]string, 0) // Validate that the roles being assigned are valid. for _, r := range grantedRoles { _, isOrgRole := rbac.IsOrgRole(r) @@ -606,7 +610,35 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r // All roles should be valid roles if _, err := rbac.RoleByName(r); err != nil { - return xerrors.Errorf("%q is not a supported role", r) + notBuiltInRoles = append(notBuiltInRoles, r) + } + } + + notBuiltInRolesMap := make(map[string]struct{}, len(notBuiltInRoles)) + for _, r := range notBuiltInRoles { + notBuiltInRolesMap[r] = struct{}{} + } + + if len(notBuiltInRoles) > 0 { + // See if they are custom roles. + customRoles, err := q.db.CustomRolesByName(ctx, notBuiltInRoles) + if err != nil { + return xerrors.Errorf("fetching custom roles: %w", err) + } + + // If the lists are not identical, then have a problem, as some roles + // provided do no exist. + if len(customRoles) != len(notBuiltInRoles) { + for _, role := range notBuiltInRoles { + // Stop at the first one found. We could make a better error that + // returns them all, but then someone could pass in a large list to make us do + // a lot of loop iterations. + if !slices.ContainsFunc(customRoles, func(customRole database.CustomRole) bool { + return strings.EqualFold(customRole.Name, role) + }) { + return xerrors.Errorf("%q is not a supported role", role) + } + } } } @@ -623,6 +655,11 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r } for _, roleName := range grantedRoles { + if _, isCustom := notBuiltInRolesMap[roleName]; isCustom { + // For now, use a constant name so our static assign map still works. + roleName = rbac.CustomSiteRole() + } + if !rbac.CanAssignRole(actor.Roles, roleName) { return xerrors.Errorf("not authorized to assign role %q", roleName) } @@ -704,10 +741,6 @@ func (q *querier) authorizeTemplateInsights(ctx context.Context, templateIDs []u return nil } -func (q *querier) CustomRoles(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { - panic("not implemented") -} - func (q *querier) AcquireLock(ctx context.Context, id int64) error { return q.db.AcquireLock(ctx, id) } @@ -778,7 +811,10 @@ func (q *querier) CleanTailnetTunnels(ctx context.Context) error { } func (q *querier) CustomRolesByName(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { - panic("not implemented") + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAssignRole); err != nil { + return nil, err + } + return q.db.CustomRolesByName(ctx, lookupRoles) } func (q *querier) DeleteAPIKeyByID(ctx context.Context, id string) error { @@ -3299,8 +3335,101 @@ func (q *querier) UpsertApplicationName(ctx context.Context, value string) error return q.db.UpsertApplicationName(ctx, value) } +// UpsertCustomRole does a series of authz checks to protect custom routes. +// - Check custom roles are valid for their resource types + actions +// - Check the actor can create the custom role +// - Check the custom role does not grant perms the user does not have +// - Prevent negative perms +// - Prevent roles with site and org permissions. func (q *querier) UpsertCustomRole(ctx context.Context, arg database.UpsertCustomRoleParams) (database.CustomRole, error) { - panic("not implemented") + act, ok := ActorFromContext(ctx) + if !ok { + return database.CustomRole{}, NoActorError + } + + // TODO: If this is an org role, check the org assign role type. + if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAssignRole); err != nil { + return database.CustomRole{}, err + } + + // There is quite a bit of validation we should do here. First, let's make sure the json data is correct. + rbacRole, err := rolestore.ConvertDBRole(database.CustomRole{ + Name: arg.Name, + DisplayName: arg.DisplayName, + SitePermissions: arg.SitePermissions, + OrgPermissions: arg.OrgPermissions, + UserPermissions: arg.UserPermissions, + }) + if err != nil { + return database.CustomRole{}, xerrors.Errorf("invalid args: %w", err) + } + + err = rbacRole.Valid() + if err != nil { + return database.CustomRole{}, xerrors.Errorf("invalid role: %w", err) + } + + if len(rbacRole.Org) > 0 && len(rbacRole.Site) > 0 { + // This is a choice to keep roles simple. If we allow mixing site and org scoped perms, then knowing who can + // do what gets more complicated. + return database.CustomRole{}, xerrors.Errorf("invalid custom role, cannot assign both org and site permissions at the same time") + } + + if len(rbacRole.Org) > 1 { + // Again to avoid more complexity in our roles + return database.CustomRole{}, xerrors.Errorf("invalid custom role, cannot assign permisisons to more than 1 org at a time") + } + + // Prevent escalation + for _, sitePerm := range rbacRole.Site { + err := q.customRoleEscalationCheck(ctx, act, sitePerm, rbac.Object{Type: sitePerm.ResourceType}) + if err != nil { + return database.CustomRole{}, xerrors.Errorf("site permission: %w", err) + } + } + + for orgID, perms := range rbacRole.Org { + for _, orgPerm := range perms { + err := q.customRoleEscalationCheck(ctx, act, orgPerm, rbac.Object{OrgID: orgID, Type: orgPerm.ResourceType}) + if err != nil { + return database.CustomRole{}, xerrors.Errorf("org=%q: %w", orgID, err) + } + } + } + + for _, userPerm := range rbacRole.User { + err := q.customRoleEscalationCheck(ctx, act, userPerm, rbac.Object{Type: userPerm.ResourceType, Owner: act.ID}) + if err != nil { + return database.CustomRole{}, xerrors.Errorf("user permission: %w", err) + } + } + + return q.db.UpsertCustomRole(ctx, arg) +} + +// customRoleEscalationCheck checks to make sure the caller has every permission they are adding +// to a custom role. This prevents permission escalation. +func (q *querier) customRoleEscalationCheck(ctx context.Context, actor rbac.Subject, perm rbac.Permission, object rbac.Object) error { + if perm.Negate { + // Users do not need negative permissions. We can include it later if required. + return xerrors.Errorf("invalid permission for action=%q type=%q, no negative permissions", perm.Action, perm.ResourceType) + } + + if perm.Action == policy.WildcardSymbol || perm.ResourceType == policy.WildcardSymbol { + // It is possible to check for supersets with wildcards, but wildcards can also + // include resources and actions that do not exist. Custom roles should only be allowed + // to include permissions for existing resources. + return xerrors.Errorf("invalid permission for action=%q type=%q, no wildcard symbols", perm.Action, perm.ResourceType) + } + + object.Type = perm.ResourceType + if err := q.auth.Authorize(ctx, actor, perm.Action, object); err != nil { + // This is a forbidden error, but we can provide more context. Since the user can create a role, just not + // with this perm. + return xerrors.Errorf("invalid permission for action=%q type=%q, not allowed to grant this permission", perm.Action, perm.ResourceType) + } + + return nil } func (q *querier) UpsertDefaultProxy(ctx context.Context, arg database.UpsertDefaultProxyParams) error { diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 22613de005b91..5bb45424b57f9 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -438,7 +438,8 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon }) } - rbacRoles, err := rolestore.Expand(ctx, cfg.DB, roles.Roles) + //nolint:gocritic // Permission to lookup custom roles the user has assigned. + rbacRoles, err := rolestore.Expand(dbauthz.AsSystemRestricted(ctx), cfg.DB, roles.Roles) if err != nil { return write(http.StatusInternalServerError, codersdk.Response{ Message: "Failed to expand authenticated user roles", diff --git a/coderd/members.go b/coderd/members.go index 038851870cf8d..ded3fcd14a838 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -82,7 +82,7 @@ func (api *API) updateOrganizationMemberRoles(ctx context.Context, args database } if _, err := rbac.RoleByName(r); err != nil { - return database.OrganizationMember{}, xerrors.Errorf("%q is not a supported role", r) + return database.OrganizationMember{}, xerrors.Errorf("%q is not a supported organization role", r) } } diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 75027df04bef9..77c2391a6b5da 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -21,6 +21,10 @@ const ( templateAdmin string = "template-admin" userAdmin string = "user-admin" auditor string = "auditor" + // customSiteRole is a placeholder for all custom site roles. + // This is used for what roles can assign other roles. + // TODO: Make this more dynamic to allow other roles to grant. + customSiteRole string = "custom-site-role" orgAdmin string = "organization-admin" orgMember string = "organization-member" @@ -49,6 +53,7 @@ func (names RoleNames) Names() []string { // Once we have a database implementation, the "default" roles can be defined on the // site and orgs, and these functions can be removed. +func CustomSiteRole() string { return roleName(customSiteRole, "") } func RoleOwner() string { return roleName(owner, "") } @@ -321,22 +326,24 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // map[actor_role][assign_role] var assignRoles = map[string]map[string]bool{ "system": { - owner: true, - auditor: true, - member: true, - orgAdmin: true, - orgMember: true, - templateAdmin: true, - userAdmin: true, + owner: true, + auditor: true, + member: true, + orgAdmin: true, + orgMember: true, + templateAdmin: true, + userAdmin: true, + customSiteRole: true, }, owner: { - owner: true, - auditor: true, - member: true, - orgAdmin: true, - orgMember: true, - templateAdmin: true, - userAdmin: true, + owner: true, + auditor: true, + member: true, + orgAdmin: true, + orgMember: true, + templateAdmin: true, + userAdmin: true, + customSiteRole: true, }, userAdmin: { member: true, diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index c5fcd0f9ef904..4dff234c3c932 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -3,6 +3,7 @@ package rolestore import ( "context" "encoding/json" + "net/http" "golang.org/x/xerrors" @@ -10,6 +11,40 @@ import ( "github.com/coder/coder/v2/coderd/rbac" ) +type customRoleCtxKey struct{} +type customRoleCache map[string]rbac.Role + +func CustomRoleMW(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = r.WithContext(CustomRoleCacheContext(r.Context())) + next.ServeHTTP(w, r) + }) +} + +// CustomRoleCacheContext prevents needing to lookup custom roles within the +// same request lifecycle. Optimizing this to span requests should be done +// in the future. +func CustomRoleCacheContext(ctx context.Context) context.Context { + return context.WithValue(ctx, customRoleCtxKey{}, customRoleCache{}) +} + +func roleCache(ctx context.Context) customRoleCache { + c, ok := ctx.Value(customRoleCtxKey{}).(customRoleCache) + if !ok { + return customRoleCache{} + } + return c +} + +func store(ctx context.Context, name string, role rbac.Role) { + roleCache(ctx)[name] = role +} + +func load(ctx context.Context, name string) (rbac.Role, bool) { + r, ok := roleCache(ctx)[name] + return r, ok +} + // Expand will expand built in roles, and fetch custom roles from the database. func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, error) { if len(names) == 0 { @@ -28,6 +63,13 @@ func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, continue } + // Check custom role cache + customRole, ok := load(ctx, name) + if ok { + roles = append(roles, customRole) + continue + } + // Defer custom role lookup lookup = append(lookup, name) } @@ -48,6 +90,7 @@ func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, return nil, xerrors.Errorf("convert db role %q: %w", dbrole, err) } roles = append(roles, converted) + store(ctx, dbrole.Name, converted) } } diff --git a/coderd/users.go b/coderd/users.go index c8ca04e390c7f..8db74cadadc9b 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1095,7 +1095,7 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) { return } - updatedUser, err := UpdateSiteUserRoles(ctx, api.Database, database.UpdateUserRolesParams{ + updatedUser, err := api.Database.UpdateUserRoles(ctx, database.UpdateUserRolesParams{ GrantedRoles: params.Roles, ID: user.ID, }) @@ -1123,27 +1123,6 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(updatedUser, organizationIDs)) } -// UpdateSiteUserRoles will ensure only site wide roles are passed in as arguments. -// If an organization role is included, an error is returned. -func UpdateSiteUserRoles(ctx context.Context, db database.Store, args database.UpdateUserRolesParams) (database.User, error) { - // Enforce only site wide roles. - for _, r := range args.GrantedRoles { - if _, ok := rbac.IsOrgRole(r); ok { - return database.User{}, xerrors.Errorf("Must only update site wide roles") - } - - if _, err := rbac.RoleByName(r); err != nil { - return database.User{}, xerrors.Errorf("%q is not a supported role", r) - } - } - - updatedUser, err := db.UpdateUserRoles(ctx, args) - if err != nil { - return database.User{}, xerrors.Errorf("update site roles: %w", err) - } - return updatedUser, nil -} - // Returns organizations the parameterized user has access to. // // @Summary Get organizations by user diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 8fe7b6c4a991f..dd52cae77d1b4 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -100,6 +100,7 @@ func (n FeatureName) AlwaysEnable() bool { FeatureAppearance: true, FeatureWorkspaceBatchActions: true, FeatureHighAvailability: true, + FeatureCustomRoles: true, }[n] } diff --git a/codersdk/roles.go b/codersdk/roles.go index a789c31ed79aa..5c09663bb2b8c 100644 --- a/codersdk/roles.go +++ b/codersdk/roles.go @@ -78,3 +78,17 @@ func (c *Client) ListOrganizationRoles(ctx context.Context, org uuid.UUID) ([]As var roles []AssignableRoles return roles, json.NewDecoder(res.Body).Decode(&roles) } + +// CreatePermissions is a helper function to quickly build permissions. +func CreatePermissions(mapping map[RBACResource][]RBACAction) []Permission { + perms := make([]Permission, 0) + for t, actions := range mapping { + for _, action := range actions { + perms = append(perms, Permission{ + ResourceType: t, + Action: action, + }) + } + } + return perms +} diff --git a/enterprise/coderd/roles.go b/enterprise/coderd/roles.go index 555d58ac43023..0b6bf76d172c4 100644 --- a/enterprise/coderd/roles.go +++ b/enterprise/coderd/roles.go @@ -1,15 +1,11 @@ package coderd import ( - "fmt" "net/http" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/httpapi" - "github.com/coder/coder/v2/coderd/httpmw" - "github.com/coder/coder/v2/coderd/rbac" - "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/rbac/rolestore" "github.com/coder/coder/v2/codersdk" ) @@ -25,7 +21,7 @@ import ( // @Router /users/roles/ [patch] func (api *API) patchRole(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - user := httpmw.UserAuthorization(r) + var req codersdk.RolePermissions if !httpapi.Read(ctx, rw, r, &req) { return @@ -43,31 +39,6 @@ func (api *API) patchRole(rw http.ResponseWriter, r *http.Request) { // Make sure all permissions inputted are valid according to our policy. rbacRole := db2sdk.RolePermissionsDB(req) - err := rbacRole.Valid() - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid request, at least 1 permissions is invalid", - Detail: err.Error(), - }) - return - } - - // Before we continue, make sure the caller has a superset of permissions. - // If they do not, then creating this role is an escalation. - for _, sitePerm := range rbacRole.Site { - if !api.escalationCheck(r, rw, sitePerm, rbac.Object{Type: sitePerm.ResourceType}) { - return - } - } - - for _, sitePerm := range rbacRole.User { - // This feels a bit weak, since all users have all perms on their own resources. - // So this check is not very strong. - if !api.escalationCheck(r, rw, sitePerm, rbac.Object{Type: sitePerm.ResourceType, Owner: user.ID}) { - return - } - } - args, err := rolestore.ConvertRoleToDB(rbacRole) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -107,34 +78,3 @@ func (api *API) patchRole(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Role(convertedInsert)) } - -func (api *API) escalationCheck(r *http.Request, rw http.ResponseWriter, perm rbac.Permission, object rbac.Object) bool { - ctx := r.Context() - if perm.Negate { - // This is just an arbitrary choice to make things more simple for today. - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid request, negative permissions are not allowed in custom roles", - Detail: fmt.Sprintf("permission action %q, permission type %q", perm.Action, perm.ResourceType), - }) - return false - } - - // It is possible to check for supersets with wildcards, but wildcards can also - // include resources and actions that do not exist. Custom roles should only be allowed - // to include permissions for existing resources. - if perm.Action == policy.WildcardSymbol || perm.ResourceType == policy.WildcardSymbol { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid request, wildcard symbols are not allows in custom roles", - Detail: fmt.Sprintf("permission action %q, permission type %q", perm.Action, perm.ResourceType), - }) - return false - } - - // Site wide resources only need the type. - if !api.Authorize(r, perm.Action, object) { - httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ - Message: "Invalid request, caller permissions do not contain all request permissions", - Detail: fmt.Sprintf("not allowed to assign action %q on resource type %q", perm.Action, perm.ResourceType), - }) - } -} diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index c9336d39dad35..135079bf59e78 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/license" @@ -15,10 +16,15 @@ import ( func TestCustomRole(t *testing.T) { t.Parallel() + // Create, assign, and use a custom role t.Run("Success", func(t *testing.T) { t.Parallel() + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)} owner, first := coderdenttest.New(t, &coderdenttest.Options{ - Options: &coderdtest.Options{}, + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ codersdk.FeatureCustomRoles: 1, @@ -27,26 +33,28 @@ func TestCustomRole(t *testing.T) { }) ctx := testutil.Context(t, testutil.WaitMedium) - original, err := owner.ListSiteRoles(ctx) - require.NoError(t, err, "list available") role, err := owner.UpsertCustomSiteRole(ctx, codersdk.RolePermissions{ Name: "test-role", DisplayName: "Testing Purposes", - SitePermissions: []codersdk.Permission{ - // Let's make a template admin ourselves - { - Negate: false, - ResourceType: codersdk.ResourceTemplate, - Action: "", - }, - }, + // Basically creating a template admin manually + SitePermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead, codersdk.ActionUpdate, codersdk.ActionViewInsights}, + codersdk.ResourceFile: {codersdk.ActionCreate, codersdk.ActionRead}, + codersdk.ResourceWorkspace: {codersdk.ActionRead}, + }), OrganizationPermissions: nil, UserPermissions: nil, }) require.NoError(t, err, "upsert role") - coderdtest.CreateAnotherUser() + tmplAdmin, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.Name) + + // Assert the role exists + roleNamesF := func(role codersdk.Role) string { return role.Name } + require.Contains(t, db2sdk.List(user.Roles, roleNamesF), role.Name) + // Try to create a template version + coderdtest.CreateTemplateVersion(t, tmplAdmin, first.OrganizationID, nil) }) } diff --git a/enterprise/coderd/userauth.go b/enterprise/coderd/userauth.go index f35d38ca448d9..a2dcac6085c2a 100644 --- a/enterprise/coderd/userauth.go +++ b/enterprise/coderd/userauth.go @@ -7,7 +7,6 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/codersdk" @@ -96,7 +95,7 @@ func (api *API) setUserSiteRoles(ctx context.Context, logger slog.Logger, db dat // Should this be feature protected? return db.InTx(func(tx database.Store) error { - _, err := coderd.UpdateSiteUserRoles(ctx, db, database.UpdateUserRolesParams{ + _, err := db.UpdateUserRoles(ctx, database.UpdateUserRolesParams{ GrantedRoles: roles, ID: userID, }) From 5eaf6b0bca12d17ee6aab40cf2d2d95468037b12 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 19:19:45 -0500 Subject: [PATCH 07/26] add comment --- enterprise/coderd/roles_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index 135079bf59e78..22d2c01ccea6a 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -48,6 +48,7 @@ func TestCustomRole(t *testing.T) { }) require.NoError(t, err, "upsert role") + // Assign the custom template admin role tmplAdmin, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.Name) // Assert the role exists From 8fb8a1be06c9d167f05031510eb6fe5b4ed6fe8b Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 19:38:02 -0500 Subject: [PATCH 08/26] add dbauthz test --- coderd/database/dbauthz/dbauthz.go | 57 +++++++++++------------ coderd/database/dbauthz/dbauthz_test.go | 60 +++++++++++++++++++++++++ coderd/database/dbauthz/setup_test.go | 4 +- coderd/database/queries.sql.go | 3 +- 4 files changed, 94 insertions(+), 30 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index e0091ed41d845..621611fb71d07 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -620,8 +620,9 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r } if len(notBuiltInRoles) > 0 { - // See if they are custom roles. - customRoles, err := q.db.CustomRolesByName(ctx, notBuiltInRoles) + // See if they are custom roles. Use rolestore to leverage any cached + // role fetches. + customRoles, err := rolestore.Expand(ctx, q.db, notBuiltInRoles) if err != nil { return xerrors.Errorf("fetching custom roles: %w", err) } @@ -633,7 +634,7 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r // Stop at the first one found. We could make a better error that // returns them all, but then someone could pass in a large list to make us do // a lot of loop iterations. - if !slices.ContainsFunc(customRoles, func(customRole database.CustomRole) bool { + if !slices.ContainsFunc(customRoles, func(customRole rbac.Role) bool { return strings.EqualFold(customRole.Name, role) }) { return xerrors.Errorf("%q is not a supported role", role) @@ -741,6 +742,31 @@ func (q *querier) authorizeTemplateInsights(ctx context.Context, templateIDs []u return nil } +// customRoleEscalationCheck checks to make sure the caller has every permission they are adding +// to a custom role. This prevents permission escalation. +func (q *querier) customRoleEscalationCheck(ctx context.Context, actor rbac.Subject, perm rbac.Permission, object rbac.Object) error { + if perm.Negate { + // Users do not need negative permissions. We can include it later if required. + return xerrors.Errorf("invalid permission for action=%q type=%q, no negative permissions", perm.Action, perm.ResourceType) + } + + if perm.Action == policy.WildcardSymbol || perm.ResourceType == policy.WildcardSymbol { + // It is possible to check for supersets with wildcards, but wildcards can also + // include resources and actions that do not exist. Custom roles should only be allowed + // to include permissions for existing resources. + return xerrors.Errorf("invalid permission for action=%q type=%q, no wildcard symbols", perm.Action, perm.ResourceType) + } + + object.Type = perm.ResourceType + if err := q.auth.Authorize(ctx, actor, perm.Action, object); err != nil { + // This is a forbidden error, but we can provide more context. Since the user can create a role, just not + // with this perm. + return xerrors.Errorf("invalid permission for action=%q type=%q, not allowed to grant this permission", perm.Action, perm.ResourceType) + } + + return nil +} + func (q *querier) AcquireLock(ctx context.Context, id int64) error { return q.db.AcquireLock(ctx, id) } @@ -3407,31 +3433,6 @@ func (q *querier) UpsertCustomRole(ctx context.Context, arg database.UpsertCusto return q.db.UpsertCustomRole(ctx, arg) } -// customRoleEscalationCheck checks to make sure the caller has every permission they are adding -// to a custom role. This prevents permission escalation. -func (q *querier) customRoleEscalationCheck(ctx context.Context, actor rbac.Subject, perm rbac.Permission, object rbac.Object) error { - if perm.Negate { - // Users do not need negative permissions. We can include it later if required. - return xerrors.Errorf("invalid permission for action=%q type=%q, no negative permissions", perm.Action, perm.ResourceType) - } - - if perm.Action == policy.WildcardSymbol || perm.ResourceType == policy.WildcardSymbol { - // It is possible to check for supersets with wildcards, but wildcards can also - // include resources and actions that do not exist. Custom roles should only be allowed - // to include permissions for existing resources. - return xerrors.Errorf("invalid permission for action=%q type=%q, no wildcard symbols", perm.Action, perm.ResourceType) - } - - object.Type = perm.ResourceType - if err := q.auth.Authorize(ctx, actor, perm.Action, object); err != nil { - // This is a forbidden error, but we can provide more context. Since the user can create a role, just not - // with this perm. - return xerrors.Errorf("invalid permission for action=%q type=%q, not allowed to grant this permission", perm.Action, perm.ResourceType) - } - - return nil -} - func (q *querier) UpsertDefaultProxy(ctx context.Context, arg database.UpsertDefaultProxyParams) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index e8dcb2f8ee5bc..df0cb7af06801 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1167,6 +1167,66 @@ func (s *MethodTestSuite) TestUser() { b := dbgen.User(s.T(), db, database.User{}) check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(slice.New(a.ID, b.ID)) })) + s.Run("CustomRolesByName", s.Subtest(func(db database.Store, check *expects) { + check.Args([]string{}).Asserts(rbac.ResourceAssignRole, policy.ActionRead).Returns([]database.CustomRole{}) + })) + s.Run("Blank/UpsertCustomRole", s.Subtest(func(db database.Store, check *expects) { + // Blank is no perms in the role + check.Args(database.UpsertCustomRoleParams{ + Name: "test", + DisplayName: "Test Name", + SitePermissions: []byte(`[]`), + OrgPermissions: []byte(`{}`), + UserPermissions: []byte(`[]`), + }).Asserts(rbac.ResourceAssignRole, policy.ActionCreate) + })) + s.Run("SitePermissions/UpsertCustomRole", s.Subtest(func(db database.Store, check *expects) { + check.Args(database.UpsertCustomRoleParams{ + Name: "test", + DisplayName: "Test Name", + SitePermissions: must(json.Marshal(rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceTemplate.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete, policy.ActionViewInsights}, + }))), + OrgPermissions: []byte(`{}`), + UserPermissions: must(json.Marshal(rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }))), + }).Asserts( + // First check + rbac.ResourceAssignRole, policy.ActionCreate, + // Escalation checks + rbac.ResourceTemplate, policy.ActionCreate, + rbac.ResourceTemplate, policy.ActionRead, + rbac.ResourceTemplate, policy.ActionUpdate, + rbac.ResourceTemplate, policy.ActionDelete, + rbac.ResourceTemplate, policy.ActionViewInsights, + + rbac.ResourceWorkspace.WithOwner(testActorID.String()), policy.ActionRead, + ) + })) + s.Run("OrgPermissions/UpsertCustomRole", s.Subtest(func(db database.Store, check *expects) { + orgID := uuid.New() + check.Args(database.UpsertCustomRoleParams{ + Name: "test", + DisplayName: "Test Name", + SitePermissions: []byte(`[]`), + OrgPermissions: must(json.Marshal(map[string][]rbac.Permission{ + orgID.String(): rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceTemplate.Type: {policy.ActionCreate, policy.ActionRead}, + })})), + UserPermissions: must(json.Marshal(rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }))), + }).Asserts( + // First check + rbac.ResourceAssignRole, policy.ActionCreate, + // Escalation checks + rbac.ResourceTemplate.InOrg(orgID), policy.ActionCreate, + rbac.ResourceTemplate.InOrg(orgID), policy.ActionRead, + + rbac.ResourceWorkspace.WithOwner(testActorID.String()), policy.ActionRead, + ) + })) } func (s *MethodTestSuite) TestWorkspace() { diff --git a/coderd/database/dbauthz/setup_test.go b/coderd/database/dbauthz/setup_test.go index 16829cdef669e..3385ca3f3240c 100644 --- a/coderd/database/dbauthz/setup_test.go +++ b/coderd/database/dbauthz/setup_test.go @@ -99,6 +99,8 @@ func (s *MethodTestSuite) TearDownSuite() { }) } +var testActorID = uuid.New() + // Subtest is a helper function that returns a function that can be passed to // s.Run(). This function will run the test case for the method that is being // tested. The check parameter is used to assert the results of the method. @@ -120,7 +122,7 @@ func (s *MethodTestSuite) Subtest(testCaseF func(db database.Store, check *expec } az := dbauthz.New(db, rec, slog.Make(), coderdtest.AccessControlStorePointer()) actor := rbac.Subject{ - ID: uuid.NewString(), + ID: testActorID.String(), Roles: rbac.RoleNames{rbac.RoleOwner()}, Groups: []string{}, Scope: rbac.ScopeAll, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ea544764c63d7..82b428ff9af0d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5572,7 +5572,8 @@ INSERT INTO last_updted ) VALUES ( - $1, + -- Always force lowercase names + lower($1), $2, $3, $4, From 8107dc78f612ad2832558697c516909f0b493065 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 20:20:35 -0500 Subject: [PATCH 09/26] add custom role esclation tests --- coderd/database/dbauthz/customroles_test.go | 258 ++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 coderd/database/dbauthz/customroles_test.go diff --git a/coderd/database/dbauthz/customroles_test.go b/coderd/database/dbauthz/customroles_test.go new file mode 100644 index 0000000000000..d756b97a96fb1 --- /dev/null +++ b/coderd/database/dbauthz/customroles_test.go @@ -0,0 +1,258 @@ +package dbauthz_test + +import ( + "encoding/json" + "testing" + + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + + "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbmem" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/testutil" +) + +// TestUpsertCustomRoles verifies creating custom roles cannot escalate permissions. +func TestUpsertCustomRoles(t *testing.T) { + t.Parallel() + + userID := uuid.New() + subjectFromRoles := func(roles rbac.ExpandableRoles) rbac.Subject { + return rbac.Subject{ + FriendlyName: "Test user", + ID: userID.String(), + Roles: roles, + Groups: nil, + Scope: rbac.ScopeAll, + } + } + + canAssignRole := rbac.Role{ + Name: "can-assign", + DisplayName: "", + Site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceAssignRole.Type: {policy.ActionCreate}, + }), + } + + merge := func(u ...interface{}) rbac.Roles { + all := make([]rbac.Role, 0) + for _, v := range u { + v := v + switch t := v.(type) { + case rbac.Role: + all = append(all, t) + case rbac.ExpandableRoles: + all = append(all, must(t.Expand())...) + case string: + all = append(all, must(rbac.RoleByName(t))) + default: + panic("unknown type") + } + } + + return all + } + + orgID := uuid.New() + testCases := []struct { + name string + + subject rbac.ExpandableRoles + + // Perms to create on new custom role + site []rbac.Permission + org map[string][]rbac.Permission + user []rbac.Permission + errorContains string + }{ + { + // No roles, so no assign role + name: "no-roles", + subject: rbac.RoleNames([]string{}), + errorContains: "forbidden", + }, + { + // This works because the new role has 0 perms + name: "empty", + subject: merge(canAssignRole), + }, + { + name: "mixed-scopes", + subject: merge(canAssignRole, rbac.RoleOwner()), + site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + org: map[string][]rbac.Permission{ + uuid.New().String(): rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + }, + errorContains: "cannot assign both org and site permissions", + }, + { + name: "multiple-org", + subject: merge(canAssignRole, rbac.RoleOwner()), + org: map[string][]rbac.Permission{ + uuid.New().String(): rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + uuid.New().String(): rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + }, + errorContains: "cannot assign permisisons to more than 1", + }, + { + name: "invalid-action", + subject: merge(canAssignRole, rbac.RoleOwner()), + site: rbac.Permissions(map[string][]policy.Action{ + // Action does not go with resource + rbac.ResourceWorkspace.Type: {policy.ActionViewInsights}, + }), + errorContains: "invalid action", + }, + { + name: "invalid-resource", + subject: merge(canAssignRole, rbac.RoleOwner()), + site: rbac.Permissions(map[string][]policy.Action{ + "foobar": {policy.ActionViewInsights}, + }), + errorContains: "invalid resource", + }, + { + // Not allowing these at this time. + name: "negative-permission", + subject: merge(canAssignRole, rbac.RoleOwner()), + site: []rbac.Permission{ + { + Negate: true, + ResourceType: rbac.ResourceWorkspace.Type, + Action: policy.ActionRead, + }, + }, + errorContains: "no negative permissions", + }, + { + name: "wildcard", // not allowed + subject: merge(canAssignRole, rbac.RoleOwner()), + site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.WildcardSymbol}, + }), + errorContains: "no wildcard symbols", + }, + // escalation checks + { + name: "read-workspace-escalation", + subject: merge(canAssignRole), + site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + errorContains: "not allowed to grant this permission", + }, + { + name: "read-workspace-outside-org", + subject: merge(canAssignRole, rbac.RoleOrgAdmin(orgID)), + org: map[string][]rbac.Permission{ + // The org admin is for a different org + uuid.NewString(): rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + }, + errorContains: "not allowed to grant this permission", + }, + { + name: "user-escalation", + // These roles do not grant user perms + subject: merge(canAssignRole, rbac.RoleOrgAdmin(orgID)), + user: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + errorContains: "not allowed to grant this permission", + }, + { + name: "template-admin-escalation", + subject: merge(canAssignRole, rbac.RoleTemplateAdmin()), + site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, // ok! + rbac.ResourceDeploymentConfig.Type: {policy.ActionUpdate}, // not ok! + }), + user: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, // ok! + }), + errorContains: "deployment_config", + }, + // ok! + { + name: "read-workspace-template-admin", + subject: merge(canAssignRole, rbac.RoleTemplateAdmin()), + site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + }, + { + name: "read-workspace-in-org", + subject: merge(canAssignRole, rbac.RoleOrgAdmin(orgID)), + org: map[string][]rbac.Permission{ + // Org admin of this org, this is ok! + orgID.String(): rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + }, + }, + { + name: "user-perms", + // This is weird, but is ok + subject: merge(canAssignRole, rbac.RoleMember()), + user: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + }, + { + name: "site+user-perms", + subject: merge(canAssignRole, rbac.RoleMember(), rbac.RoleTemplateAdmin()), + site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + user: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + }, + } + + for _, tc := range testCases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + db := dbmem.New() + rec := &coderdtest.RecordingAuthorizer{ + Wrapped: rbac.NewAuthorizer(prometheus.NewRegistry()), + } + az := dbauthz.New(db, rec, slog.Make(), coderdtest.AccessControlStorePointer()) + + subject := subjectFromRoles(tc.subject) + ctx := testutil.Context(t, testutil.WaitMedium) + ctx = dbauthz.As(ctx, subject) + + _, err := az.UpsertCustomRole(ctx, database.UpsertCustomRoleParams{ + Name: "test-role", + DisplayName: "", + SitePermissions: must(json.Marshal(tc.site)), + OrgPermissions: must(json.Marshal(tc.org)), + UserPermissions: must(json.Marshal(tc.user)), + }) + if tc.errorContains != "" { + require.ErrorContains(t, err, tc.errorContains) + } else { + require.NoError(t, err) + } + }) + } +} From f5cd901807ac388f8d0a1721648bce5c840ffd66 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 20:23:02 -0500 Subject: [PATCH 10/26] add built in role validation tests --- coderd/database/queries.sql.go | 12 ++++++------ coderd/database/queries/roles.sql | 12 ++++++------ coderd/rbac/roles_test.go | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 82b428ff9af0d..7472867f189f5 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5563,17 +5563,17 @@ func (q *sqlQuerier) CustomRolesByName(ctx context.Context, lookupRoles []string const upsertCustomRole = `-- name: UpsertCustomRole :one INSERT INTO custom_roles ( - name, + name, display_name, - site_permissions, - org_permissions, - user_permissions, - created_at, + site_permissions, + org_permissions, + user_permissions, + created_at, last_updted ) VALUES ( -- Always force lowercase names - lower($1), + lower($1), $2, $3, $4, diff --git a/coderd/database/queries/roles.sql b/coderd/database/queries/roles.sql index 57cc051b4af2a..cc094cd41e8dc 100644 --- a/coderd/database/queries/roles.sql +++ b/coderd/database/queries/roles.sql @@ -12,17 +12,17 @@ WHERE -- name: UpsertCustomRole :one INSERT INTO custom_roles ( - name, + name, display_name, - site_permissions, - org_permissions, - user_permissions, - created_at, + site_permissions, + org_permissions, + user_permissions, + created_at, last_updted ) VALUES ( -- Always force lowercase names - lower(@name), + lower(@name), @display_name, @site_permissions, @org_permissions, diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 56f428b061332..7c72925e1fd31 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -20,6 +20,25 @@ type authSubject struct { Actor rbac.Subject } +func TestBuiltInRoles(t *testing.T) { + t.Parallel() + for _, r := range rbac.SiteRoles() { + r := r + t.Run(r.Name, func(t *testing.T) { + t.Parallel() + require.NoError(t, r.Valid(), "invalid role") + }) + } + + for _, r := range rbac.OrganizationRoles(uuid.New()) { + r := r + t.Run(r.Name, func(t *testing.T) { + t.Parallel() + require.NoError(t, r.Valid(), "invalid role") + }) + } +} + //nolint:tparallel,paralleltest func TestOwnerExec(t *testing.T) { owner := rbac.Subject{ From ce87f777d6072a9e55bed30bec762efcd19603a8 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 20:23:57 -0500 Subject: [PATCH 11/26] add test comment --- coderd/rbac/roles_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 7c72925e1fd31..07c8466065402 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -20,6 +20,8 @@ type authSubject struct { Actor rbac.Subject } +// TestBuiltInRoles makes sure our built-in roles are valid by our own policy +// rules. If this is incorrect, that is a mistake. func TestBuiltInRoles(t *testing.T) { t.Parallel() for _, r := range rbac.SiteRoles() { From d41e95a527ecdc2f1f6fccdab876acff89791092 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 20:28:46 -0500 Subject: [PATCH 12/26] update comment --- coderd/database/migrations/000209_custom_roles.up.sql | 2 +- coderd/database/models.go | 2 +- coderd/database/querier.go | 2 +- coderd/database/queries.sql.go | 6 +++--- coderd/rbac/roles.go | 3 ++- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/coderd/database/migrations/000209_custom_roles.up.sql b/coderd/database/migrations/000209_custom_roles.up.sql index e3fe8e509ddbf..e3b30fda9eeb5 100644 --- a/coderd/database/migrations/000209_custom_roles.up.sql +++ b/coderd/database/migrations/000209_custom_roles.up.sql @@ -12,7 +12,7 @@ CREATE TABLE custom_roles ( -- site_permissions is '[]Permission' site_permissions jsonb NOT NULL default '[]', -- org_permissions is 'map[][]Permission' - org_permissions jsonb NOT NULL default '{}', + org_permissions jsonb NOT NULL default '{}', -- user_permissions is '[]Permission' user_permissions jsonb NOT NULL default '[]', diff --git a/coderd/database/models.go b/coderd/database/models.go index 5a4b9708dd13d..ca0222bf35f83 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.25.0 package database diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 2e9e03fbad612..01615a58e06bd 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.25.0 package database diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 7472867f189f5..4782232b2eab5 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.25.0 package database @@ -2949,7 +2949,7 @@ func (q *sqlQuerier) GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, } const upsertJFrogXrayScanByWorkspaceAndAgentID = `-- name: UpsertJFrogXrayScanByWorkspaceAndAgentID :exec -INSERT INTO +INSERT INTO jfrog_xray_scans ( agent_id, workspace_id, @@ -2958,7 +2958,7 @@ INSERT INTO medium, results_url ) -VALUES +VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (agent_id, workspace_id) DO UPDATE SET critical = $3, high = $4, medium = $5, results_url = $6 diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 77c2391a6b5da..4799ead2699aa 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -418,7 +418,8 @@ type Role struct { } // Valid will check all it's permissions and ensure they are all correct -// according to the policy. +// according to the policy. This verifies every action specified make sense +// for the given resource. func (role Role) Valid() error { var errs []error for _, perm := range role.Site { From 171dbf06ee42c74f7001378f8ce37fa390e29c32 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 21:25:12 -0500 Subject: [PATCH 13/26] cleanup --- coderd/audit.go | 4 ++-- coderd/database/db2sdk/db2sdk.go | 24 +++++++++---------- coderd/database/dbauthz/dbauthz.go | 4 ++-- coderd/database/dbmem/dbmem.go | 4 ---- .../migrations/000209_custom_roles.down.sql | 2 ++ coderd/members.go | 4 ++-- coderd/roles.go | 4 +--- coderd/roles_test.go | 6 ++--- coderd/users_test.go | 2 +- codersdk/organizations.go | 10 ++++---- codersdk/roles.go | 16 ++++++------- codersdk/users.go | 2 +- enterprise/coderd/roles.go | 4 ++-- enterprise/coderd/roles_test.go | 4 ++-- scripts/rbacgen/codersdk.gotmpl | 12 ---------- 15 files changed, 43 insertions(+), 59 deletions(-) diff --git a/coderd/audit.go b/coderd/audit.go index 782c977afcf1c..315913dff49c2 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -196,12 +196,12 @@ func (api *API) convertAuditLog(ctx context.Context, dblog database.GetAuditLogs CreatedAt: dblog.UserCreatedAt.Time, Status: codersdk.UserStatus(dblog.UserStatus.UserStatus), }, - Roles: []codersdk.Role{}, + Roles: []codersdk.SlimRole{}, } for _, roleName := range dblog.UserRoles { rbacRole, _ := rbac.RoleByName(roleName) - user.Roles = append(user.Roles, db2sdk.Role(rbacRole)) + user.Roles = append(user.Roles, db2sdk.SlimRole(rbacRole)) } } diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 37e6d38acc453..163168fe3f46e 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -167,17 +167,17 @@ func User(user database.User, organizationIDs []uuid.UUID) codersdk.User { convertedUser := codersdk.User{ ReducedUser: ReducedUser(user), OrganizationIDs: organizationIDs, - Roles: make([]codersdk.Role, 0, len(user.RBACRoles)), + Roles: make([]codersdk.SlimRole, 0, len(user.RBACRoles)), } for _, roleName := range user.RBACRoles { rbacRole, err := rbac.RoleByName(roleName) if err == nil { - convertedUser.Roles = append(convertedUser.Roles, Role(rbacRole)) + convertedUser.Roles = append(convertedUser.Roles, SlimRole(rbacRole)) } else { // TODO: Fix this for custom roles to display the actual display_name // Requires plumbing either a cached role value, or the db. - convertedUser.Roles = append(convertedUser.Roles, codersdk.Role{ + convertedUser.Roles = append(convertedUser.Roles, codersdk.SlimRole{ Name: roleName, }) } @@ -205,8 +205,8 @@ func Group(group database.Group, members []database.User) codersdk.Group { } } -func Role(role rbac.Role) codersdk.Role { - return codersdk.Role{ +func SlimRole(role rbac.Role) codersdk.SlimRole { + return codersdk.SlimRole{ DisplayName: role.DisplayName, Name: role.Name, } @@ -526,8 +526,8 @@ func ProvisionerDaemon(dbDaemon database.ProvisionerDaemon) codersdk.Provisioner return result } -func RolePermissions(role rbac.Role) codersdk.RolePermissions { - return codersdk.RolePermissions{ +func Role(role rbac.Role) codersdk.Role { + return codersdk.Role{ Name: role.Name, DisplayName: role.DisplayName, SitePermissions: List(role.Site, Permission), @@ -544,16 +544,16 @@ func Permission(permission rbac.Permission) codersdk.Permission { } } -func RolePermissionsDB(role codersdk.RolePermissions) rbac.Role { +func RoleToRBAC(role codersdk.Role) rbac.Role { return rbac.Role{ Name: role.Name, DisplayName: role.DisplayName, - Site: List(role.SitePermissions, PermissionToDB), - Org: Map(role.OrganizationPermissions, ListLazy(PermissionToDB)), - User: List(role.UserPermissions, PermissionToDB), + Site: List(role.SitePermissions, PermissionToRBAC), + Org: Map(role.OrganizationPermissions, ListLazy(PermissionToRBAC)), + User: List(role.UserPermissions, PermissionToRBAC), } } -func PermissionToDB(permission codersdk.Permission) rbac.Permission { +func PermissionToRBAC(permission codersdk.Permission) rbac.Permission { return rbac.Permission{ Negate: permission.Negate, ResourceType: string(permission.ResourceType), diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 621611fb71d07..3121b27668a03 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3361,10 +3361,10 @@ func (q *querier) UpsertApplicationName(ctx context.Context, value string) error return q.db.UpsertApplicationName(ctx, value) } -// UpsertCustomRole does a series of authz checks to protect custom routes. +// UpsertCustomRole does a series of authz checks to protect custom roles. // - Check custom roles are valid for their resource types + actions // - Check the actor can create the custom role -// - Check the custom role does not grant perms the user does not have +// - Check the custom role does not grant perms the actor does not have // - Prevent negative perms // - Prevent roles with site and org permissions. func (q *querier) UpsertCustomRole(ctx context.Context, arg database.UpsertCustomRoleParams) (database.CustomRole, error) { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index ecfa58f79b4db..6b5fb3ff21676 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -896,10 +896,6 @@ func (q *FakeQuerier) getLatestWorkspaceAppByTemplateIDUserIDSlugNoLock(ctx cont return database.WorkspaceApp{}, sql.ErrNoRows } -func (q *FakeQuerier) CustomRoles(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { - panic("not implemented") -} - func (*FakeQuerier) AcquireLock(_ context.Context, _ int64) error { return xerrors.New("AcquireLock must only be called within a transaction") } diff --git a/coderd/database/migrations/000209_custom_roles.down.sql b/coderd/database/migrations/000209_custom_roles.down.sql index e69de29bb2d1d..b0f9b2a8cc76c 100644 --- a/coderd/database/migrations/000209_custom_roles.down.sql +++ b/coderd/database/migrations/000209_custom_roles.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_custom_roles_name_lower; +DROP TABLE IF EXISTS custom_roles; diff --git a/coderd/members.go b/coderd/members.go index ded3fcd14a838..6a3fe3b2bcb09 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -99,12 +99,12 @@ func convertOrganizationMember(mem database.OrganizationMember) codersdk.Organiz OrganizationID: mem.OrganizationID, CreatedAt: mem.CreatedAt, UpdatedAt: mem.UpdatedAt, - Roles: make([]codersdk.Role, 0, len(mem.Roles)), + Roles: make([]codersdk.SlimRole, 0, len(mem.Roles)), } for _, roleName := range mem.Roles { rbacRole, _ := rbac.RoleByName(roleName) - convertedMember.Roles = append(convertedMember.Roles, db2sdk.Role(rbacRole)) + convertedMember.Roles = append(convertedMember.Roles, db2sdk.SlimRole(rbacRole)) } return convertedMember } diff --git a/coderd/roles.go b/coderd/roles.go index d09d61a9bd54b..d18c8f0991611 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -29,8 +29,6 @@ func (api *API) assignableSiteRoles(rw http.ResponseWriter, r *http.Request) { } roles := rbac.SiteRoles() - - httpapi.Write(ctx, rw, http.StatusOK, assignableRoles(actorRoles.Roles, roles)) } @@ -68,7 +66,7 @@ func assignableRoles(actorRoles rbac.ExpandableRoles, roles []rbac.Role) []coder continue } assignable = append(assignable, codersdk.AssignableRoles{ - Role: codersdk.Role{ + SlimRole: codersdk.SlimRole{ Name: role.Name, DisplayName: role.DisplayName, }, diff --git a/coderd/roles_test.go b/coderd/roles_test.go index c50f24eb467a0..6754ddc17c9c2 100644 --- a/coderd/roles_test.go +++ b/coderd/roles_test.go @@ -143,9 +143,9 @@ func TestListRoles(t *testing.T) { } } -func convertRole(roleName string) codersdk.Role { +func convertRole(roleName string) codersdk.SlimRole { role, _ := rbac.RoleByName(roleName) - return codersdk.Role{ + return codersdk.SlimRole{ DisplayName: role.DisplayName, Name: role.Name, } @@ -156,7 +156,7 @@ func convertRoles(assignableRoles map[string]bool) []codersdk.AssignableRoles { for roleName, assignable := range assignableRoles { role := convertRole(roleName) converted = append(converted, codersdk.AssignableRoles{ - Role: role, + SlimRole: role, Assignable: assignable, }) } diff --git a/coderd/users_test.go b/coderd/users_test.go index 588a2e107566b..01cac4d1c8251 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -1049,7 +1049,7 @@ func TestGrantSiteRoles(t *testing.T) { c.AssignToUser = newUser.ID.String() } - var newRoles []codersdk.Role + var newRoles []codersdk.SlimRole if c.OrgID != uuid.Nil { // Org assign var mem codersdk.OrganizationMember diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 441f4774f2441..4c9cf81c497d3 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -48,11 +48,11 @@ type Organization struct { } type OrganizationMember struct { - UserID uuid.UUID `db:"user_id" json:"user_id" format:"uuid"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id" format:"uuid"` - CreatedAt time.Time `db:"created_at" json:"created_at" format:"date-time"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at" format:"date-time"` - Roles []Role `db:"roles" json:"roles"` + UserID uuid.UUID `db:"user_id" json:"user_id" format:"uuid"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id" format:"uuid"` + CreatedAt time.Time `db:"created_at" json:"created_at" format:"date-time"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at" format:"date-time"` + Roles []SlimRole `db:"roles" json:"roles"` } // CreateTemplateVersionRequest enables callers to create a new Template Version. diff --git a/codersdk/roles.go b/codersdk/roles.go index 5c09663bb2b8c..a16c7f587f0d1 100644 --- a/codersdk/roles.go +++ b/codersdk/roles.go @@ -9,13 +9,13 @@ import ( "github.com/google/uuid" ) -type Role struct { +type SlimRole struct { Name string `json:"name"` DisplayName string `json:"display_name"` } type AssignableRoles struct { - Role + SlimRole Assignable bool `json:"assignable"` } @@ -27,8 +27,8 @@ type Permission struct { Action RBACAction `json:"action"` } -// RolePermissions is a longer form of Role used to edit custom roles. -type RolePermissions struct { +// Role is a longer form of SlimRole used to edit custom roles. +type Role struct { Name string `json:"name"` DisplayName string `json:"display_name"` SitePermissions []Permission `json:"site_permissions"` @@ -38,16 +38,16 @@ type RolePermissions struct { } // UpsertCustomSiteRole will upsert a custom site wide role -func (c *Client) UpsertCustomSiteRole(ctx context.Context, req RolePermissions) (RolePermissions, error) { +func (c *Client) UpsertCustomSiteRole(ctx context.Context, req Role) (Role, error) { res, err := c.Request(ctx, http.MethodPatch, "/api/v2/users/roles", req) if err != nil { - return RolePermissions{}, err + return Role{}, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { - return RolePermissions{}, ReadBodyAsError(res) + return Role{}, ReadBodyAsError(res) } - var role RolePermissions + var role Role return role, json.NewDecoder(res.Body).Decode(&role) } diff --git a/codersdk/users.go b/codersdk/users.go index 7eb7604fc57b7..80ca583141c9b 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -63,7 +63,7 @@ type User struct { ReducedUser `table:"r,recursive_inline"` OrganizationIDs []uuid.UUID `json:"organization_ids" format:"uuid"` - Roles []Role `json:"roles"` + Roles []SlimRole `json:"roles"` } type GetUsersResponse struct { diff --git a/enterprise/coderd/roles.go b/enterprise/coderd/roles.go index 0b6bf76d172c4..c9716d32e82c9 100644 --- a/enterprise/coderd/roles.go +++ b/enterprise/coderd/roles.go @@ -22,7 +22,7 @@ import ( func (api *API) patchRole(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - var req codersdk.RolePermissions + var req codersdk.Role if !httpapi.Read(ctx, rw, r, &req) { return } @@ -38,7 +38,7 @@ func (api *API) patchRole(rw http.ResponseWriter, r *http.Request) { } // Make sure all permissions inputted are valid according to our policy. - rbacRole := db2sdk.RolePermissionsDB(req) + rbacRole := db2sdk.RoleToRBAC(req) args, err := rolestore.ConvertRoleToDB(rbacRole) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index 22d2c01ccea6a..bd94d2efb7afa 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -34,7 +34,7 @@ func TestCustomRole(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) - role, err := owner.UpsertCustomSiteRole(ctx, codersdk.RolePermissions{ + role, err := owner.UpsertCustomSiteRole(ctx, codersdk.Role{ Name: "test-role", DisplayName: "Testing Purposes", // Basically creating a template admin manually @@ -52,7 +52,7 @@ func TestCustomRole(t *testing.T) { tmplAdmin, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.Name) // Assert the role exists - roleNamesF := func(role codersdk.Role) string { return role.Name } + roleNamesF := func(role codersdk.SlimRole) string { return role.Name } require.Contains(t, db2sdk.List(user.Roles, roleNamesF), role.Name) // Try to create a template version diff --git a/scripts/rbacgen/codersdk.gotmpl b/scripts/rbacgen/codersdk.gotmpl index dff4e165b1df5..1492eaf86c2bf 100644 --- a/scripts/rbacgen/codersdk.gotmpl +++ b/scripts/rbacgen/codersdk.gotmpl @@ -16,15 +16,3 @@ const ( {{ $element.Enum }} RBACAction = "{{ $element.Value }}" {{- end }} ) - -// RBACResourceActions is the mapping of resources to which actions are valid for -// said resource type. -var RBACResourceActions = map[RBACResource][]RBACAction{ - {{- range $element := . }} - Resource{{ pascalCaseName $element.FunctionName }}: []RBACAction{ - {{- range $actionValue, $_ := $element.Actions }} - {{- actionEnum $actionValue -}}, - {{- end -}} - }, - {{- end }} -} From 52e097c5416ccd7dc3db375abe8806ea15ade9e9 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 21:28:43 -0500 Subject: [PATCH 14/26] fixup rolename expansion --- coderd/httpmw/authorize_test.go | 3 ++- coderd/rbac/roles.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/coderd/httpmw/authorize_test.go b/coderd/httpmw/authorize_test.go index c67be2ca2bdf7..6b513682cb68c 100644 --- a/coderd/httpmw/authorize_test.go +++ b/coderd/httpmw/authorize_test.go @@ -128,7 +128,8 @@ func TestExtractUserRoles(t *testing.T) { rtr.Get("/", func(_ http.ResponseWriter, r *http.Request) { roles := httpmw.UserAuthorization(r) require.Equal(t, user.ID.String(), roles.ID) - require.ElementsMatch(t, expRoles, roles.Roles.Names()) + foundRoles := roles.Roles.Names() + require.ElementsMatch(t, expRoles, foundRoles) }) req := httptest.NewRequest("GET", "/", nil) diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 4799ead2699aa..332526fbee027 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -454,7 +454,7 @@ func (roles Roles) Expand() ([]Role, error) { func (roles Roles) Names() []string { names := make([]string, 0, len(roles)) for _, r := range roles { - return append(names, r.Name) + names = append(names, r.Name) } return names } From 71db49d7b4172c997af7bc572af9d5415fdafca0 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 21:29:54 -0500 Subject: [PATCH 15/26] fmt --- coderd/database/db2sdk/db2sdk.go | 1 + coderd/database/dbauthz/dbauthz_test.go | 3 ++- coderd/rbac/roles.go | 1 + coderd/rbac/rolestore/rolestore.go | 6 ++++-- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 163168fe3f46e..ab6f3aa82b3f6 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -553,6 +553,7 @@ func RoleToRBAC(role codersdk.Role) rbac.Role { User: List(role.UserPermissions, PermissionToRBAC), } } + func PermissionToRBAC(permission codersdk.Permission) rbac.Permission { return rbac.Permission{ Negate: permission.Negate, diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index df0cb7af06801..7d04a0d20a52e 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1213,7 +1213,8 @@ func (s *MethodTestSuite) TestUser() { OrgPermissions: must(json.Marshal(map[string][]rbac.Permission{ orgID.String(): rbac.Permissions(map[string][]policy.Action{ rbac.ResourceTemplate.Type: {policy.ActionCreate, policy.ActionRead}, - })})), + }), + })), UserPermissions: must(json.Marshal(rbac.Permissions(map[string][]policy.Action{ rbac.ResourceWorkspace.Type: {policy.ActionRead}, }))), diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 332526fbee027..112414a0af9e9 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -54,6 +54,7 @@ func (names RoleNames) Names() []string { // site and orgs, and these functions can be removed. func CustomSiteRole() string { return roleName(customSiteRole, "") } + func RoleOwner() string { return roleName(owner, "") } diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index 4dff234c3c932..d919fe4c8fcae 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -11,8 +11,10 @@ import ( "github.com/coder/coder/v2/coderd/rbac" ) -type customRoleCtxKey struct{} -type customRoleCache map[string]rbac.Role +type ( + customRoleCtxKey struct{} + customRoleCache map[string]rbac.Role +) func CustomRoleMW(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 1c6939e72d73d79712955fba79dcb784ea5ea6ef Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 21:36:18 -0500 Subject: [PATCH 16/26] add db fixture --- .../fixtures/000209_custom_roles.up.sql | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 coderd/database/migrations/testdata/fixtures/000209_custom_roles.up.sql diff --git a/coderd/database/migrations/testdata/fixtures/000209_custom_roles.up.sql b/coderd/database/migrations/testdata/fixtures/000209_custom_roles.up.sql new file mode 100644 index 0000000000000..b085f460afdcc --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000209_custom_roles.up.sql @@ -0,0 +1,20 @@ +INSERT INTO + custom_roles ( + name, + display_name, + site_permissions, + org_permissions, + user_permissions, + created_at, + last_updated +) +VALUES + ( + 'custom-role', + 'Custom Role', + '[{"negate":false,"resource_type":"deployment_config","action":"update"},{"negate":false,"resource_type":"workspace","action":"read"}]', + '{}', + '[{"negate":false,"resource_type":"workspace","action":"read"}]', + date_trunc('hour', NOW()), + date_trunc('hour', NOW()) + '30 minute'::interval + ); From ea9b9bed124775ae139e8e504b7de2729d6eac65 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 21:37:14 -0500 Subject: [PATCH 17/26] typo --- coderd/database/dbauthz/customroles_test.go | 2 +- coderd/database/dbauthz/dbauthz.go | 2 +- coderd/database/queries/roles.sql | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/database/dbauthz/customroles_test.go b/coderd/database/dbauthz/customroles_test.go index d756b97a96fb1..aaa2c7a34bbf3 100644 --- a/coderd/database/dbauthz/customroles_test.go +++ b/coderd/database/dbauthz/customroles_test.go @@ -107,7 +107,7 @@ func TestUpsertCustomRoles(t *testing.T) { rbac.ResourceWorkspace.Type: {policy.ActionRead}, }), }, - errorContains: "cannot assign permisisons to more than 1", + errorContains: "cannot assign permissions to more than 1", }, { name: "invalid-action", diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 3121b27668a03..614bcfc3b63be 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3403,7 +3403,7 @@ func (q *querier) UpsertCustomRole(ctx context.Context, arg database.UpsertCusto if len(rbacRole.Org) > 1 { // Again to avoid more complexity in our roles - return database.CustomRole{}, xerrors.Errorf("invalid custom role, cannot assign permisisons to more than 1 org at a time") + return database.CustomRole{}, xerrors.Errorf("invalid custom role, cannot assign permissions to more than 1 org at a time") } // Prevent escalation diff --git a/coderd/database/queries/roles.sql b/coderd/database/queries/roles.sql index cc094cd41e8dc..e2077bce3d9d7 100644 --- a/coderd/database/queries/roles.sql +++ b/coderd/database/queries/roles.sql @@ -18,7 +18,7 @@ INSERT INTO org_permissions, user_permissions, created_at, - last_updted + last_updated ) VALUES ( -- Always force lowercase names From aa85df1d0b322d6852b6b4004c7ff06ab51c87f9 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 21:50:32 -0500 Subject: [PATCH 18/26] Make gen --- coderd/apidoc/docs.go | 58 ++++++- coderd/apidoc/swagger.json | 58 ++++++- coderd/database/queries.sql.go | 6 +- docs/api/members.md | 96 ++++++++++- docs/api/schemas.md | 150 +++++++++++++----- enterprise/coderd/roles.go | 2 +- site/src/api/api.ts | 2 +- site/src/api/typesGenerated.ts | 18 +-- site/src/pages/UsersPage/UsersPage.test.tsx | 4 +- site/src/pages/UsersPage/UsersPageView.tsx | 2 +- .../UsersPage/UsersTable/EditRolesButton.tsx | 6 +- .../UsersPage/UsersTable/UserRoleCell.tsx | 12 +- .../pages/UsersPage/UsersTable/UsersTable.tsx | 2 +- .../UsersPage/UsersTable/UsersTableBody.tsx | 2 +- site/src/testHelpers/entities.ts | 12 +- 15 files changed, 335 insertions(+), 95 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 0c6586a5997ba..b9dd40ae9239f 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4286,9 +4286,7 @@ const docTemplate = `{ } } } - } - }, - "/users/roles/": { + }, "patch": { "security": [ { @@ -10403,7 +10401,7 @@ const docTemplate = `{ "roles": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.Role" + "$ref": "#/definitions/codersdk.SlimRole" } }, "updated_at": { @@ -10483,6 +10481,21 @@ const docTemplate = `{ } } }, + "codersdk.Permission": { + "type": "object", + "properties": { + "action": { + "$ref": "#/definitions/codersdk.RBACAction" + }, + "negate": { + "description": "Negate makes this a negative permission", + "type": "boolean" + }, + "resource_type": { + "$ref": "#/definitions/codersdk.RBACResource" + } + } + }, "codersdk.PostOAuth2ProviderAppRequest": { "type": "object", "required": [ @@ -11125,6 +11138,28 @@ const docTemplate = `{ }, "name": { "type": "string" + }, + "organization_permissions": { + "description": "map[\u003corg_id\u003e] -\u003e Permissions", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } + } + }, + "site_permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } + }, + "user_permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } } } }, @@ -11191,6 +11226,17 @@ const docTemplate = `{ } } }, + "codersdk.SlimRole": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "codersdk.SupportConfig": { "type": "object", "properties": { @@ -11704,7 +11750,7 @@ const docTemplate = `{ "roles": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.Role" + "$ref": "#/definitions/codersdk.SlimRole" } }, "status": { @@ -12241,7 +12287,7 @@ const docTemplate = `{ "roles": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.Role" + "$ref": "#/definitions/codersdk.SlimRole" } }, "status": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 21bde99c0e57f..f315cfe20584f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3775,9 +3775,7 @@ } } } - } - }, - "/users/roles/": { + }, "patch": { "security": [ { @@ -9347,7 +9345,7 @@ "roles": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.Role" + "$ref": "#/definitions/codersdk.SlimRole" } }, "updated_at": { @@ -9422,6 +9420,21 @@ } } }, + "codersdk.Permission": { + "type": "object", + "properties": { + "action": { + "$ref": "#/definitions/codersdk.RBACAction" + }, + "negate": { + "description": "Negate makes this a negative permission", + "type": "boolean" + }, + "resource_type": { + "$ref": "#/definitions/codersdk.RBACResource" + } + } + }, "codersdk.PostOAuth2ProviderAppRequest": { "type": "object", "required": ["callback_url", "name"], @@ -10027,6 +10040,28 @@ }, "name": { "type": "string" + }, + "organization_permissions": { + "description": "map[\u003corg_id\u003e] -\u003e Permissions", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } + } + }, + "site_permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } + }, + "user_permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } } } }, @@ -10093,6 +10128,17 @@ } } }, + "codersdk.SlimRole": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "codersdk.SupportConfig": { "type": "object", "properties": { @@ -10586,7 +10632,7 @@ "roles": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.Role" + "$ref": "#/definitions/codersdk.SlimRole" } }, "status": { @@ -11080,7 +11126,7 @@ "roles": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.Role" + "$ref": "#/definitions/codersdk.SlimRole" } }, "status": { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 4782232b2eab5..2fcc0edff0d82 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2949,7 +2949,7 @@ func (q *sqlQuerier) GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, } const upsertJFrogXrayScanByWorkspaceAndAgentID = `-- name: UpsertJFrogXrayScanByWorkspaceAndAgentID :exec -INSERT INTO +INSERT INTO jfrog_xray_scans ( agent_id, workspace_id, @@ -2958,7 +2958,7 @@ INSERT INTO medium, results_url ) -VALUES +VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (agent_id, workspace_id) DO UPDATE SET critical = $3, high = $4, medium = $5, results_url = $6 @@ -5569,7 +5569,7 @@ INSERT INTO org_permissions, user_permissions, created_at, - last_updted + last_updated ) VALUES ( -- Always force lowercase names diff --git a/docs/api/members.md b/docs/api/members.md index c9cbbb9a69a3f..43ae4e8f23da1 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -161,12 +161,12 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X PATCH http://coder-server:8080/api/v2/users/roles/ \ +curl -X PATCH http://coder-server:8080/api/v2/users/roles \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`PATCH /users/roles/` +`PATCH /users/roles` ### Example responses @@ -176,7 +176,37 @@ curl -X PATCH http://coder-server:8080/api/v2/users/roles/ \ [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_permissions": { + "property1": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "property2": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] + }, + "site_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "user_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] } ] ``` @@ -191,10 +221,60 @@ curl -X PATCH http://coder-server:8080/api/v2/users/roles/ \ Status Code **200** -| Name | Type | Required | Restrictions | Description | -| ---------------- | ------ | -------- | ------------ | ----------- | -| `[array item]` | array | false | | | -| `» display_name` | string | false | | | -| `» name` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ---------------------------- | -------------------------------------------------------- | -------- | ------------ | --------------------------------------- | +| `[array item]` | array | false | | | +| `» display_name` | string | false | | | +| `» name` | string | false | | | +| `» organization_permissions` | object | false | | map[] -> Permissions | +| `»» [any property]` | array | false | | | +| `»»» action` | [codersdk.RBACAction](schemas.md#codersdkrbacaction) | false | | | +| `»»» negate` | boolean | false | | Negate makes this a negative permission | +| `»»» resource_type` | [codersdk.RBACResource](schemas.md#codersdkrbacresource) | false | | | +| `» site_permissions` | array | false | | | +| `» user_permissions` | array | false | | | + +#### Enumerated Values + +| Property | Value | +| --------------- | ----------------------- | +| `action` | `application_connect` | +| `action` | `assign` | +| `action` | `create` | +| `action` | `delete` | +| `action` | `read` | +| `action` | `read_personal` | +| `action` | `ssh` | +| `action` | `update` | +| `action` | `update_personal` | +| `action` | `use` | +| `action` | `view_insights` | +| `action` | `start` | +| `action` | `stop` | +| `resource_type` | `*` | +| `resource_type` | `api_key` | +| `resource_type` | `assign_org_role` | +| `resource_type` | `assign_role` | +| `resource_type` | `audit_log` | +| `resource_type` | `debug_info` | +| `resource_type` | `deployment_config` | +| `resource_type` | `deployment_stats` | +| `resource_type` | `file` | +| `resource_type` | `group` | +| `resource_type` | `license` | +| `resource_type` | `oauth2_app` | +| `resource_type` | `oauth2_app_code_token` | +| `resource_type` | `oauth2_app_secret` | +| `resource_type` | `organization` | +| `resource_type` | `organization_member` | +| `resource_type` | `provisioner_daemon` | +| `resource_type` | `replicas` | +| `resource_type` | `system` | +| `resource_type` | `tailnet_coordinator` | +| `resource_type` | `template` | +| `resource_type` | `user` | +| `resource_type` | `workspace` | +| `resource_type` | `workspace_dormant` | +| `resource_type` | `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 6c81e307e88fc..a50962cba09e7 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3580,13 +3580,13 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ### Properties -| Name | Type | Required | Restrictions | Description | -| ----------------- | --------------------------------------- | -------- | ------------ | ----------- | -| `created_at` | string | false | | | -| `organization_id` | string | false | | | -| `roles` | array of [codersdk.Role](#codersdkrole) | false | | | -| `updated_at` | string | false | | | -| `user_id` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ----------------- | ----------------------------------------------- | -------- | ------------ | ----------- | +| `created_at` | string | false | | | +| `organization_id` | string | false | | | +| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `updated_at` | string | false | | | +| `user_id` | string | false | | | ## codersdk.PatchGroupRequest @@ -3650,6 +3650,24 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `name` | string | true | | | | `regenerate_token` | boolean | false | | | +## codersdk.Permission + +```json +{ + "action": "application_connect", + "negate": true, + "resource_type": "*" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| --------------- | ---------------------------------------------- | -------- | ------------ | --------------------------------------- | +| `action` | [codersdk.RBACAction](#codersdkrbacaction) | false | | | +| `negate` | boolean | false | | Negate makes this a negative permission | +| `resource_type` | [codersdk.RBACResource](#codersdkrbacresource) | false | | | + ## codersdk.PostOAuth2ProviderAppRequest ```json @@ -4272,16 +4290,50 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ```json { "display_name": "string", - "name": "string" + "name": "string", + "organization_permissions": { + "property1": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "property2": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] + }, + "site_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "user_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| -------------- | ------ | -------- | ------------ | ----------- | -| `display_name` | string | false | | | -| `name` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| -------------------------- | --------------------------------------------------- | -------- | ------------ | ---------------------------- | +| `display_name` | string | false | | | +| `name` | string | false | | | +| `organization_permissions` | object | false | | map[] -> Permissions | +| » `[any property]` | array of [codersdk.Permission](#codersdkpermission) | false | | | +| `site_permissions` | array of [codersdk.Permission](#codersdkpermission) | false | | | +| `user_permissions` | array of [codersdk.Permission](#codersdkpermission) | false | | | ## codersdk.SSHConfig @@ -4357,6 +4409,22 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `disable_expiry_refresh` | boolean | false | | Disable expiry refresh will disable automatically refreshing api keys when they are used from the api. This means the api key lifetime at creation is the lifetime of the api key. | | `max_token_lifetime` | integer | false | | | +## codersdk.SlimRole + +```json +{ + "display_name": "string", + "name": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------ | -------- | ------------ | ----------- | +| `display_name` | string | false | | | +| `name` | string | false | | | + ## codersdk.SupportConfig ```json @@ -4903,21 +4971,21 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------ | ---------------------------------------------- | -------- | ------------ | ----------- | -| `avatar_url` | string | false | | | -| `created_at` | string | true | | | -| `email` | string | true | | | -| `id` | string | true | | | -| `last_seen_at` | string | false | | | -| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | -| `name` | string | false | | | -| `organization_ids` | array of string | false | | | -| `role` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | -| `roles` | array of [codersdk.Role](#codersdkrole) | false | | | -| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | -| `theme_preference` | string | false | | | -| `username` | string | true | | | +| Name | Type | Required | Restrictions | Description | +| ------------------ | ----------------------------------------------- | -------- | ------------ | ----------- | +| `avatar_url` | string | false | | | +| `created_at` | string | true | | | +| `email` | string | true | | | +| `id` | string | true | | | +| `last_seen_at` | string | false | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | +| `name` | string | false | | | +| `organization_ids` | array of string | false | | | +| `role` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | +| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | +| `theme_preference` | string | false | | | +| `username` | string | true | | | #### Enumerated Values @@ -5494,20 +5562,20 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------ | ------------------------------------------ | -------- | ------------ | ----------- | -| `avatar_url` | string | false | | | -| `created_at` | string | true | | | -| `email` | string | true | | | -| `id` | string | true | | | -| `last_seen_at` | string | false | | | -| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | -| `name` | string | false | | | -| `organization_ids` | array of string | false | | | -| `roles` | array of [codersdk.Role](#codersdkrole) | false | | | -| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | -| `theme_preference` | string | false | | | -| `username` | string | true | | | +| Name | Type | Required | Restrictions | Description | +| ------------------ | ----------------------------------------------- | -------- | ------------ | ----------- | +| `avatar_url` | string | false | | | +| `created_at` | string | true | | | +| `email` | string | true | | | +| `id` | string | true | | | +| `last_seen_at` | string | false | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | +| `name` | string | false | | | +| `organization_ids` | array of string | false | | | +| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | +| `theme_preference` | string | false | | | +| `username` | string | true | | | #### Enumerated Values diff --git a/enterprise/coderd/roles.go b/enterprise/coderd/roles.go index c9716d32e82c9..2224e7f25c0bf 100644 --- a/enterprise/coderd/roles.go +++ b/enterprise/coderd/roles.go @@ -18,7 +18,7 @@ import ( // @Produce json // @Tags Members // @Success 200 {array} codersdk.Role -// @Router /users/roles/ [patch] +// @Router /users/roles [patch] func (api *API) patchRole(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/site/src/api/api.ts b/site/src/api/api.ts index ed7f18ef1472c..8baa6a5edfc1c 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1102,7 +1102,7 @@ class ApiMethods { }; updateUserRoles = async ( - roles: TypesGen.Role["name"][], + roles: TypesGen.SlimRole["name"][], userId: TypesGen.User["id"], ): Promise => { const response = await this.axios.put( diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a977832f492f0..2bf254250b741 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -65,7 +65,7 @@ export interface ArchiveTemplateVersionsResponse { } // From codersdk/roles.go -export interface AssignableRoles extends Role { +export interface AssignableRoles extends SlimRole { readonly assignable: boolean; } @@ -786,7 +786,7 @@ export interface OrganizationMember { readonly organization_id: string; readonly created_at: string; readonly updated_at: string; - readonly roles: readonly Role[]; + readonly roles: readonly SlimRole[]; } // From codersdk/pagination.go @@ -977,12 +977,6 @@ export interface Response { export interface Role { readonly name: string; readonly display_name: string; -} - -// From codersdk/roles.go -export interface RolePermissions { - readonly name: string; - readonly display_name: string; readonly site_permissions: readonly Permission[]; readonly organization_permissions: Record; readonly user_permissions: readonly Permission[]; @@ -1030,6 +1024,12 @@ export interface SessionLifetime { readonly max_token_lifetime?: number; } +// From codersdk/roles.go +export interface SlimRole { + readonly name: string; + readonly display_name: string; +} + // From codersdk/deployment.go export interface SupportConfig { readonly links: readonly LinkConfig[]; @@ -1420,7 +1420,7 @@ export interface UpsertWorkspaceAgentPortShareRequest { // From codersdk/users.go export interface User extends ReducedUser { readonly organization_ids: readonly string[]; - readonly roles: readonly Role[]; + readonly roles: readonly SlimRole[]; } // From codersdk/insights.go diff --git a/site/src/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx index ebc5e24a5e6b6..edbc0118b09f2 100644 --- a/site/src/pages/UsersPage/UsersPage.test.tsx +++ b/site/src/pages/UsersPage/UsersPage.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { HttpResponse, http } from "msw"; import { API } from "api/api"; -import type { Role } from "api/typesGenerated"; +import type { SlimRole } from "api/typesGenerated"; import { MockUser, MockUser2, @@ -102,7 +102,7 @@ const resetUserPassword = async (setupActionSpies: () => void) => { fireEvent.click(confirmButton); }; -const updateUserRole = async (role: Role) => { +const updateUserRole = async (role: SlimRole) => { // Get the first user in the table const users = await screen.findAllByText(/.*@coder.com/); const userRow = users[0].closest("tr"); diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index 9349311392146..be5f50b6ff9b8 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -25,7 +25,7 @@ export interface UsersPageViewProps { onResetUserPassword: (user: TypesGen.User) => void; onUpdateUserRoles: ( user: TypesGen.User, - roles: TypesGen.Role["name"][], + roles: TypesGen.SlimRole["name"][], ) => void; filterProps: ComponentProps; isNonInitialPage: boolean; diff --git a/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx b/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx index 76099c9796205..b304bbed01f89 100644 --- a/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx +++ b/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx @@ -3,7 +3,7 @@ import UserIcon from "@mui/icons-material/PersonOutline"; import Checkbox from "@mui/material/Checkbox"; import IconButton from "@mui/material/IconButton"; import type { FC } from "react"; -import type { Role } from "api/typesGenerated"; +import type { SlimRole } from "api/typesGenerated"; import { HelpTooltip, HelpTooltipContent, @@ -69,9 +69,9 @@ const Option: FC = ({ export interface EditRolesButtonProps { isLoading: boolean; - roles: readonly Role[]; + roles: readonly SlimRole[]; selectedRoleNames: Set; - onChange: (roles: Role["name"][]) => void; + onChange: (roles: SlimRole["name"][]) => void; isDefaultOpen?: boolean; oidcRoleSync: boolean; userLoginType: string; diff --git a/site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx b/site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx index 36090dbfdeff8..d6bb8c926a739 100644 --- a/site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx +++ b/site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx @@ -17,7 +17,7 @@ import { useTheme } from "@emotion/react"; import Stack from "@mui/material/Stack"; import TableCell from "@mui/material/TableCell"; import type { FC } from "react"; -import type { Role, User } from "api/typesGenerated"; +import type { SlimRole, User } from "api/typesGenerated"; import { Pill } from "components/Pill/Pill"; import { Popover, @@ -28,7 +28,7 @@ import { EditRolesButton } from "./EditRolesButton"; type UserRoleCellProps = { canEditUsers: boolean; - allAvailableRoles: Role[] | undefined; + allAvailableRoles: SlimRole[] | undefined; user: User; isLoading: boolean; oidcRoleSyncEnabled: boolean; @@ -90,7 +90,7 @@ export const UserRoleCell: FC = ({ }; type OverflowRolePillProps = { - roles: readonly Role[]; + roles: readonly SlimRole[]; }; const OverflowRolePill: FC = ({ roles }) => { @@ -148,7 +148,7 @@ const OverflowRolePill: FC = ({ roles }) => { ); }; -const fallbackRole: Role = { +const fallbackRole: SlimRole = { name: "member", display_name: "Member", } as const; @@ -160,7 +160,7 @@ const roleNamesByAccessLevel: readonly string[] = [ "auditor", ]; -function sortRolesByAccessLevel(roles: readonly Role[]): readonly Role[] { +function sortRolesByAccessLevel(roles: readonly SlimRole[]): readonly SlimRole[] { if (roles.length === 0) { return roles; } @@ -172,7 +172,7 @@ function sortRolesByAccessLevel(roles: readonly Role[]): readonly Role[] { ); } -function getSelectedRoleNames(roles: readonly Role[]) { +function getSelectedRoleNames(roles: readonly SlimRole[]) { const roleNameSet = new Set(roles.map((role) => role.name)); if (roleNameSet.size === 0) { roleNameSet.add(fallbackRole.name); diff --git a/site/src/pages/UsersPage/UsersTable/UsersTable.tsx b/site/src/pages/UsersPage/UsersTable/UsersTable.tsx index 119b00a851146..d3748f2d8ea95 100644 --- a/site/src/pages/UsersPage/UsersTable/UsersTable.tsx +++ b/site/src/pages/UsersPage/UsersTable/UsersTable.tsx @@ -36,7 +36,7 @@ export interface UsersTableProps { onResetUserPassword: (user: TypesGen.User) => void; onUpdateUserRoles: ( user: TypesGen.User, - roles: TypesGen.Role["name"][], + roles: TypesGen.SlimRole["name"][], ) => void; isNonInitialPage: boolean; actorID: string; diff --git a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx index 03222edbedd6d..03a99bd423bf9 100644 --- a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx +++ b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx @@ -52,7 +52,7 @@ interface UsersTableBodyProps { onResetUserPassword: (user: TypesGen.User) => void; onUpdateUserRoles: ( user: TypesGen.User, - roles: TypesGen.Role["name"][], + roles: TypesGen.SlimRole["name"][], ) => void; isNonInitialPage: boolean; actorID: string; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 6cf97131aba67..5fe1e9cc7b0ff 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -229,27 +229,27 @@ export const MockUpdateCheck: TypesGen.UpdateCheckResponse = { version: "v99.999.9999+c9cdf14", }; -export const MockOwnerRole: TypesGen.Role = { +export const MockOwnerRole: TypesGen.SlimRole = { name: "owner", display_name: "Owner", }; -export const MockUserAdminRole: TypesGen.Role = { +export const MockUserAdminRole: TypesGen.SlimRole = { name: "user_admin", display_name: "User Admin", }; -export const MockTemplateAdminRole: TypesGen.Role = { +export const MockTemplateAdminRole: TypesGen.SlimRole = { name: "template_admin", display_name: "Template Admin", }; -export const MockMemberRole: TypesGen.Role = { +export const MockMemberRole: TypesGen.SlimRole = { name: "member", display_name: "Member", }; -export const MockAuditorRole: TypesGen.Role = { +export const MockAuditorRole: TypesGen.SlimRole = { name: "auditor", display_name: "Auditor", }; @@ -257,7 +257,7 @@ export const MockAuditorRole: TypesGen.Role = { // assignableRole takes a role and a boolean. The boolean implies if the // actor can assign (add/remove) the role from other users. export function assignableRole( - role: TypesGen.Role, + role: TypesGen.SlimRole, assignable: boolean, ): TypesGen.AssignableRoles { return { From 2b0cba6e2e25e1f47449cea76171add3d0d33ed6 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 22:01:49 -0500 Subject: [PATCH 19/26] thread safe role cache' --- .../migrations/000209_custom_roles.up.sql | 2 +- coderd/rbac/roles.go | 5 ++++ coderd/rbac/rolestore/rolestore.go | 30 +++++++------------ enterprise/coderd/roles_test.go | 1 + .../UsersPage/UsersTable/UserRoleCell.tsx | 4 ++- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/coderd/database/migrations/000209_custom_roles.up.sql b/coderd/database/migrations/000209_custom_roles.up.sql index e3b30fda9eeb5..402ab19635cfc 100644 --- a/coderd/database/migrations/000209_custom_roles.up.sql +++ b/coderd/database/migrations/000209_custom_roles.up.sql @@ -1,5 +1,5 @@ CREATE TABLE custom_roles ( - -- name is globally unique. Org scoped roles have their orgid prepended + -- name is globally unique. Org scoped roles have their orgid appended -- like: "name":"organization-admin:bbe8c156-c61e-4d36-b91e-697c6b1477e8" name text primary key, -- display_name is the actual name of the role displayed to the user. diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 112414a0af9e9..c3acfa858a568 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -380,6 +380,11 @@ type Permission struct { } func (perm Permission) Valid() error { + if perm.ResourceType == policy.WildcardSymbol { + // Wildcard is tricky to check. Just allow it. + return nil + } + resource, ok := policy.RBACPermissions[perm.ResourceType] if !ok { return fmt.Errorf("invalid resource type %q", perm.ResourceType) diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index d919fe4c8fcae..0ed8b2f12fcdb 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -9,13 +9,13 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/syncmap" ) -type ( - customRoleCtxKey struct{} - customRoleCache map[string]rbac.Role -) +type customRoleCtxKey struct{} +// CustomRoleMW adds a custom role cache on the ctx to prevent duplicate +// db fetches. func CustomRoleMW(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r = r.WithContext(CustomRoleCacheContext(r.Context())) @@ -27,26 +27,17 @@ func CustomRoleMW(next http.Handler) http.Handler { // same request lifecycle. Optimizing this to span requests should be done // in the future. func CustomRoleCacheContext(ctx context.Context) context.Context { - return context.WithValue(ctx, customRoleCtxKey{}, customRoleCache{}) + return context.WithValue(ctx, customRoleCtxKey{}, syncmap.New[string, rbac.Role]()) } -func roleCache(ctx context.Context) customRoleCache { - c, ok := ctx.Value(customRoleCtxKey{}).(customRoleCache) +func roleCache(ctx context.Context) *syncmap.Map[string, rbac.Role] { + c, ok := ctx.Value(customRoleCtxKey{}).(*syncmap.Map[string, rbac.Role]) if !ok { - return customRoleCache{} + return syncmap.New[string, rbac.Role]() } return c } -func store(ctx context.Context, name string, role rbac.Role) { - roleCache(ctx)[name] = role -} - -func load(ctx context.Context, name string) (rbac.Role, bool) { - r, ok := roleCache(ctx)[name] - return r, ok -} - // Expand will expand built in roles, and fetch custom roles from the database. func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, error) { if len(names) == 0 { @@ -54,6 +45,7 @@ func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, return []rbac.Role{}, nil } + cache := roleCache(ctx) lookup := make([]string, 0) roles := make([]rbac.Role, 0, len(names)) @@ -66,7 +58,7 @@ func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, } // Check custom role cache - customRole, ok := load(ctx, name) + customRole, ok := cache.Load(name) if ok { roles = append(roles, customRole) continue @@ -92,7 +84,7 @@ func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, return nil, xerrors.Errorf("convert db role %q: %w", dbrole, err) } roles = append(roles, converted) - store(ctx, dbrole.Name, converted) + cache.Store(dbrole.Name, converted) } } diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index bd94d2efb7afa..7894ee766bfec 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -34,6 +34,7 @@ func TestCustomRole(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) + //nolint:gocritic -- owner is required for this role, err := owner.UpsertCustomSiteRole(ctx, codersdk.Role{ Name: "test-role", DisplayName: "Testing Purposes", diff --git a/site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx b/site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx index d6bb8c926a739..398354f94ee69 100644 --- a/site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx +++ b/site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx @@ -160,7 +160,9 @@ const roleNamesByAccessLevel: readonly string[] = [ "auditor", ]; -function sortRolesByAccessLevel(roles: readonly SlimRole[]): readonly SlimRole[] { +function sortRolesByAccessLevel( + roles: readonly SlimRole[], +): readonly SlimRole[] { if (roles.length === 0) { return roles; } From 0d91366bb5a4a10ca3a5b9ff7067de6ba2c44d48 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 22:07:03 -0500 Subject: [PATCH 20/26] More lint --- coderd/database/dbmem/dbmem.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 6b5fb3ff21676..01cea6709814b 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -1174,7 +1174,7 @@ func (*FakeQuerier) CleanTailnetTunnels(context.Context) error { return ErrUnimplemented } -func (q *FakeQuerier) CustomRolesByName(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { +func (q *FakeQuerier) CustomRolesByName(_ context.Context, lookupRoles []string) ([]database.CustomRole, error) { q.mutex.Lock() defer q.mutex.Unlock() From 06a17c16957ab994125667881115be9b24fc778d Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 22:12:34 -0500 Subject: [PATCH 21/26] graphite pulled code from another branch?? --- codersdk/rbacresources_gen.go | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 42db5449c29f4..9c7d9cc485128 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -48,33 +48,3 @@ const ( ActionWorkspaceStart RBACAction = "start" ActionWorkspaceStop RBACAction = "stop" ) - -// RBACResourceActions is the mapping of resources to which actions are valid for -// said resource type. -var RBACResourceActions = map[RBACResource][]RBACAction{ - ResourceWildcard: []RBACAction{}, - ResourceApiKey: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceAssignOrgRole: []RBACAction{ActionAssign, ActionDelete, ActionRead}, - ResourceAssignRole: []RBACAction{ActionAssign, ActionCreate, ActionDelete, ActionRead}, - ResourceAuditLog: []RBACAction{ActionCreate, ActionRead}, - ResourceDebugInfo: []RBACAction{ActionRead}, - ResourceDeploymentConfig: []RBACAction{ActionRead, ActionUpdate}, - ResourceDeploymentStats: []RBACAction{ActionRead}, - ResourceFile: []RBACAction{ActionCreate, ActionRead}, - ResourceGroup: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceLicense: []RBACAction{ActionCreate, ActionDelete, ActionRead}, - ResourceOauth2App: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceOauth2AppCodeToken: []RBACAction{ActionCreate, ActionDelete, ActionRead}, - ResourceOauth2AppSecret: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceOrganization: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceOrganizationMember: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceProvisionerDaemon: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceReplicas: []RBACAction{ActionRead}, - ResourceSystem: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceTailnetCoordinator: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceTemplate: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate, ActionViewInsights}, - ResourceUser: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionReadPersonal, ActionUpdate, ActionUpdatePersonal}, - ResourceWorkspace: []RBACAction{ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, - ResourceWorkspaceDormant: []RBACAction{ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, - ResourceWorkspaceProxy: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, -} From 8ccf30c4313c63020773584aa7ff8dfe5aa1fb52 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 22:22:18 -0500 Subject: [PATCH 22/26] unsure how to nolint this use of the owner --- enterprise/coderd/roles_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index 7894ee766bfec..5bba566a1a001 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -17,6 +17,7 @@ func TestCustomRole(t *testing.T) { t.Parallel() // Create, assign, and use a custom role + //nolint:gocritic t.Run("Success", func(t *testing.T) { t.Parallel() dv := coderdtest.DeploymentValues(t) From 490e9d99bfd11b0afe9eb4738fd760505ccd1561 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 22:24:00 -0500 Subject: [PATCH 23/26] fix gen --- coderd/rbac/object_gen.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 57ec0982a15ae..9ab848d795b1c 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -37,7 +37,8 @@ var ( // ResourceAssignRole // Valid Actions // - "ActionAssign" :: ability to assign roles - // - "ActionDelete" :: ability to delete roles + // - "ActionCreate" :: ability to create/delete/edit custom roles + // - "ActionDelete" :: ability to unassign roles // - "ActionRead" :: view what roles are assignable ResourceAssignRole = Object{ Type: "assign_role", From 1eec6e2cb3c21c404283f63a41ee5e6e3a2a66d6 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 22:27:23 -0500 Subject: [PATCH 24/26] fmt.Errorf to xerrors --- coderd/rbac/object.go | 4 ++-- coderd/rbac/roles.go | 10 +++++----- coderd/rbac/roles_test.go | 2 +- enterprise/coderd/roles_test.go | 2 +- scripts/rbacgen/main.go | 8 ++++---- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 30a74e4f825dd..91df14e0ee481 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -36,10 +36,10 @@ type Object struct { func (z Object) ValidAction(action policy.Action) error { perms, ok := policy.RBACPermissions[z.Type] if !ok { - return fmt.Errorf("invalid type %q", z.Type) + return xerrors.Errorf("invalid type %q", z.Type) } if _, ok := perms.Actions[action]; !ok { - return fmt.Errorf("invalid action %q for type %q", action, z.Type) + return xerrors.Errorf("invalid action %q for type %q", action, z.Type) } return nil diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index c3acfa858a568..8461d5a66788a 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -387,13 +387,13 @@ func (perm Permission) Valid() error { resource, ok := policy.RBACPermissions[perm.ResourceType] if !ok { - return fmt.Errorf("invalid resource type %q", perm.ResourceType) + return xerrors.Errorf("invalid resource type %q", perm.ResourceType) } if perm.Action != policy.WildcardSymbol { _, ok := resource.Actions[perm.Action] if !ok { - return fmt.Errorf("invalid action %q for resource %q", perm.Action, perm.ResourceType) + return xerrors.Errorf("invalid action %q for resource %q", perm.Action, perm.ResourceType) } } return nil @@ -430,21 +430,21 @@ func (role Role) Valid() error { var errs []error for _, perm := range role.Site { if err := perm.Valid(); err != nil { - errs = append(errs, fmt.Errorf("site: %w", err)) + errs = append(errs, xerrors.Errorf("site: %w", err)) } } for orgID, permissions := range role.Org { for _, perm := range permissions { if err := perm.Valid(); err != nil { - errs = append(errs, fmt.Errorf("org=%q: %w", orgID, err)) + errs = append(errs, xerrors.Errorf("org=%q: %w", orgID, err)) } } } for _, perm := range role.User { if err := perm.Valid(); err != nil { - errs = append(errs, fmt.Errorf("user: %w", err)) + errs = append(errs, xerrors.Errorf("user: %w", err)) } } diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 07c8466065402..d90f045284c5b 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -389,7 +389,7 @@ func TestRolePermissions(t *testing.T) { }, // Some admin style resources { - Name: "Licences", + Name: "Licenses", Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionDelete}, Resource: rbac.ResourceLicense, AuthorizeMap: map[bool][]authSubject{ diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index 5bba566a1a001..57bac91260012 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -35,7 +35,7 @@ func TestCustomRole(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) - //nolint:gocritic -- owner is required for this + //nolint:gocritic // owner is required for this role, err := owner.UpsertCustomSiteRole(ctx, codersdk.Role{ Name: "test-role", DisplayName: "Testing Purposes", diff --git a/scripts/rbacgen/main.go b/scripts/rbacgen/main.go index 38f13434c77e4..67b35ae57c037 100644 --- a/scripts/rbacgen/main.go +++ b/scripts/rbacgen/main.go @@ -148,7 +148,7 @@ func generateRbacObjects(templateSource string) ([]byte, error) { // Parse the policy.go file for the action enums f, err := parser.ParseFile(token.NewFileSet(), "./coderd/rbac/policy/policy.go", nil, parser.ParseComments) if err != nil { - return nil, fmt.Errorf("parsing policy.go: %w", err) + return nil, xerrors.Errorf("parsing policy.go: %w", err) } actionMap := fileActions(f) actionList := make([]ActionDetails, 0) @@ -176,14 +176,14 @@ func generateRbacObjects(templateSource string) ([]byte, error) { x++ v, ok := actionMap[string(action)] if !ok { - errorList = append(errorList, fmt.Errorf("action value %q does not have a constant a matching enum constant", action)) + errorList = append(errorList, xerrors.Errorf("action value %q does not have a constant a matching enum constant", action)) } return v }, "concat": func(strs ...string) string { return strings.Join(strs, "") }, }).Parse(templateSource) if err != nil { - return nil, fmt.Errorf("parse template: %w", err) + return nil, xerrors.Errorf("parse template: %w", err) } // Convert to sorted list for autogen consistency. @@ -203,7 +203,7 @@ func generateRbacObjects(templateSource string) ([]byte, error) { err = tpl.Execute(&out, list) if err != nil { - return nil, fmt.Errorf("execute template: %w", err) + return nil, xerrors.Errorf("execute template: %w", err) } if len(errorList) > 0 { From 5803346c129c5f39fef955bf1c0102a52ef040f3 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 22:43:52 -0500 Subject: [PATCH 25/26] fix imports --- coderd/rbac/object.go | 3 +-- coderd/rbac/roles.go | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 91df14e0ee481..dfd8ab6b55b23 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -1,9 +1,8 @@ package rbac import ( - "fmt" - "github.com/google/uuid" + "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/rbac/policy" ) diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 8461d5a66788a..33a3d24610a6b 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -2,7 +2,6 @@ package rbac import ( "errors" - "fmt" "sort" "strings" From 3c8f79f1b7da5b7bab32bc7e104b86ba6503ac72 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 15 May 2024 22:52:53 -0500 Subject: [PATCH 26/26] add import --- scripts/rbacgen/main.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/rbacgen/main.go b/scripts/rbacgen/main.go index 67b35ae57c037..1eb186c1b5ce4 100644 --- a/scripts/rbacgen/main.go +++ b/scripts/rbacgen/main.go @@ -16,6 +16,8 @@ import ( "slices" "strings" + "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/rbac/policy" )