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