diff --git a/coderd/coderd.go b/coderd/coderd.go index 9437a9219b045..9c5633cb03033 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -340,7 +340,7 @@ func New(options *Options) *API { r.Get("/", api.workspaceAgent) r.Post("/peer", api.postWorkspaceAgentWireguardPeer) r.Get("/dial", api.workspaceAgentDial) - r.Get("/turn", api.workspaceAgentTurn) + r.Get("/turn", api.userWorkspaceAgentTurn) r.Get("/pty", api.workspaceAgentPTY) r.Get("/iceservers", api.workspaceAgentICEServers) r.Get("/derp", api.derpMap) diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index f3c4baaa227ab..89fa81b275db7 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -220,6 +220,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { // Some quick reused objects workspaceRBACObj := rbac.ResourceWorkspace.InOrg(organization.ID).WithOwner(workspace.OwnerID.String()) + workspaceExecObj := rbac.ResourceWorkspaceExecution.InOrg(organization.ID).WithOwner(workspace.OwnerID.String()) // skipRoutes allows skipping routes from being checked. skipRoutes := map[string]string{ @@ -268,7 +269,6 @@ func TestAuthorizeAllEndpoints(t *testing.T) { "GET:/api/v2/workspaceagents/me/wireguardlisten": {NoAuthorize: true}, "POST:/api/v2/workspaceagents/me/keys": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/{workspaceagent}/iceservers": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/{workspaceagent}/turn": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/{workspaceagent}/derp": {NoAuthorize: true}, // These endpoints have more assertions. This is good, add more endpoints to assert if you can! @@ -331,12 +331,16 @@ func TestAuthorizeAllEndpoints(t *testing.T) { AssertObject: workspaceRBACObj, }, "GET:/api/v2/workspaceagents/{workspaceagent}/dial": { - AssertAction: rbac.ActionUpdate, - AssertObject: workspaceRBACObj, + AssertAction: rbac.ActionCreate, + AssertObject: workspaceExecObj, + }, + "GET:/api/v2/workspaceagents/{workspaceagent}/turn": { + AssertAction: rbac.ActionCreate, + AssertObject: workspaceExecObj, }, "GET:/api/v2/workspaceagents/{workspaceagent}/pty": { - AssertAction: rbac.ActionUpdate, - AssertObject: workspaceRBACObj, + AssertAction: rbac.ActionCreate, + AssertObject: workspaceExecObj, }, "GET:/api/v2/workspaces/": { StatusCode: http.StatusOK, diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index d83180247ac83..1002ef4b4b523 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -17,6 +17,10 @@ func (w Workspace) RBACObject() rbac.Object { return rbac.ResourceWorkspace.InOrg(w.OrganizationID).WithOwner(w.OwnerID.String()) } +func (w Workspace) ExecutionRBAC() rbac.Object { + return rbac.ResourceWorkspaceExecution.InOrg(w.OrganizationID).WithOwner(w.OwnerID.String()) +} + func (m OrganizationMember) RBACObject() rbac.Object { return rbac.ResourceOrganizationMember.InOrg(m.OrganizationID) } diff --git a/coderd/rbac/builtin.go b/coderd/rbac/builtin.go index 8fd60fc6c2c93..1f3f34fd9ffb7 100644 --- a/coderd/rbac/builtin.go +++ b/coderd/rbac/builtin.go @@ -9,9 +9,11 @@ import ( ) const ( - admin string = "admin" - member string = "member" - auditor string = "auditor" + admin string = "admin" + member string = "member" + templateAdmin string = "template-admin" + userAdmin string = "user-admin" + auditor string = "auditor" orgAdmin string = "organization-admin" orgMember string = "organization-member" @@ -26,6 +28,14 @@ func RoleAdmin() string { return roleName(admin, "") } +func RoleTemplateAdmin() string { + return roleName(templateAdmin, "") +} + +func RoleUserAdmin() string { + return roleName(userAdmin, "") +} + func RoleMember() string { return roleName(member, "") } @@ -93,6 +103,31 @@ var ( } }, + templateAdmin: func(_ string) Role { + return Role{ + Name: templateAdmin, + DisplayName: "Template Admin", + Site: permissions(map[Object][]Action{ + ResourceTemplate: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + // CRUD all files, even those they did not upload. + ResourceFile: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + ResourceWorkspace: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + // CRUD to provisioner daemons for now. + ResourceProvisionerDaemon: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + }), + } + }, + + userAdmin: func(_ string) Role { + return Role{ + Name: userAdmin, + DisplayName: "User Admin", + Site: permissions(map[Object][]Action{ + ResourceUser: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + }), + } + }, + // orgAdmin returns a role with all actions allows in a given // organization scope. orgAdmin: func(organizationID string) Role { @@ -153,11 +188,13 @@ var ( // map[actor_role][assign_role] assignRoles = map[string]map[string]bool{ admin: { - admin: true, - auditor: true, - member: true, - orgAdmin: true, - orgMember: true, + admin: true, + auditor: true, + member: true, + orgAdmin: true, + orgMember: true, + templateAdmin: true, + userAdmin: true, }, orgAdmin: { orgAdmin: true, diff --git a/coderd/rbac/builtin_internal_test.go b/coderd/rbac/builtin_internal_test.go index 3ffe8879a2548..2e49949d6cc04 100644 --- a/coderd/rbac/builtin_internal_test.go +++ b/coderd/rbac/builtin_internal_test.go @@ -18,6 +18,8 @@ func TestRoleByName(t *testing.T) { }{ {Role: builtInRoles[admin]("")}, {Role: builtInRoles[member]("")}, + {Role: builtInRoles[templateAdmin]("")}, + {Role: builtInRoles[userAdmin]("")}, {Role: builtInRoles[auditor]("")}, {Role: builtInRoles[orgAdmin](uuid.New().String())}, diff --git a/coderd/rbac/builtin_test.go b/coderd/rbac/builtin_test.go index 231780e9eed56..de0bb076d82a9 100644 --- a/coderd/rbac/builtin_test.go +++ b/coderd/rbac/builtin_test.go @@ -111,6 +111,7 @@ func TestRolePermissions(t *testing.T) { // currentUser is anything that references "me", "mine", or "my". currentUser := uuid.New() adminID := uuid.New() + templateAdminID := uuid.New() orgID := uuid.New() otherOrg := uuid.New() @@ -124,9 +125,12 @@ func TestRolePermissions(t *testing.T) { otherOrgMember := authSubject{Name: "org_member_other", UserID: uuid.NewString(), Roles: []string{rbac.RoleMember(), rbac.RoleOrgMember(otherOrg)}} otherOrgAdmin := authSubject{Name: "org_admin_other", UserID: uuid.NewString(), Roles: []string{rbac.RoleMember(), rbac.RoleOrgMember(otherOrg), rbac.RoleOrgAdmin(otherOrg)}} + templateAdmin := authSubject{Name: "template-admin", UserID: templateAdminID.String(), Roles: []string{rbac.RoleMember(), rbac.RoleTemplateAdmin()}} + userAdmin := authSubject{Name: "user-admin", UserID: templateAdminID.String(), Roles: []string{rbac.RoleMember(), rbac.RoleUserAdmin()}} + // requiredSubjects are required to be asserted in each test case. This is // to make sure one is not forgotten. - requiredSubjects := []authSubject{memberMe, admin, orgMemberMe, orgAdmin, otherOrgAdmin, otherOrgMember} + requiredSubjects := []authSubject{memberMe, admin, orgMemberMe, orgAdmin, otherOrgAdmin, otherOrgMember, templateAdmin, userAdmin} testCases := []struct { // Name the test case to better locate the failing test case. @@ -146,7 +150,7 @@ func TestRolePermissions(t *testing.T) { Actions: []rbac.Action{rbac.ActionRead}, Resource: rbac.ResourceUser, AuthorizeMap: map[bool][]authSubject{ - true: {admin, memberMe, orgMemberMe, orgAdmin, otherOrgMember, otherOrgAdmin}, + true: {admin, memberMe, orgMemberMe, orgAdmin, otherOrgMember, otherOrgAdmin, templateAdmin, userAdmin}, false: {}, }, }, @@ -155,8 +159,8 @@ func TestRolePermissions(t *testing.T) { Actions: []rbac.Action{rbac.ActionCreate, rbac.ActionUpdate, rbac.ActionDelete}, Resource: rbac.ResourceUser, AuthorizeMap: map[bool][]authSubject{ - true: {admin}, - false: {memberMe, orgMemberMe, orgAdmin, otherOrgMember, otherOrgAdmin}, + true: {admin, userAdmin}, + false: {memberMe, orgMemberMe, orgAdmin, otherOrgMember, otherOrgAdmin, templateAdmin}, }, }, { @@ -165,8 +169,18 @@ func TestRolePermissions(t *testing.T) { Actions: []rbac.Action{rbac.ActionCreate, rbac.ActionRead, rbac.ActionUpdate, rbac.ActionDelete}, Resource: rbac.ResourceWorkspace.InOrg(orgID).WithOwner(currentUser.String()), AuthorizeMap: map[bool][]authSubject{ - true: {admin, orgMemberMe, orgAdmin}, - false: {memberMe, otherOrgAdmin, otherOrgMember}, + true: {admin, orgMemberMe, orgAdmin, templateAdmin}, + false: {memberMe, otherOrgAdmin, otherOrgMember, userAdmin}, + }, + }, + { + Name: "MyWorkspaceInOrgExecution", + // When creating the WithID won't be set, but it does not change the result. + Actions: []rbac.Action{rbac.ActionCreate, rbac.ActionRead, rbac.ActionUpdate, rbac.ActionDelete}, + Resource: rbac.ResourceWorkspaceExecution.InOrg(orgID).WithOwner(currentUser.String()), + AuthorizeMap: map[bool][]authSubject{ + true: {admin, orgAdmin, orgMemberMe}, + false: {memberMe, otherOrgAdmin, otherOrgMember, templateAdmin, userAdmin}, }, }, { @@ -174,8 +188,8 @@ func TestRolePermissions(t *testing.T) { Actions: []rbac.Action{rbac.ActionCreate, rbac.ActionUpdate, rbac.ActionDelete}, Resource: rbac.ResourceTemplate.InOrg(orgID), AuthorizeMap: map[bool][]authSubject{ - true: {admin, orgAdmin}, - false: {memberMe, orgMemberMe, otherOrgAdmin, otherOrgMember}, + true: {admin, orgAdmin, templateAdmin}, + false: {memberMe, orgMemberMe, otherOrgAdmin, otherOrgMember, userAdmin}, }, }, { @@ -183,8 +197,8 @@ func TestRolePermissions(t *testing.T) { Actions: []rbac.Action{rbac.ActionRead}, Resource: rbac.ResourceTemplate.InOrg(orgID), AuthorizeMap: map[bool][]authSubject{ - true: {admin, orgMemberMe, orgAdmin}, - false: {memberMe, otherOrgAdmin, otherOrgMember}, + true: {admin, orgMemberMe, orgAdmin, templateAdmin}, + false: {memberMe, otherOrgAdmin, otherOrgMember, userAdmin}, }, }, { @@ -192,8 +206,8 @@ func TestRolePermissions(t *testing.T) { Actions: []rbac.Action{rbac.ActionCreate}, Resource: rbac.ResourceFile, AuthorizeMap: map[bool][]authSubject{ - true: {admin}, - false: {orgMemberMe, orgAdmin, memberMe, otherOrgAdmin, otherOrgMember}, + true: {admin, templateAdmin}, + false: {orgMemberMe, orgAdmin, memberMe, otherOrgAdmin, otherOrgMember, userAdmin}, }, }, { @@ -201,8 +215,8 @@ func TestRolePermissions(t *testing.T) { Actions: []rbac.Action{rbac.ActionRead, rbac.ActionUpdate, rbac.ActionDelete}, Resource: rbac.ResourceFile.WithOwner(currentUser.String()), AuthorizeMap: map[bool][]authSubject{ - true: {admin, memberMe, orgMemberMe}, - false: {orgAdmin, otherOrgAdmin, otherOrgMember}, + true: {admin, memberMe, orgMemberMe, templateAdmin}, + false: {orgAdmin, otherOrgAdmin, otherOrgMember, userAdmin}, }, }, { @@ -211,7 +225,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceOrganization, AuthorizeMap: map[bool][]authSubject{ true: {admin}, - false: {orgAdmin, otherOrgAdmin, otherOrgMember, memberMe, orgMemberMe}, + false: {orgAdmin, otherOrgAdmin, otherOrgMember, memberMe, orgMemberMe, templateAdmin, userAdmin}, }, }, { @@ -220,7 +234,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceOrganization.InOrg(orgID), AuthorizeMap: map[bool][]authSubject{ true: {admin, orgAdmin}, - false: {otherOrgAdmin, otherOrgMember, memberMe, orgMemberMe}, + false: {otherOrgAdmin, otherOrgMember, memberMe, orgMemberMe, templateAdmin, userAdmin}, }, }, { @@ -229,7 +243,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceOrganization.InOrg(orgID), AuthorizeMap: map[bool][]authSubject{ true: {admin, orgAdmin, orgMemberMe}, - false: {otherOrgAdmin, otherOrgMember, memberMe}, + false: {otherOrgAdmin, otherOrgMember, memberMe, templateAdmin, userAdmin}, }, }, { @@ -238,7 +252,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceRoleAssignment, AuthorizeMap: map[bool][]authSubject{ true: {admin}, - false: {orgAdmin, orgMemberMe, otherOrgAdmin, otherOrgMember, memberMe}, + false: {orgAdmin, orgMemberMe, otherOrgAdmin, otherOrgMember, memberMe, templateAdmin, userAdmin}, }, }, { @@ -246,7 +260,7 @@ func TestRolePermissions(t *testing.T) { Actions: []rbac.Action{rbac.ActionRead}, Resource: rbac.ResourceRoleAssignment, AuthorizeMap: map[bool][]authSubject{ - true: {admin, orgAdmin, orgMemberMe, otherOrgAdmin, otherOrgMember, memberMe}, + true: {admin, orgAdmin, orgMemberMe, otherOrgAdmin, otherOrgMember, memberMe, templateAdmin, userAdmin}, false: {}, }, }, @@ -256,7 +270,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceOrgRoleAssignment.InOrg(orgID), AuthorizeMap: map[bool][]authSubject{ true: {admin, orgAdmin}, - false: {orgMemberMe, otherOrgAdmin, otherOrgMember, memberMe}, + false: {orgMemberMe, otherOrgAdmin, otherOrgMember, memberMe, templateAdmin, userAdmin}, }, }, { @@ -265,7 +279,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceOrgRoleAssignment.InOrg(orgID), AuthorizeMap: map[bool][]authSubject{ true: {admin, orgAdmin, orgMemberMe}, - false: {otherOrgAdmin, otherOrgMember, memberMe}, + false: {otherOrgAdmin, otherOrgMember, memberMe, templateAdmin, userAdmin}, }, }, { @@ -274,7 +288,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceAPIKey.WithOwner(currentUser.String()), AuthorizeMap: map[bool][]authSubject{ true: {admin, orgMemberMe, memberMe}, - false: {orgAdmin, otherOrgAdmin, otherOrgMember}, + false: {orgAdmin, otherOrgAdmin, otherOrgMember, templateAdmin, userAdmin}, }, }, { @@ -283,7 +297,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceUserData.WithOwner(currentUser.String()), AuthorizeMap: map[bool][]authSubject{ true: {admin, orgMemberMe, memberMe}, - false: {orgAdmin, otherOrgAdmin, otherOrgMember}, + false: {orgAdmin, otherOrgAdmin, otherOrgMember, templateAdmin, userAdmin}, }, }, { @@ -292,7 +306,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceOrganizationMember.InOrg(orgID), AuthorizeMap: map[bool][]authSubject{ true: {admin, orgAdmin}, - false: {orgMemberMe, memberMe, otherOrgAdmin, otherOrgMember}, + false: {orgMemberMe, memberMe, otherOrgAdmin, otherOrgMember, templateAdmin, userAdmin}, }, }, { @@ -301,7 +315,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceOrganizationMember.InOrg(orgID), AuthorizeMap: map[bool][]authSubject{ true: {admin, orgAdmin, orgMemberMe}, - false: {memberMe, otherOrgAdmin, otherOrgMember}, + false: {memberMe, otherOrgAdmin, otherOrgMember, templateAdmin, userAdmin}, }, }, } @@ -396,10 +410,14 @@ func TestListRoles(t *testing.T) { // If this test is ever failing, just update the list to the roles // expected from the builtin set. + // Always use constant strings, as if the names change, we need to write + // a SQL migration to change the name on the backend. require.ElementsMatch(t, []string{ "admin", "member", "auditor", + "template-admin", + "user-admin", }, siteRoleNames) diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 88f342d286e41..0dff5218748da 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -22,6 +22,15 @@ var ( Type: "workspace", } + // ResourceWorkspaceExecution CRUD. Org + User owner + // create = workspace remote execution + // read = ? + // update = ? + // delete = ? + ResourceWorkspaceExecution = Object{ + Type: "workspace_execution", + } + // ResourceAuditLog // read = access audit log ResourceAuditLog = Object{ diff --git a/coderd/roles.go b/coderd/roles.go index 05013f696cd94..c8f3d95e112af 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -13,23 +13,27 @@ import ( // assignableSiteRoles returns all site wide roles that can be assigned. func (api *API) assignableSiteRoles(rw http.ResponseWriter, r *http.Request) { - // TODO: @emyrk in the future, allow granular subsets of roles to be returned based on the - // role of the user. - + actorRoles := httpmw.AuthorizationUserRoles(r) if !api.Authorize(r, rbac.ActionRead, rbac.ResourceRoleAssignment) { httpapi.Forbidden(rw) return } roles := rbac.SiteRoles() - httpapi.Write(rw, http.StatusOK, convertRoles(roles)) + assignable := make([]rbac.Role, 0) + for _, role := range roles { + if rbac.CanAssignRole(actorRoles.Roles, role.Name) { + assignable = append(assignable, role) + } + } + + httpapi.Write(rw, http.StatusOK, convertRoles(assignable)) } // assignableSiteRoles returns all site wide roles that can be assigned. func (api *API) assignableOrgRoles(rw http.ResponseWriter, r *http.Request) { - // TODO: @emyrk in the future, allow granular subsets of roles to be returned based on the - // role of the user. organization := httpmw.OrganizationParam(r) + actorRoles := httpmw.AuthorizationUserRoles(r) if !api.Authorize(r, rbac.ActionRead, rbac.ResourceOrgRoleAssignment.InOrg(organization.ID)) { httpapi.Forbidden(rw) @@ -37,7 +41,14 @@ func (api *API) assignableOrgRoles(rw http.ResponseWriter, r *http.Request) { } roles := rbac.OrganizationRoles(organization.ID) - httpapi.Write(rw, http.StatusOK, convertRoles(roles)) + assignable := make([]rbac.Role, 0) + for _, role := range roles { + if rbac.CanAssignRole(actorRoles.Roles, role.Name) { + assignable = append(assignable, role) + } + } + + httpapi.Write(rw, http.StatusOK, convertRoles(assignable)) } func (api *API) checkPermissions(rw http.ResponseWriter, r *http.Request) { diff --git a/coderd/roles_test.go b/coderd/roles_test.go index 1e1b3b177fab8..d2b83c83cc618 100644 --- a/coderd/roles_test.go +++ b/coderd/roles_test.go @@ -120,7 +120,7 @@ func TestListRoles(t *testing.T) { require.NoError(t, err, "create org") const forbidden = "Forbidden" - siteRoles := convertRoles(rbac.RoleAdmin(), "auditor") + siteRoles := convertRoles(rbac.RoleAdmin(), "auditor", "template-admin", "user-admin") orgRoles := convertRoles(rbac.RoleOrgAdmin(admin.OrganizationID)) testCases := []struct { @@ -131,19 +131,20 @@ func TestListRoles(t *testing.T) { AuthorizedError string }{ { + // Members cannot assign any roles Name: "MemberListSite", APICall: func(ctx context.Context) ([]codersdk.Role, error) { x, err := member.ListSiteRoles(ctx) return x, err }, - ExpectedRoles: siteRoles, + ExpectedRoles: []codersdk.Role{}, }, { Name: "OrgMemberListOrg", APICall: func(ctx context.Context) ([]codersdk.Role, error) { return member.ListOrganizationRoles(ctx, admin.OrganizationID) }, - ExpectedRoles: orgRoles, + ExpectedRoles: []codersdk.Role{}, }, { Name: "NonOrgMemberListOrg", @@ -158,7 +159,7 @@ func TestListRoles(t *testing.T) { APICall: func(ctx context.Context) ([]codersdk.Role, error) { return orgAdmin.ListSiteRoles(ctx) }, - ExpectedRoles: siteRoles, + ExpectedRoles: []codersdk.Role{}, }, { Name: "OrgAdminListOrg", diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 798b75c88d07e..0178026f4d8b6 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -70,7 +70,7 @@ func (api *API) workspaceAgentDial(rw http.ResponseWriter, r *http.Request) { workspaceAgent := httpmw.WorkspaceAgentParam(r) workspace := httpmw.WorkspaceParam(r) - if !api.Authorize(r, rbac.ActionUpdate, workspace) { + if !api.Authorize(r, rbac.ActionCreate, workspace.ExecutionRBAC()) { httpapi.ResourceNotFound(rw) return } @@ -302,6 +302,19 @@ func (api *API) workspaceAgentICEServers(rw http.ResponseWriter, _ *http.Request httpapi.Write(rw, http.StatusOK, api.ICEServers) } +// userWorkspaceAgentTurn is a user connecting to a remote workspace agent +// through turn. +func (api *API) userWorkspaceAgentTurn(rw http.ResponseWriter, r *http.Request) { + workspace := httpmw.WorkspaceParam(r) + if !api.Authorize(r, rbac.ActionCreate, workspace.ExecutionRBAC()) { + httpapi.ResourceNotFound(rw) + return + } + + // Passed authorization + api.workspaceAgentTurn(rw, r) +} + // workspaceAgentTurn proxies a WebSocket connection to the TURN server. func (api *API) workspaceAgentTurn(rw http.ResponseWriter, r *http.Request) { api.websocketWaitMutex.Lock() @@ -364,7 +377,7 @@ func (api *API) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) { workspaceAgent := httpmw.WorkspaceAgentParam(r) workspace := httpmw.WorkspaceParam(r) - if !api.Authorize(r, rbac.ActionUpdate, workspace) { + if !api.Authorize(r, rbac.ActionCreate, workspace.ExecutionRBAC()) { httpapi.ResourceNotFound(rw) return } @@ -478,7 +491,7 @@ func (api *API) postWorkspaceAgentWireguardPeer(rw http.ResponseWriter, r *http. workspace = httpmw.WorkspaceParam(r) ) - if !api.Authorize(r, rbac.ActionUpdate, workspace) { + if !api.Authorize(r, rbac.ActionCreate, workspace.ExecutionRBAC()) { httpapi.ResourceNotFound(rw) return } diff --git a/coderd/workspaceapps.go b/coderd/workspaceapps.go index f08e9d77c01e4..99c30eec2b7a7 100644 --- a/coderd/workspaceapps.go +++ b/coderd/workspaceapps.go @@ -43,7 +43,8 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) }) return } - if !api.Authorize(r, rbac.ActionRead, workspace) { + + if !api.Authorize(r, rbac.ActionCreate, workspace.ExecutionRBAC()) { httpapi.ResourceNotFound(rw) return } diff --git a/codersdk/roles.go b/codersdk/roles.go index 377565c06d404..d6e34c7e48127 100644 --- a/codersdk/roles.go +++ b/codersdk/roles.go @@ -14,8 +14,7 @@ type Role struct { DisplayName string `json:"display_name"` } -// ListSiteRoles lists all available site wide roles. -// This is not user specific. +// ListSiteRoles lists all assignable site wide roles. func (c *Client) ListSiteRoles(ctx context.Context) ([]Role, error) { res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/roles", nil) if err != nil { @@ -29,8 +28,7 @@ func (c *Client) ListSiteRoles(ctx context.Context) ([]Role, error) { return roles, json.NewDecoder(res.Body).Decode(&roles) } -// ListOrganizationRoles lists all available roles for a given organization. -// This is not user specific. +// ListOrganizationRoles lists all assignable roles for a given organization. func (c *Client) ListOrganizationRoles(ctx context.Context, org uuid.UUID) ([]Role, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/roles", org.String()), nil) if err != nil { diff --git a/site/src/components/Tooltips/UserRoleHelpTooltip.tsx b/site/src/components/Tooltips/UserRoleHelpTooltip.tsx new file mode 100644 index 0000000000000..47a3722c938ba --- /dev/null +++ b/site/src/components/Tooltips/UserRoleHelpTooltip.tsx @@ -0,0 +1,30 @@ +import { FC } from "react" +import { + HelpTooltip, + HelpTooltipLink, + HelpTooltipLinksGroup, + HelpTooltipText, + HelpTooltipTitle, +} from "./HelpTooltip" + +export const Language = { + title: "What is a role?", + text: + "Coder role-based access control (RBAC) provides fine-grained access management. " + + "View our docs on how to use the available roles.", + link: "User Roles", +} + +export const UserRoleHelpTooltip: FC = () => { + return ( + + {Language.title} + {Language.text} + + + {Language.link} + + + + ) +} diff --git a/site/src/components/Tooltips/index.ts b/site/src/components/Tooltips/index.ts index 4f7eabac682c7..e7b4f1bc5a2ff 100644 --- a/site/src/components/Tooltips/index.ts +++ b/site/src/components/Tooltips/index.ts @@ -2,4 +2,5 @@ export { AgentHelpTooltip } from "./AgentHelpTooltip" export { AuditHelpTooltip } from "./AuditHelpTooltip" export { OutdatedHelpTooltip } from "./OutdatedHelpTooltip" export { ResourcesHelpTooltip } from "./ResourcesHelpTooltip" +export { UserRoleHelpTooltip } from "./UserRoleHelpTooltip" export { WorkspaceHelpTooltip } from "./WorkspaceHelpTooltip" diff --git a/site/src/components/UsersTable/UsersTable.test.tsx b/site/src/components/UsersTable/UsersTable.test.tsx new file mode 100644 index 0000000000000..d9ca51fae5cc5 --- /dev/null +++ b/site/src/components/UsersTable/UsersTable.test.tsx @@ -0,0 +1,24 @@ +import { fireEvent, screen } from "@testing-library/react" +import { Language as TooltipLanguage } from "components/Tooltips/HelpTooltip/HelpTooltip" +import { Language as UserRoleLanguage } from "components/Tooltips/UserRoleHelpTooltip" +import { render } from "testHelpers/renderHelpers" +import { UsersTable } from "./UsersTable" + +describe("AuditPage", () => { + it("renders a page with a title and subtitle", async () => { + // When + render( + jest.fn()} + onActivateUser={() => jest.fn()} + onResetUserPassword={() => jest.fn()} + onUpdateUserRoles={() => jest.fn()} + />, + ) + + // Then + const tooltipIcon = await screen.findByRole("button", { name: TooltipLanguage.ariaLabel }) + fireEvent.mouseOver(tooltipIcon) + expect(await screen.findByText(UserRoleLanguage.title)).toBeInTheDocument() + }) +}) diff --git a/site/src/components/UsersTable/UsersTable.tsx b/site/src/components/UsersTable/UsersTable.tsx index 0275ea08cfc91..fc49dca3b1b34 100644 --- a/site/src/components/UsersTable/UsersTable.tsx +++ b/site/src/components/UsersTable/UsersTable.tsx @@ -6,6 +6,8 @@ import TableHead from "@material-ui/core/TableHead" import TableRow from "@material-ui/core/TableRow" import { FC } from "react" import * as TypesGen from "../../api/typesGenerated" +import { Stack } from "../Stack/Stack" +import { UserRoleHelpTooltip } from "../Tooltips" import { UsersTableBody } from "./UsersTableBody" export const Language = { @@ -44,7 +46,12 @@ export const UsersTable: FC = ({ {Language.usernameLabel} {Language.statusLabel} - {Language.rolesLabel} + + + {Language.rolesLabel} + + + {/* 1% is a trick to make the table cell width fit the content */} {canEditUsers && }