feat: Add template-admin + user-admin role for managing templates + users (#3490)

Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>
This commit is contained in:
Steven Masley 2022-08-12 17:27:48 -05:00 committed by GitHub
parent c41261cf6e
commit 40e68cb80b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 219 additions and 59 deletions

View File

@ -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)

View File

@ -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,

View File

@ -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)
}

View File

@ -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]<can_assign>
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,

View File

@ -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())},

View File

@ -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)

View File

@ -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{

View File

@ -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) {

View File

@ -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",

View File

@ -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
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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 (
<HelpTooltip size="small">
<HelpTooltipTitle>{Language.title}</HelpTooltipTitle>
<HelpTooltipText>{Language.text}</HelpTooltipText>
<HelpTooltipLinksGroup>
<HelpTooltipLink href="https://coder.com/docs/coder-oss/latest/users">
{Language.link}
</HelpTooltipLink>
</HelpTooltipLinksGroup>
</HelpTooltip>
)
}

View File

@ -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"

View File

@ -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(
<UsersTable
onSuspendUser={() => 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()
})
})

View File

@ -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<UsersTableProps> = ({
<TableRow>
<TableCell>{Language.usernameLabel}</TableCell>
<TableCell>{Language.statusLabel}</TableCell>
<TableCell>{Language.rolesLabel}</TableCell>
<TableCell>
<Stack direction="row" spacing={1} alignItems="center">
<span>{Language.rolesLabel}</span>
<UserRoleHelpTooltip />
</Stack>
</TableCell>
{/* 1% is a trick to make the table cell width fit the content */}
{canEditUsers && <TableCell width="1%" />}
</TableRow>