feat: Add user roles, but do not yet enforce them (#1200)

* chore: Rework roles to be expandable by name alone
This commit is contained in:
Steven Masley 2022-04-29 09:04:19 -05:00 committed by GitHub
parent ba4c3ce3b9
commit 35211e2190
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1150 additions and 232 deletions

View File

@ -42,6 +42,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{
"created_at": ActionIgnore, // Never changes.
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
"status": ActionTrack, // A user can update another user status
"rbac_roles": ActionTrack, // A user's roles are mutable
},
&database.Workspace{}: {
"id": ActionIgnore, // Never changes.

View File

@ -120,6 +120,14 @@ func New(options *Options) (http.Handler, func()) {
r.Get("/", api.workspacesByOwner)
})
})
r.Route("/members", func(r chi.Router) {
r.Route("/{user}", func(r chi.Router) {
r.Use(
httpmw.ExtractUserParam(options.Database),
)
r.Put("/roles", api.putMemberRoles)
})
})
})
r.Route("/parameters/{scope}/{id}", func(r chi.Router) {
r.Use(apiKeyMiddleware)
@ -183,6 +191,10 @@ func New(options *Options) (http.Handler, func()) {
r.Get("/", api.userByName)
r.Put("/profile", api.putUserProfile)
r.Put("/suspend", api.putUserSuspend)
// TODO: @emyrk Might want to move these to a /roles group instead of /user.
// As we include more roles like org roles, it makes less sense to scope these here.
r.Put("/roles", api.putUserRoles)
r.Get("/roles", api.userRoles)
r.Get("/organizations", api.organizationsByUser)
r.Post("/organizations", api.postOrganizationsByUser)
r.Post("/keys", api.postAPIKey)

View File

@ -743,6 +743,43 @@ func (q *fakeQuerier) GetOrganizationIDsByMemberIDs(_ context.Context, ids []uui
return getOrganizationIDsByMemberIDRows, nil
}
func (q *fakeQuerier) GetOrganizationMembershipsByUserID(_ context.Context, userID uuid.UUID) ([]database.OrganizationMember, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
var memberships []database.OrganizationMember
for _, organizationMember := range q.organizationMembers {
mem := organizationMember
if mem.UserID != userID {
continue
}
memberships = append(memberships, mem)
}
return memberships, nil
}
func (q *fakeQuerier) UpdateMemberRoles(_ context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) {
for i, mem := range q.organizationMembers {
if mem.UserID == arg.UserID && mem.OrganizationID == arg.OrgID {
uniqueRoles := make([]string, 0, len(arg.GrantedRoles))
exist := make(map[string]struct{})
for _, r := range arg.GrantedRoles {
if _, ok := exist[r]; ok {
continue
}
exist[r] = struct{}{}
uniqueRoles = append(uniqueRoles, r)
}
sort.Strings(uniqueRoles)
mem.Roles = uniqueRoles
q.organizationMembers[i] = mem
return mem, nil
}
}
return database.OrganizationMember{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetProvisionerDaemons(_ context.Context) ([]database.ProvisionerDaemon, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@ -1173,11 +1210,42 @@ func (q *fakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam
UpdatedAt: arg.UpdatedAt,
Username: arg.Username,
Status: database.UserStatusActive,
RBACRoles: arg.RBACRoles,
}
q.users = append(q.users, user)
return user, nil
}
func (q *fakeQuerier) UpdateUserRoles(_ context.Context, arg database.UpdateUserRolesParams) (database.User, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
for index, user := range q.users {
if user.ID != arg.ID {
continue
}
// Set new roles
user.RBACRoles = arg.GrantedRoles
// Remove duplicates and sort
uniqueRoles := make([]string, 0, len(user.RBACRoles))
exist := make(map[string]struct{})
for _, r := range user.RBACRoles {
if _, ok := exist[r]; ok {
continue
}
exist[r] = struct{}{}
uniqueRoles = append(uniqueRoles, r)
}
sort.Strings(uniqueRoles)
user.RBACRoles = uniqueRoles
q.users[index] = user
return user, nil
}
return database.User{}, sql.ErrNoRows
}
func (q *fakeQuerier) UpdateUserProfile(_ context.Context, arg database.UpdateUserProfileParams) (database.User, error) {
q.mutex.Lock()
defer q.mutex.Unlock()

View File

@ -227,7 +227,8 @@ CREATE TABLE users (
hashed_password bytea NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
status user_status DEFAULT 'active'::public.user_status NOT NULL
status user_status DEFAULT 'active'::public.user_status NOT NULL,
rbac_roles text[] DEFAULT '{}'::text[] NOT NULL
);
CREATE TABLE workspace_agents (

View File

@ -0,0 +1,2 @@
ALTER TABLE ONLY users
DROP COLUMN IF EXISTS rbac_roles;

View File

@ -0,0 +1,18 @@
ALTER TABLE ONLY users
ADD COLUMN IF NOT EXISTS rbac_roles text[] DEFAULT '{}' NOT NULL;
-- All users are site members. So give them the standard role.
-- Also give them membership to the first org we retrieve. We should only have
-- 1 organization at this point in the product.
UPDATE
users
SET
rbac_roles = ARRAY ['member', 'organization-member:' || (SELECT id FROM organizations LIMIT 1)];
-- Give the first user created the admin role
UPDATE
users
SET
rbac_roles = rbac_roles || ARRAY ['admin']
WHERE
id = (SELECT id FROM users ORDER BY created_at ASC LIMIT 1)

View File

@ -398,6 +398,7 @@ type User struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Status UserStatus `db:"status" json:"status"`
RBACRoles []string `db:"rbac_roles" json:"rbac_roles"`
}
type Workspace struct {

View File

@ -19,6 +19,7 @@ type querier interface {
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)
@ -78,6 +79,7 @@ type querier interface {
InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error)
UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error
UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) error
UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error)
UpdateProvisionerDaemonByID(ctx context.Context, arg UpdateProvisionerDaemonByIDParams) error
UpdateProvisionerJobByID(ctx context.Context, arg UpdateProvisionerJobByIDParams) error
UpdateProvisionerJobWithCancelByID(ctx context.Context, arg UpdateProvisionerJobWithCancelByIDParams) error
@ -86,6 +88,7 @@ type querier interface {
UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error
UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error)
UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error)
UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error)
UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error
UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error

View File

@ -375,6 +375,44 @@ func (q *sqlQuerier) GetOrganizationMemberByUserID(ctx context.Context, arg GetO
return i, err
}
const getOrganizationMembershipsByUserID = `-- name: GetOrganizationMembershipsByUserID :many
SELECT
user_id, organization_id, created_at, updated_at, roles
FROM
organization_members
WHERE
user_id = $1
`
func (q *sqlQuerier) GetOrganizationMembershipsByUserID(ctx context.Context, userID uuid.UUID) ([]OrganizationMember, error) {
rows, err := q.db.QueryContext(ctx, getOrganizationMembershipsByUserID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []OrganizationMember
for rows.Next() {
var i OrganizationMember
if err := rows.Scan(
&i.UserID,
&i.OrganizationID,
&i.CreatedAt,
&i.UpdatedAt,
pq.Array(&i.Roles),
); 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 insertOrganizationMember = `-- name: InsertOrganizationMember :one
INSERT INTO
organization_members (
@ -415,6 +453,37 @@ func (q *sqlQuerier) InsertOrganizationMember(ctx context.Context, arg InsertOrg
return i, err
}
const updateMemberRoles = `-- name: UpdateMemberRoles :one
UPDATE
organization_members
SET
-- Remove all duplicates from the roles.
roles = ARRAY(SELECT DISTINCT UNNEST($1 :: text[]))
WHERE
user_id = $2
AND organization_id = $3
RETURNING user_id, organization_id, created_at, updated_at, roles
`
type UpdateMemberRolesParams struct {
GrantedRoles []string `db:"granted_roles" json:"granted_roles"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
OrgID uuid.UUID `db:"org_id" json:"org_id"`
}
func (q *sqlQuerier) UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error) {
row := q.db.QueryRowContext(ctx, updateMemberRoles, pq.Array(arg.GrantedRoles), arg.UserID, arg.OrgID)
var i OrganizationMember
err := row.Scan(
&i.UserID,
&i.OrganizationID,
&i.CreatedAt,
&i.UpdatedAt,
pq.Array(&i.Roles),
)
return i, err
}
const getOrganizationByID = `-- name: GetOrganizationByID :one
SELECT
id, name, description, created_at, updated_at
@ -1821,7 +1890,7 @@ func (q *sqlQuerier) UpdateTemplateVersionByID(ctx context.Context, arg UpdateTe
const getUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one
SELECT
id, email, username, hashed_password, created_at, updated_at, status
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles
FROM
users
WHERE
@ -1847,13 +1916,14 @@ func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserBy
&i.CreatedAt,
&i.UpdatedAt,
&i.Status,
pq.Array(&i.RBACRoles),
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT
id, email, username, hashed_password, created_at, updated_at, status
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles
FROM
users
WHERE
@ -1873,6 +1943,7 @@ func (q *sqlQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (User, error
&i.CreatedAt,
&i.UpdatedAt,
&i.Status,
pq.Array(&i.RBACRoles),
)
return i, err
}
@ -1893,7 +1964,7 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context) (int64, error) {
const getUsers = `-- name: GetUsers :many
SELECT
id, email, username, hashed_password, created_at, updated_at, status
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles
FROM
users
WHERE
@ -1978,6 +2049,7 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]User,
&i.CreatedAt,
&i.UpdatedAt,
&i.Status,
pq.Array(&i.RBACRoles),
); err != nil {
return nil, err
}
@ -2000,10 +2072,11 @@ INSERT INTO
username,
hashed_password,
created_at,
updated_at
updated_at,
rbac_roles
)
VALUES
($1, $2, $3, $4, $5, $6) RETURNING id, email, username, hashed_password, created_at, updated_at, status
($1, $2, $3, $4, $5, $6, $7) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles
`
type InsertUserParams struct {
@ -2013,6 +2086,7 @@ type InsertUserParams struct {
HashedPassword []byte `db:"hashed_password" json:"hashed_password"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
RBACRoles []string `db:"rbac_roles" json:"rbac_roles"`
}
func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) {
@ -2023,6 +2097,7 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User
arg.HashedPassword,
arg.CreatedAt,
arg.UpdatedAt,
pq.Array(arg.RBACRoles),
)
var i User
err := row.Scan(
@ -2033,6 +2108,7 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User
&i.CreatedAt,
&i.UpdatedAt,
&i.Status,
pq.Array(&i.RBACRoles),
)
return i, err
}
@ -2045,7 +2121,7 @@ SET
username = $3,
updated_at = $4
WHERE
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles
`
type UpdateUserProfileParams struct {
@ -2071,6 +2147,39 @@ func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfil
&i.CreatedAt,
&i.UpdatedAt,
&i.Status,
pq.Array(&i.RBACRoles),
)
return i, err
}
const updateUserRoles = `-- name: UpdateUserRoles :one
UPDATE
users
SET
-- Remove all duplicates from the roles.
rbac_roles = ARRAY(SELECT DISTINCT UNNEST($1 :: text[]))
WHERE
id = $2
RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles
`
type UpdateUserRolesParams struct {
GrantedRoles []string `db:"granted_roles" json:"granted_roles"`
ID uuid.UUID `db:"id" json:"id"`
}
func (q *sqlQuerier) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error) {
row := q.db.QueryRowContext(ctx, updateUserRoles, pq.Array(arg.GrantedRoles), arg.ID)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.Username,
&i.HashedPassword,
&i.CreatedAt,
&i.UpdatedAt,
&i.Status,
pq.Array(&i.RBACRoles),
)
return i, err
}
@ -2082,7 +2191,7 @@ SET
status = $2,
updated_at = $3
WHERE
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles
`
type UpdateUserStatusParams struct {
@ -2102,6 +2211,7 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP
&i.CreatedAt,
&i.UpdatedAt,
&i.Status,
pq.Array(&i.RBACRoles),
)
return i, err
}

View File

@ -21,6 +21,15 @@ INSERT INTO
VALUES
($1, $2, $3, $4, $5) RETURNING *;
-- name: GetOrganizationMembershipsByUserID :many
SELECT
*
FROM
organization_members
WHERE
user_id = $1;
-- name: GetOrganizationIDsByMemberIDs :many
SELECT
user_id, array_agg(organization_id) :: uuid [ ] AS "organization_IDs"
@ -30,3 +39,14 @@ WHERE
user_id = ANY(@ids :: uuid [ ])
GROUP BY
user_id;
-- name: UpdateMemberRoles :one
UPDATE
organization_members
SET
-- Remove all duplicates from the roles.
roles = ARRAY(SELECT DISTINCT UNNEST(@granted_roles :: text[]))
WHERE
user_id = @user_id
AND organization_id = @org_id
RETURNING *;

View File

@ -33,10 +33,11 @@ INSERT INTO
username,
hashed_password,
created_at,
updated_at
updated_at,
rbac_roles
)
VALUES
($1, $2, $3, $4, $5, $6) RETURNING *;
($1, $2, $3, $4, $5, $6, $7) RETURNING *;
-- name: UpdateUserProfile :one
UPDATE
@ -48,6 +49,16 @@ SET
WHERE
id = $1 RETURNING *;
-- name: UpdateUserRoles :one
UPDATE
users
SET
-- Remove all duplicates from the roles.
rbac_roles = ARRAY(SELECT DISTINCT UNNEST(@granted_roles :: text[]))
WHERE
id = @id
RETURNING *;
-- name: GetUsers :many
SELECT
*

View File

@ -28,3 +28,4 @@ rename:
parameter_type_system_hcl: ParameterTypeSystemHCL
userstatus: UserStatus
gitsshkey: GitSSHKey
rbac_roles: RBACRoles

95
coderd/members.go Normal file
View File

@ -0,0 +1,95 @@
package coderd
import (
"context"
"fmt"
"net/http"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/codersdk"
)
func (api *api) putMemberRoles(rw http.ResponseWriter, r *http.Request) {
// User is the user to modify
// TODO: Until rbac authorize is implemented, only be able to change your
// own roles. This also means you can grant yourself whatever roles you want.
user := httpmw.UserParam(r)
apiKey := httpmw.APIKey(r)
organization := httpmw.OrganizationParam(r)
// TODO: @emyrk add proper `Authorize()` check here instead of a uuid match.
// Proper authorize should check the granted roles are able to given within
// the selected organization. Until then, allow anarchy
if apiKey.UserID != user.ID {
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
Message: fmt.Sprintf("modifying other users is not supported at this time"),
})
return
}
var params codersdk.UpdateRoles
if !httpapi.Read(rw, r, &params) {
return
}
updatedUser, err := api.updateOrganizationMemberRoles(r.Context(), database.UpdateMemberRolesParams{
GrantedRoles: params.Roles,
UserID: user.ID,
OrgID: organization.ID,
})
if err != nil {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: err.Error(),
})
return
}
httpapi.Write(rw, http.StatusOK, convertOrganizationMember(updatedUser))
}
func (api *api) updateOrganizationMemberRoles(ctx context.Context, args database.UpdateMemberRolesParams) (database.OrganizationMember, error) {
// Enforce only site wide roles
for _, r := range args.GrantedRoles {
// Must be an org role for the org in the args
orgID, ok := rbac.IsOrgRole(r)
if !ok {
return database.OrganizationMember{}, xerrors.Errorf("must only update organization roles")
}
roleOrg, err := uuid.Parse(orgID)
if err != nil {
return database.OrganizationMember{}, xerrors.Errorf("role must have proper uuids for organization, %q does not", r)
}
if roleOrg != args.OrgID {
return database.OrganizationMember{}, xerrors.Errorf("must only pass roles for org %q", args.OrgID.String())
}
if _, err := rbac.RoleByName(r); err != nil {
return database.OrganizationMember{}, xerrors.Errorf("%q is not a supported role", r)
}
}
updatedUser, err := api.Database.UpdateMemberRoles(ctx, args)
if err != nil {
return database.OrganizationMember{}, xerrors.Errorf("update site roles: %w", err)
}
return updatedUser, nil
}
func convertOrganizationMember(mem database.OrganizationMember) codersdk.OrganizationMember {
return codersdk.OrganizationMember{
UserID: mem.UserID,
OrganizationID: mem.OrganizationID,
CreatedAt: mem.CreatedAt,
UpdatedAt: mem.UpdatedAt,
Roles: mem.Roles,
}
}

View File

@ -38,6 +38,23 @@ type authSubject struct {
Roles []Role `json:"roles"`
}
// AuthorizeByRoleName will expand all roleNames into roles before calling Authorize().
// This is the function intended to be used outside this package.
// The role is fetched from the builtin map located in memory.
func (a RegoAuthorizer) AuthorizeByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, object Object) error {
roles := make([]Role, 0, len(roleNames))
for _, n := range roleNames {
r, err := RoleByName(n)
if err != nil {
return xerrors.Errorf("get role permissions: %w", err)
}
roles = append(roles, r)
}
return a.Authorize(ctx, subjectID, roles, action, object)
}
// Authorize allows passing in custom Roles.
// This is really helpful for unit testing, as we can create custom roles to exercise edge cases.
func (a RegoAuthorizer) Authorize(ctx context.Context, subjectID string, roles []Role, action Action, object Object) error {
input := map[string]interface{}{
"subject": authSubject{

View File

@ -3,8 +3,11 @@ package rbac_test
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/stretchr/testify/require"
@ -14,19 +17,26 @@ import (
// subject is required because rego needs
type subject struct {
UserID string `json:"id"`
Roles []rbac.Role `json:"roles"`
UserID string `json:"id"`
// For the unit test we want to pass in the roles directly, instead of just
// by name. This allows us to test custom roles that do not exist in the product,
// but test edge cases of the implementation.
Roles []rbac.Role `json:"roles"`
}
// TestAuthorizeDomain test the very basic roles that are commonly used.
func TestAuthorizeDomain(t *testing.T) {
t.Parallel()
defOrg := "default"
defOrg := uuid.New()
unuseID := uuid.New()
wrkID := "1234"
user := subject{
UserID: "me",
Roles: []rbac.Role{rbac.RoleMember, rbac.RoleOrgMember(defOrg)},
Roles: []rbac.Role{
must(rbac.RoleByName(rbac.RoleMember())),
must(rbac.RoleByName(rbac.RoleOrgMember(defOrg))),
},
}
testAuthorize(t, "Member", user, []authTestCase{
@ -44,10 +54,10 @@ func TestAuthorizeDomain(t *testing.T) {
{resource: rbac.ResourceWorkspace.All(), actions: allActions(), allow: false},
// Other org + me + id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID).WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID).WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false},
// Other org + other user + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me").WithID(wrkID), actions: allActions(), allow: false},
@ -57,10 +67,10 @@ func TestAuthorizeDomain(t *testing.T) {
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
// Other org + other use + other id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
@ -99,10 +109,10 @@ func TestAuthorizeDomain(t *testing.T) {
{resource: rbac.ResourceWorkspace.All(), actions: allActions(), allow: false},
// Other org + me + id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID).WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID).WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false},
// Other org + other user + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me").WithID(wrkID), actions: allActions(), allow: false},
@ -112,10 +122,10 @@ func TestAuthorizeDomain(t *testing.T) {
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
// Other org + other use + other id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
@ -126,8 +136,8 @@ func TestAuthorizeDomain(t *testing.T) {
user = subject{
UserID: "me",
Roles: []rbac.Role{
rbac.RoleOrgAdmin(defOrg),
rbac.RoleMember,
must(rbac.RoleByName(rbac.RoleOrgAdmin(defOrg))),
must(rbac.RoleByName(rbac.RoleMember())),
},
}
@ -146,10 +156,10 @@ func TestAuthorizeDomain(t *testing.T) {
{resource: rbac.ResourceWorkspace.All(), actions: allActions(), allow: false},
// Other org + me + id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID).WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID).WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false},
// Other org + other user + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me").WithID(wrkID), actions: allActions(), allow: true},
@ -159,10 +169,10 @@ func TestAuthorizeDomain(t *testing.T) {
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
// Other org + other use + other id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
@ -173,8 +183,8 @@ func TestAuthorizeDomain(t *testing.T) {
user = subject{
UserID: "me",
Roles: []rbac.Role{
rbac.RoleAdmin,
rbac.RoleMember,
must(rbac.RoleByName(rbac.RoleAdmin())),
must(rbac.RoleByName(rbac.RoleMember())),
},
}
@ -193,10 +203,10 @@ func TestAuthorizeDomain(t *testing.T) {
{resource: rbac.ResourceWorkspace.All(), actions: allActions(), allow: true},
// Other org + me + id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID).WithID(wrkID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID(wrkID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg("other"), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID).WithID(wrkID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithID(wrkID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: true},
// Other org + other user + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me").WithID(wrkID), actions: allActions(), allow: true},
@ -206,10 +216,10 @@ func TestAuthorizeDomain(t *testing.T) {
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: true},
// Other org + other use + other id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me").WithID("not-id"), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me"), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID("not-id"), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg("other"), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me").WithID("not-id"), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithID("not-id"), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID("not-id"), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: true},
@ -221,7 +231,19 @@ func TestAuthorizeDomain(t *testing.T) {
user = subject{
UserID: "me",
Roles: []rbac.Role{
rbac.RoleWorkspaceAgent(wrkID),
{
Name: fmt.Sprintf("agent-%s", wrkID),
// This is at the site level to prevent the token from losing access if the user
// is kicked from the org
Site: []rbac.Permission{
{
Negate: false,
ResourceType: rbac.ResourceWorkspace.Type,
ResourceID: wrkID,
Action: rbac.ActionRead,
},
},
},
},
}
@ -245,10 +267,10 @@ func TestAuthorizeDomain(t *testing.T) {
{resource: rbac.ResourceWorkspace.All(), allow: false},
// Other org + me + id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID).WithID(wrkID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID(wrkID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg("other"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID).WithID(wrkID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithID(wrkID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), allow: false},
// Other org + other user + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me").WithID(wrkID), allow: true},
@ -258,10 +280,10 @@ func TestAuthorizeDomain(t *testing.T) {
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), allow: false},
// Other org + other use + other id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), allow: false},
@ -288,10 +310,10 @@ func TestAuthorizeDomain(t *testing.T) {
{resource: rbac.ResourceWorkspace.All()},
// Other org + me + id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg("other")},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg(unuseID)},
// Other org + other user + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me").WithID(wrkID)},
@ -301,10 +323,10 @@ func TestAuthorizeDomain(t *testing.T) {
{resource: rbac.ResourceWorkspace.WithOwner("not-me")},
// Other org + other use + other id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me").WithID("not-id")},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID("not-id")},
{resource: rbac.ResourceWorkspace.InOrg("other")},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me").WithID("not-id")},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithID("not-id")},
{resource: rbac.ResourceWorkspace.InOrg(unuseID)},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID("not-id")},
{resource: rbac.ResourceWorkspace.WithOwner("not-me")},
@ -321,7 +343,7 @@ func TestAuthorizeDomain(t *testing.T) {
Name: "ReadOnlyOrgAndUser",
Site: []rbac.Permission{},
Org: map[string][]rbac.Permission{
defOrg: {{
defOrg.String(): {{
Negate: false,
ResourceType: "*",
ResourceID: "*",
@ -360,10 +382,10 @@ func TestAuthorizeDomain(t *testing.T) {
{resource: rbac.ResourceWorkspace.All(), allow: false},
// Other org + me + id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID).WithID(wrkID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID(wrkID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID).WithID(wrkID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithID(wrkID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), allow: false},
// Other org + other user + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me").WithID(wrkID), allow: true},
@ -373,10 +395,10 @@ func TestAuthorizeDomain(t *testing.T) {
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), allow: false},
// Other org + other use + other id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), allow: false},
@ -405,10 +427,10 @@ func TestAuthorizeDomain(t *testing.T) {
{resource: rbac.ResourceWorkspace.All()},
// Other org + me + id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg("other")},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg(unuseID)},
// Other org + other user + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me").WithID(wrkID)},
@ -418,10 +440,10 @@ func TestAuthorizeDomain(t *testing.T) {
{resource: rbac.ResourceWorkspace.WithOwner("not-me")},
// Other org + other use + other id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me").WithID("not-id")},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID("not-id")},
{resource: rbac.ResourceWorkspace.InOrg("other")},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me").WithID("not-id")},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithID("not-id")},
{resource: rbac.ResourceWorkspace.InOrg(unuseID)},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID("not-id")},
{resource: rbac.ResourceWorkspace.WithOwner("not-me")},
@ -433,14 +455,27 @@ func TestAuthorizeDomain(t *testing.T) {
// TestAuthorizeLevels ensures level overrides are acting appropriately
//nolint:paralleltest
func TestAuthorizeLevels(t *testing.T) {
defOrg := "default"
defOrg := uuid.New()
unusedID := uuid.New()
wrkID := "1234"
user := subject{
UserID: "me",
Roles: []rbac.Role{
rbac.RoleAdmin,
rbac.RoleOrgDenyAll(defOrg),
must(rbac.RoleByName(rbac.RoleAdmin())),
{
Name: "org-deny:" + defOrg.String(),
Org: map[string][]rbac.Permission{
defOrg.String(): {
{
Negate: true,
ResourceType: "*",
ResourceID: "*",
Action: "*",
},
},
},
},
{
Name: "user-deny-all",
// List out deny permissions explicitly
@ -476,10 +511,10 @@ func TestAuthorizeLevels(t *testing.T) {
{resource: rbac.ResourceWorkspace.All()},
// Other org + me + id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg("other")},
{resource: rbac.ResourceWorkspace.InOrg(unusedID).WithOwner(user.UserID).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg(unusedID).WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.InOrg(unusedID).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg(unusedID)},
// Other org + other user + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me").WithID(wrkID)},
@ -489,10 +524,10 @@ func TestAuthorizeLevels(t *testing.T) {
{resource: rbac.ResourceWorkspace.WithOwner("not-me")},
// Other org + other use + other id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me").WithID("not-id")},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID("not-id")},
{resource: rbac.ResourceWorkspace.InOrg("other")},
{resource: rbac.ResourceWorkspace.InOrg(unusedID).WithOwner("not-me").WithID("not-id")},
{resource: rbac.ResourceWorkspace.InOrg(unusedID).WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.InOrg(unusedID).WithID("not-id")},
{resource: rbac.ResourceWorkspace.InOrg(unusedID)},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID("not-id")},
{resource: rbac.ResourceWorkspace.WithOwner("not-me")},
@ -514,7 +549,7 @@ func TestAuthorizeLevels(t *testing.T) {
},
},
},
rbac.RoleOrgAdmin(defOrg),
must(rbac.RoleByName(rbac.RoleOrgAdmin(defOrg))),
{
Name: "user-deny-all",
// List out deny permissions explicitly
@ -549,10 +584,10 @@ func TestAuthorizeLevels(t *testing.T) {
{resource: rbac.ResourceWorkspace.All(), allow: false},
// Other org + me + id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID).WithID(wrkID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID(wrkID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unusedID).WithOwner(user.UserID).WithID(wrkID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unusedID).WithOwner(user.UserID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unusedID).WithID(wrkID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unusedID), allow: false},
// Other org + other user + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me").WithID(wrkID), allow: true},
@ -562,10 +597,10 @@ func TestAuthorizeLevels(t *testing.T) {
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), allow: false},
// Other org + other use + other id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unusedID).WithOwner("not-me").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unusedID).WithOwner("not-me"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unusedID).WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unusedID), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), allow: false},

190
coderd/rbac/builtin.go Normal file
View File

@ -0,0 +1,190 @@
package rbac
import (
"strings"
"github.com/google/uuid"
"golang.org/x/xerrors"
)
const (
admin string = "admin"
member string = "member"
auditor string = "auditor"
orgAdmin string = "organization-admin"
orgMember string = "organization-member"
)
// The functions below ONLY need to exist for roles that are "defaulted" in some way.
// Any other roles (like auditor), can be listed and let the user select/assigned.
// Once we have a database implementation, the "default" roles can be defined on the
// site and orgs, and these functions can be removed.
func RoleAdmin() string {
return roleName(admin, "")
}
func RoleMember() string {
return roleName(member, "")
}
func RoleOrgAdmin(organizationID uuid.UUID) string {
return roleName(orgAdmin, organizationID.String())
}
func RoleOrgMember(organizationID uuid.UUID) string {
return roleName(orgMember, organizationID.String())
}
var (
// builtInRoles are just a hard coded set for now. Ideally we store these in
// the database. Right now they are functions because the org id should scope
// certain roles. When we store them in the database, each organization should
// create the roles that are assignable in the org. This isn't a hard problem to solve,
// it's just easier as a function right now.
//
// This map will be replaced by database storage defined by this ticket.
// https://github.com/coder/coder/issues/1194
builtInRoles = map[string]func(orgID string) Role{
// admin grants all actions to all resources.
admin: func(_ string) Role {
return Role{
Name: admin,
Site: permissions(map[Object][]Action{
ResourceWildcard: {WildcardSymbol},
}),
}
},
// member grants all actions to all resources owned by the user
member: func(_ string) Role {
return Role{
Name: member,
User: permissions(map[Object][]Action{
ResourceWildcard: {WildcardSymbol},
}),
}
},
// auditor provides all permissions required to effectively read and understand
// audit log events.
// TODO: Finish the auditor as we add resources.
auditor: func(_ string) Role {
return Role{
Name: "auditor",
Site: permissions(map[Object][]Action{
// Should be able to read all template details, even in orgs they
// are not in.
ResourceTemplate: {ActionRead},
}),
}
},
// orgAdmin returns a role with all actions allows in a given
// organization scope.
orgAdmin: func(organizationID string) Role {
return Role{
Name: roleName(orgAdmin, organizationID),
Org: map[string][]Permission{
organizationID: {
{
Negate: false,
ResourceType: "*",
ResourceID: "*",
Action: "*",
},
},
},
}
},
// orgMember has an empty set of permissions, this just implies their membership
// in an organization.
orgMember: func(organizationID string) Role {
return Role{
Name: roleName(orgMember, organizationID),
Org: map[string][]Permission{
organizationID: {},
},
}
},
}
)
// RoleByName returns the permissions associated with a given role name.
// This allows just the role names to be stored and expanded when required.
func RoleByName(name string) (Role, error) {
roleName, orgID, err := roleSplit(name)
if err != nil {
return Role{}, xerrors.Errorf(":%w", err)
}
roleFunc, ok := builtInRoles[roleName]
if !ok {
// No role found
return Role{}, xerrors.Errorf("role %q not found", roleName)
}
// Ensure all org roles are properly scoped a non-empty organization id.
// This is just some defensive programming.
role := roleFunc(orgID)
if len(role.Org) > 0 && orgID == "" {
return Role{}, xerrors.Errorf("expect a org id for role %q", roleName)
}
return role, nil
}
func IsOrgRole(roleName string) (string, bool) {
_, orgID, err := roleSplit(roleName)
if err == nil && orgID != "" {
return orgID, true
}
return "", false
}
// roleName is a quick helper function to return
// role_name:scopeID
// If no scopeID is required, only 'role_name' is returned
func roleName(name string, orgID string) string {
if orgID == "" {
return name
}
return name + ":" + orgID
}
func roleSplit(role string) (name string, orgID string, err error) {
arr := strings.Split(role, ":")
if len(arr) > 2 {
return "", "", xerrors.Errorf("too many colons in role name")
}
if arr[0] == "" {
return "", "", xerrors.Errorf("role cannot be the empty string")
}
if len(arr) == 2 {
return arr[0], arr[1], nil
}
return arr[0], "", nil
}
// permissions is just a helper function to make building roles that list out resources
// and actions a bit easier.
func permissions(perms map[Object][]Action) []Permission {
list := make([]Permission, 0, len(perms))
for k, actions := range perms {
for _, act := range actions {
act := act
list = append(list, Permission{
Negate: false,
ResourceType: k.Type,
ResourceID: WildcardSymbol,
Action: act,
})
}
}
return list
}

View File

@ -0,0 +1,55 @@
package rbac
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/google/uuid"
)
func TestRoleByName(t *testing.T) {
t.Parallel()
t.Run("BuiltIns", func(t *testing.T) {
t.Parallel()
testCases := []struct {
Role Role
}{
{Role: builtInRoles[admin]("")},
{Role: builtInRoles[member]("")},
{Role: builtInRoles[auditor]("")},
{Role: builtInRoles[orgAdmin](uuid.New().String())},
{Role: builtInRoles[orgAdmin](uuid.New().String())},
{Role: builtInRoles[orgAdmin](uuid.New().String())},
{Role: builtInRoles[orgMember](uuid.New().String())},
{Role: builtInRoles[orgMember](uuid.New().String())},
{Role: builtInRoles[orgMember](uuid.New().String())},
}
for _, c := range testCases {
c := c
t.Run(c.Role.Name, func(t *testing.T) {
role, err := RoleByName(c.Role.Name)
require.NoError(t, err, "role exists")
require.Equal(t, c.Role, role)
})
}
})
// nolint:paralleltest
t.Run("Errors", func(t *testing.T) {
var err error
_, err = RoleByName("")
require.Error(t, err, "empty role")
_, err = RoleByName("too:many:colons")
require.Error(t, err, "too many colons")
_, err = RoleByName(orgMember)
require.Error(t, err, "expect orgID")
})
}

View File

@ -0,0 +1,62 @@
package rbac_test
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/rbac"
)
func TestIsOrgRole(t *testing.T) {
t.Parallel()
randomUUID := uuid.New()
testCases := []struct {
RoleName string
OrgRole bool
OrgID string
}{
// Not org roles
{RoleName: rbac.RoleAdmin()},
{RoleName: rbac.RoleMember()},
{RoleName: "auditor"},
{
RoleName: "a:bad:role",
OrgRole: false,
},
{
RoleName: "",
OrgRole: false,
},
// Org roles
{
RoleName: rbac.RoleOrgAdmin(randomUUID),
OrgRole: true,
OrgID: randomUUID.String(),
},
{
RoleName: rbac.RoleOrgMember(randomUUID),
OrgRole: true,
OrgID: randomUUID.String(),
},
{
RoleName: "test:example",
OrgRole: true,
OrgID: "example",
},
}
// nolint:paralleltest
for _, c := range testCases {
t.Run(c.RoleName, func(t *testing.T) {
orgID, ok := rbac.IsOrgRole(c.RoleName)
require.Equal(t, c.OrgRole, ok, "match expected org role")
require.Equal(t, c.OrgID, orgID, "match expected org id")
})
}
}

View File

@ -4,6 +4,8 @@ import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/rbac"
@ -16,14 +18,15 @@ func TestExample(t *testing.T) {
ctx := context.Background()
authorizer, err := rbac.NewAuthorizer()
require.NoError(t, err)
defaultOrg := uuid.New()
// user will become an authn object, and can even be a database.User if it
// fulfills the interface. Until then, use a placeholder.
user := subject{
UserID: "alice",
Roles: []rbac.Role{
rbac.RoleOrgAdmin("default"),
rbac.RoleMember,
must(rbac.RoleByName(rbac.RoleMember())),
must(rbac.RoleByName(rbac.RoleOrgAdmin(defaultOrg))),
},
}
@ -38,17 +41,24 @@ func TestExample(t *testing.T) {
//nolint:paralleltest
t.Run("ReadOrgWorkspaces", func(t *testing.T) {
// To read all workspaces on the org 'default'
err := authorizer.Authorize(ctx, user.UserID, user.Roles, rbac.ActionRead, rbac.ResourceWorkspace.InOrg("default"))
err := authorizer.Authorize(ctx, user.UserID, user.Roles, rbac.ActionRead, rbac.ResourceWorkspace.InOrg(defaultOrg))
require.NoError(t, err, "this user can read all org workspaces in 'default'")
})
//nolint:paralleltest
t.Run("ReadMyWorkspace", func(t *testing.T) {
// Note 'database.Workspace' could fulfill the object interface and be passed in directly
err := authorizer.Authorize(ctx, user.UserID, user.Roles, rbac.ActionRead, rbac.ResourceWorkspace.InOrg("default").WithOwner(user.UserID))
err := authorizer.Authorize(ctx, user.UserID, user.Roles, rbac.ActionRead, rbac.ResourceWorkspace.InOrg(defaultOrg).WithOwner(user.UserID))
require.NoError(t, err, "this user can their workspace")
err = authorizer.Authorize(ctx, user.UserID, user.Roles, rbac.ActionRead, rbac.ResourceWorkspace.InOrg("default").WithOwner(user.UserID).WithID("1234"))
err = authorizer.Authorize(ctx, user.UserID, user.Roles, rbac.ActionRead, rbac.ResourceWorkspace.InOrg(defaultOrg).WithOwner(user.UserID).WithID("1234"))
require.NoError(t, err, "this user can read workspace '1234'")
})
}
func must[T any](value T, err error) T {
if err != nil {
panic(err)
}
return value
}

View File

@ -1,5 +1,9 @@
package rbac
import (
"github.com/google/uuid"
)
const WildcardSymbol = "*"
// Resources are just typed objects. Making resources this way allows directly
@ -46,11 +50,11 @@ func (z Object) All() Object {
}
// InOrg adds an org OwnerID to the resource
func (z Object) InOrg(orgID string) Object {
func (z Object) InOrg(orgID uuid.UUID) Object {
return Object{
ResourceID: z.ResourceID,
Owner: z.Owner,
OrgID: orgID,
OrgID: orgID.String(),
Type: z.Type,
}
}

View File

@ -1,7 +1,6 @@
package rbac
import "fmt"
// Permission is the format passed into the rego.
type Permission struct {
// Negate makes this a negative permission
Negate bool `json:"negate"`
@ -14,122 +13,15 @@ type Permission struct {
// - Site level permissions apply EVERYWHERE
// - Org level permissions apply to EVERYTHING in a given ORG
// - User level permissions are the lowest
// In most cases, you will just want to use the pre-defined roles
// below.
// This is the type passed into the rego as a json payload.
// Users of this package should instead **only** use the role names, and
// this package will expand the role names into their json payloads.
type Role struct {
Name string `json:"name"`
Site []Permission `json:"site"`
// Org is a map of orgid to permissions. We represent orgid as a string.
// We scope the organizations in the role so we can easily combine all the
// roles.
Org map[string][]Permission `json:"org"`
User []Permission `json:"user"`
}
// Roles are stored as structs, so they can be serialized and stored. Until we store them elsewhere,
// const's will do just fine.
var (
// RoleAdmin is a role that allows everything everywhere.
RoleAdmin = Role{
Name: "admin",
Site: permissions(map[Object][]Action{
ResourceWildcard: {WildcardSymbol},
}),
}
// RoleMember is a role that allows access to user-level resources.
RoleMember = Role{
Name: "member",
User: permissions(map[Object][]Action{
ResourceWildcard: {WildcardSymbol},
}),
}
// RoleAuditor is an example on how to give more precise permissions
RoleAuditor = Role{
Name: "auditor",
Site: permissions(map[Object][]Action{
//ResourceAuditLogs: {ActionRead},
// Should be able to read user details to associate with logs.
// Without this the user-id in logs is not very helpful
ResourceWorkspace: {ActionRead},
}),
}
)
func RoleOrgDenyAll(orgID string) Role {
return Role{
Name: "org-deny-" + orgID,
Org: map[string][]Permission{
orgID: {
{
Negate: true,
ResourceType: "*",
ResourceID: "*",
Action: "*",
},
},
},
}
}
// RoleOrgAdmin returns a role with all actions allows in a given
// organization scope.
func RoleOrgAdmin(orgID string) Role {
return Role{
Name: "org-admin-" + orgID,
Org: map[string][]Permission{
orgID: {
{
Negate: false,
ResourceType: "*",
ResourceID: "*",
Action: "*",
},
},
},
}
}
// RoleOrgMember returns a role with default permissions in a given
// organization scope.
func RoleOrgMember(orgID string) Role {
return Role{
Name: "org-member-" + orgID,
Org: map[string][]Permission{
orgID: {},
},
}
}
// RoleWorkspaceAgent returns a role with permission to read a given
// workspace.
func RoleWorkspaceAgent(workspaceID string) Role {
return Role{
Name: fmt.Sprintf("agent-%s", workspaceID),
// This is at the site level to prevent the token from losing access if the user
// is kicked from the org
Site: []Permission{
{
Negate: false,
ResourceType: ResourceWorkspace.Type,
ResourceID: workspaceID,
Action: ActionRead,
},
},
}
}
func permissions(perms map[Object][]Action) []Permission {
list := make([]Permission, 0, len(perms))
for k, actions := range perms {
for _, act := range actions {
act := act
list = append(list, Permission{
Negate: false,
ResourceType: k.Type,
ResourceID: WildcardSymbol,
Action: act,
})
}
}
return list
}

View File

@ -19,6 +19,7 @@ import (
"github.com/coder/coder/coderd/gitsshkey"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/userpassword"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/cryptorand"
@ -82,6 +83,21 @@ func (api *api) postFirstUser(rw http.ResponseWriter, r *http.Request) {
return
}
// TODO: @emyrk this currently happens outside the database tx used to create
// the user. Maybe I add this ability to grant roles in the createUser api
// and add some rbac bypass when calling api functions this way??
// Add the admin role to this first user
_, err = api.Database.UpdateUserRoles(r.Context(), database.UpdateUserRolesParams{
GrantedRoles: []string{rbac.RoleAdmin(), rbac.RoleMember()},
ID: user.ID,
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: err.Error(),
})
return
}
httpapi.Write(rw, http.StatusCreated, codersdk.CreateFirstUserResponse{
UserID: user.ID,
OrganizationID: organizationID,
@ -344,6 +360,88 @@ func (api *api) putUserSuspend(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(rw, http.StatusOK, convertUser(suspendedUser, organizations))
}
func (api *api) userRoles(rw http.ResponseWriter, r *http.Request) {
user := httpmw.UserParam(r)
resp := codersdk.UserRoles{
Roles: user.RBACRoles,
OrganizationRoles: make(map[uuid.UUID][]string),
}
memberships, err := api.Database.GetOrganizationMembershipsByUserID(r.Context(), user.ID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get user memberships: %s", err),
})
return
}
for _, mem := range memberships {
resp.OrganizationRoles[mem.OrganizationID] = mem.Roles
}
httpapi.Write(rw, http.StatusOK, resp)
}
func (api *api) putUserRoles(rw http.ResponseWriter, r *http.Request) {
// User is the user to modify
// TODO: Until rbac authorize is implemented, only be able to change your
// own roles. This also means you can grant yourself whatever roles you want.
user := httpmw.UserParam(r)
apiKey := httpmw.APIKey(r)
if apiKey.UserID != user.ID {
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
Message: fmt.Sprintf("modifying other users is not supported at this time"),
})
return
}
var params codersdk.UpdateRoles
if !httpapi.Read(rw, r, &params) {
return
}
updatedUser, err := api.updateSiteUserRoles(r.Context(), database.UpdateUserRolesParams{
GrantedRoles: params.Roles,
ID: user.ID,
})
if err != nil {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: err.Error(),
})
return
}
organizationIDs, err := userOrganizationIDs(r.Context(), api, user)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get organization IDs: %s", err.Error()),
})
return
}
httpapi.Write(rw, http.StatusOK, convertUser(updatedUser, organizationIDs))
}
func (api *api) updateSiteUserRoles(ctx context.Context, 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 := api.Database.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.
func (api *api) organizationsByUser(rw http.ResponseWriter, r *http.Request) {
user := httpmw.UserParam(r)
@ -440,7 +538,11 @@ func (api *api) postOrganizationsByUser(rw http.ResponseWriter, r *http.Request)
UserID: user.ID,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
Roles: []string{"organization-admin"},
Roles: []string{
// Also assign member role incase they get demoted from admin
rbac.RoleOrgMember(organization.ID),
rbac.RoleOrgAdmin(organization.ID),
},
})
if err != nil {
return xerrors.Errorf("create organization member: %w", err)
@ -604,6 +706,7 @@ func (api *api) createAPIKey(rw http.ResponseWriter, r *http.Request, params dat
func (api *api) createUser(ctx context.Context, req codersdk.CreateUserRequest) (database.User, uuid.UUID, error) {
var user database.User
return user, req.OrganizationID, api.Database.InTx(func(db database.Store) error {
var orgRoles []string
// If no organization is provided, create a new one for the user.
if req.OrganizationID == uuid.Nil {
organization, err := db.InsertOrganization(ctx, database.InsertOrganizationParams{
@ -616,7 +719,10 @@ func (api *api) createUser(ctx context.Context, req codersdk.CreateUserRequest)
return xerrors.Errorf("create organization: %w", err)
}
req.OrganizationID = organization.ID
orgRoles = append(orgRoles, rbac.RoleOrgAdmin(req.OrganizationID))
}
// Always also be a member
orgRoles = append(orgRoles, rbac.RoleOrgMember(req.OrganizationID))
params := database.InsertUserParams{
ID: uuid.New(),
@ -624,6 +730,8 @@ func (api *api) createUser(ctx context.Context, req codersdk.CreateUserRequest)
Username: req.Username,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
// All new users are defaulted to members of the site.
RBACRoles: []string{rbac.RoleMember()},
}
// If a user signs up with OAuth, they can have no password!
if req.Password != "" {
@ -659,7 +767,8 @@ func (api *api) createUser(ctx context.Context, req codersdk.CreateUserRequest)
UserID: user.ID,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
Roles: []string{},
// By default give them membership to the organization
Roles: orgRoles,
})
if err != nil {
return xerrors.Errorf("create organization member: %w", err)

View File

@ -12,6 +12,7 @@ import (
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/codersdk"
)
@ -286,6 +287,118 @@ func TestUpdateUserProfile(t *testing.T) {
})
}
func TestGrantRoles(t *testing.T) {
t.Parallel()
t.Run("UpdateIncorrectRoles", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
admin := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, admin)
member := coderdtest.CreateAnotherUser(t, admin, first.OrganizationID)
_, err := admin.UpdateUserRoles(ctx, codersdk.Me, codersdk.UpdateRoles{
Roles: []string{rbac.RoleOrgMember(first.OrganizationID)},
})
require.Error(t, err, "org role in site")
_, err = admin.UpdateUserRoles(ctx, uuid.New(), codersdk.UpdateRoles{
Roles: []string{rbac.RoleOrgMember(first.OrganizationID)},
})
require.Error(t, err, "user does not exist")
_, err = admin.UpdateOrganizationMemberRoles(ctx, first.OrganizationID, codersdk.Me, codersdk.UpdateRoles{
Roles: []string{rbac.RoleMember()},
})
require.Error(t, err, "site role in org")
_, err = admin.UpdateOrganizationMemberRoles(ctx, uuid.New(), codersdk.Me, codersdk.UpdateRoles{
Roles: []string{rbac.RoleMember()},
})
require.Error(t, err, "role in org without membership")
_, err = member.UpdateUserRoles(ctx, first.UserID, codersdk.UpdateRoles{
Roles: []string{rbac.RoleMember()},
})
require.Error(t, err, "member cannot change other's roles")
_, err = member.UpdateOrganizationMemberRoles(ctx, first.OrganizationID, first.UserID, codersdk.UpdateRoles{
Roles: []string{rbac.RoleMember()},
})
require.Error(t, err, "member cannot change other's org roles")
})
t.Run("FirstUserRoles", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
client := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, client)
roles, err := client.GetUserRoles(ctx, codersdk.Me)
require.NoError(t, err)
require.ElementsMatch(t, roles.Roles, []string{
rbac.RoleAdmin(),
rbac.RoleMember(),
}, "should be a member and admin")
require.ElementsMatch(t, roles.OrganizationRoles[first.OrganizationID], []string{
rbac.RoleOrgMember(first.OrganizationID),
rbac.RoleOrgAdmin(first.OrganizationID),
}, "should be a member and admin")
})
t.Run("GrantAdmin", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
admin := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, admin)
member := coderdtest.CreateAnotherUser(t, admin, first.OrganizationID)
roles, err := member.GetUserRoles(ctx, codersdk.Me)
require.NoError(t, err)
require.ElementsMatch(t, roles.Roles, []string{
rbac.RoleMember(),
}, "should be a member and admin")
require.ElementsMatch(t,
roles.OrganizationRoles[first.OrganizationID],
[]string{rbac.RoleOrgMember(first.OrganizationID)},
)
// Grant
// TODO: @emyrk this should be 'admin.UpdateUserRoles' once proper authz
// is enforced.
_, err = member.UpdateUserRoles(ctx, codersdk.Me, codersdk.UpdateRoles{
Roles: []string{
// Promote to site admin
rbac.RoleMember(),
rbac.RoleAdmin(),
},
})
require.NoError(t, err, "grant member admin role")
// Promote to org admin
_, err = member.UpdateOrganizationMemberRoles(ctx, first.OrganizationID, codersdk.Me, codersdk.UpdateRoles{
Roles: []string{
// Promote to org admin
rbac.RoleOrgMember(first.OrganizationID),
rbac.RoleOrgAdmin(first.OrganizationID),
},
})
require.NoError(t, err, "grant member org admin role")
roles, err = member.GetUserRoles(ctx, codersdk.Me)
require.NoError(t, err)
require.ElementsMatch(t, roles.Roles, []string{
rbac.RoleMember(),
rbac.RoleAdmin(),
}, "should be a member and admin")
require.ElementsMatch(t, roles.OrganizationRoles[first.OrganizationID], []string{
rbac.RoleOrgMember(first.OrganizationID),
rbac.RoleOrgAdmin(first.OrganizationID),
}, "should be a member and admin")
})
}
func TestPutUserSuspend(t *testing.T) {
t.Parallel()

View File

@ -0,0 +1,15 @@
package codersdk
import (
"time"
"github.com/google/uuid"
)
type OrganizationMember struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Roles []string `db:"roles" json:"roles"`
}

View File

@ -72,6 +72,15 @@ type UpdateUserProfileRequest struct {
Username string `json:"username" validate:"required,username"`
}
type UpdateRoles struct {
Roles []string `json:"roles" validate:"required"`
}
type UserRoles struct {
Roles []string `json:"roles"`
OrganizationRoles map[uuid.UUID][]string `json:"organization_roles"`
}
// LoginWithPasswordRequest enables callers to authenticate with email and password.
type LoginWithPasswordRequest struct {
Email string `json:"email" validate:"required,email"`
@ -172,6 +181,50 @@ func (c *Client) SuspendUser(ctx context.Context, userID uuid.UUID) (User, error
return user, json.NewDecoder(res.Body).Decode(&user)
}
// UpdateUserRoles grants the userID the specified roles.
// Include ALL roles the user has.
func (c *Client) UpdateUserRoles(ctx context.Context, userID uuid.UUID, req UpdateRoles) (User, error) {
res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/roles", uuidOrMe(userID)), req)
if err != nil {
return User{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return User{}, readBodyAsError(res)
}
var user User
return user, json.NewDecoder(res.Body).Decode(&user)
}
// UpdateOrganizationMemberRoles grants the userID the specified roles in an org.
// Include ALL roles the user has.
func (c *Client) UpdateOrganizationMemberRoles(ctx context.Context, organizationID, userID uuid.UUID, req UpdateRoles) (User, error) {
res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/organizations/%s/members/%s/roles", organizationID, uuidOrMe(userID)), req)
if err != nil {
return User{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return User{}, readBodyAsError(res)
}
var user User
return user, json.NewDecoder(res.Body).Decode(&user)
}
// GetUserRoles returns all roles the user has
func (c *Client) GetUserRoles(ctx context.Context, userID uuid.UUID) (UserRoles, error) {
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/roles", uuidOrMe(userID)), nil)
if err != nil {
return UserRoles{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserRoles{}, readBodyAsError(res)
}
var roles UserRoles
return roles, json.NewDecoder(res.Body).Decode(&roles)
}
// CreateAPIKey generates an API key for the user ID provided.
func (c *Client) CreateAPIKey(ctx context.Context, userID uuid.UUID) (*GenerateAPIKeyResponse, error) {
res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys", uuidOrMe(userID)), nil)

View File

@ -12,7 +12,7 @@ export interface AgentGitSSHKey {
readonly private_key: string
}
// From codersdk/users.go:96:6
// From codersdk/users.go:105:6
export interface AuthMethods {
readonly password: boolean
readonly github: boolean
@ -44,7 +44,7 @@ export interface CreateFirstUserResponse {
readonly organization_id: string
}
// From codersdk/users.go:91:6
// From codersdk/users.go:100:6
export interface CreateOrganizationRequest {
readonly name: string
}
@ -100,7 +100,7 @@ export interface CreateWorkspaceRequest {
readonly parameter_values: CreateParameterRequest[]
}
// From codersdk/users.go:87:6
// From codersdk/users.go:96:6
export interface GenerateAPIKeyResponse {
readonly key: string
}
@ -118,13 +118,13 @@ export interface GoogleInstanceIdentityToken {
readonly json_web_token: string
}
// From codersdk/users.go:76:6
// From codersdk/users.go:85:6
export interface LoginWithPasswordRequest {
readonly email: string
readonly password: string
}
// From codersdk/users.go:82:6
// From codersdk/users.go:91:6
export interface LoginWithPasswordResponse {
readonly session_token: string
}
@ -137,6 +137,15 @@ export interface Organization {
readonly updated_at: string
}
// From codersdk/organizationmember.go:9:6
export interface OrganizationMember {
readonly user_id: string
readonly organization_id: string
readonly created_at: string
readonly updated_at: string
readonly roles: string[]
}
// From codersdk/parameters.go:26:6
export interface Parameter {
readonly id: string
@ -245,6 +254,11 @@ export interface UpdateActiveTemplateVersion {
readonly id: string
}
// From codersdk/users.go:75:6
export interface UpdateRoles {
readonly roles: string[]
}
// From codersdk/users.go:70:6
export interface UpdateUserProfileRequest {
readonly email: string
@ -276,6 +290,12 @@ export interface User {
readonly organization_ids: string[]
}
// From codersdk/users.go:79:6
export interface UserRoles {
readonly roles: string[]
readonly organization_roles: Record<string, string[]>
}
// From codersdk/users.go:24:6
export interface UsersRequest {
readonly after_user: string