diff --git a/coderd/audit/table.go b/coderd/audit/table.go index fe6e4dda7e043..6e9db7fec9899 100644 --- a/coderd/audit/table.go +++ b/coderd/audit/table.go @@ -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. diff --git a/coderd/coderd.go b/coderd/coderd.go index 6f255826088c4..f06dc36f137bc 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 44dd7f043467b..4095e613480ec 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -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() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index c536883997a51..3e63572f89962 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -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 ( diff --git a/coderd/database/migrations/000008_rbac_roles.down.sql b/coderd/database/migrations/000008_rbac_roles.down.sql new file mode 100644 index 0000000000000..981559af10f0f --- /dev/null +++ b/coderd/database/migrations/000008_rbac_roles.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE ONLY users + DROP COLUMN IF EXISTS rbac_roles; \ No newline at end of file diff --git a/coderd/database/migrations/000008_rbac_roles.up.sql b/coderd/database/migrations/000008_rbac_roles.up.sql new file mode 100644 index 0000000000000..adc7d72b24c20 --- /dev/null +++ b/coderd/database/migrations/000008_rbac_roles.up.sql @@ -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) diff --git a/coderd/database/models.go b/coderd/database/models.go index 99f7622c5ae71..b1f521acda963 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -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 { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 304e012e24e88..24c459d9d3744 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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 diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b378e70cf8fbe..0fc5c7c267821 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 } diff --git a/coderd/database/queries/organizationmembers.sql b/coderd/database/queries/organizationmembers.sql index 27c41ed53f577..34d1d8635c711 100644 --- a/coderd/database/queries/organizationmembers.sql +++ b/coderd/database/queries/organizationmembers.sql @@ -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 *; \ No newline at end of file diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index c97858bcb6ebe..aa94117ec2aff 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -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 * diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index abde7029c3c79..290a30faa551b 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -28,3 +28,4 @@ rename: parameter_type_system_hcl: ParameterTypeSystemHCL userstatus: UserStatus gitsshkey: GitSSHKey + rbac_roles: RBACRoles diff --git a/coderd/members.go b/coderd/members.go new file mode 100644 index 0000000000000..cff26042619ea --- /dev/null +++ b/coderd/members.go @@ -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, ¶ms) { + 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, + } +} diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index f1ae3335cc4d8..f11508ad2b099 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -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{ diff --git a/coderd/rbac/authz_test.go b/coderd/rbac/authz_test.go index c37b3d0f83a10..3e797b238e982 100644 --- a/coderd/rbac/authz_test.go +++ b/coderd/rbac/authz_test.go @@ -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}, diff --git a/coderd/rbac/builtin.go b/coderd/rbac/builtin.go new file mode 100644 index 0000000000000..4d659e6e8e39f --- /dev/null +++ b/coderd/rbac/builtin.go @@ -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 +} diff --git a/coderd/rbac/builtin_internal_test.go b/coderd/rbac/builtin_internal_test.go new file mode 100644 index 0000000000000..a5374dcc37547 --- /dev/null +++ b/coderd/rbac/builtin_internal_test.go @@ -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") + }) +} diff --git a/coderd/rbac/builtin_test.go b/coderd/rbac/builtin_test.go new file mode 100644 index 0000000000000..849179dc78893 --- /dev/null +++ b/coderd/rbac/builtin_test.go @@ -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") + }) + } +} diff --git a/coderd/rbac/example_test.go b/coderd/rbac/example_test.go index e9c4222f9c4a9..3d5847a0f5635 100644 --- a/coderd/rbac/example_test.go +++ b/coderd/rbac/example_test.go @@ -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 +} diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index c8b7c94f1d9dc..1e86165f24cf4 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -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, } } diff --git a/coderd/rbac/role.go b/coderd/rbac/role.go index 4788a189e6363..a083b2a11ac80 100644 --- a/coderd/rbac/role.go +++ b/coderd/rbac/role.go @@ -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 -} diff --git a/coderd/users.go b/coderd/users.go index f0cd923e94363..310bee8e79597 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -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, ¶ms) { + 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) diff --git a/coderd/users_test.go b/coderd/users_test.go index c7f84f15588e2..536c716b36f57 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -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() diff --git a/codersdk/organizationmember.go b/codersdk/organizationmember.go new file mode 100644 index 0000000000000..0a49c45d7abd0 --- /dev/null +++ b/codersdk/organizationmember.go @@ -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"` +} diff --git a/codersdk/users.go b/codersdk/users.go index 0bb8c3eee54f5..a2608c8bdec3a 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -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) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c74514f3da3b0..7247a81d9b412 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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 +} + // From codersdk/users.go:24:6 export interface UsersRequest { readonly after_user: string