From 9d531cbbe04feac4b6132e6a01cae69c7abbfd4e Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 25 Apr 2022 13:14:12 -0500 Subject: [PATCH 01/20] WIP --- coderd/httpmw/rbac.go | 9 ++++++ coderd/rbac/builtin.go | 61 +++++++++++++++++++++++++++++++++++++ coderd/rbac/example_test.go | 11 +++++-- coderd/rbac/role.go | 7 +++-- 4 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 coderd/httpmw/rbac.go create mode 100644 coderd/rbac/builtin.go diff --git a/coderd/httpmw/rbac.go b/coderd/httpmw/rbac.go new file mode 100644 index 0000000000000..63377cd4ca7fb --- /dev/null +++ b/coderd/httpmw/rbac.go @@ -0,0 +1,9 @@ +package httpmw + +//func Can(action rbac.Action, object rbac.Object) func(http.Handler) http.Handler { +// return func(next http.Handler) http.Handler { +// return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { +// +// }, +// } +//} diff --git a/coderd/rbac/builtin.go b/coderd/rbac/builtin.go new file mode 100644 index 0000000000000..c867004de807d --- /dev/null +++ b/coderd/rbac/builtin.go @@ -0,0 +1,61 @@ +package rbac + +import ( + "strings" + + "golang.org/x/xerrors" +) + +const ( + Admin = "admin" + Member = "member" + + OrganizationMember = "organization-member" + OrganizationAdmin = "organization-admin" +) + +// RoleByName returns the permissions associated with a given role name. +// This allows just the role names to be stored. +func RoleByName(name string) (Role, error) { + arr := strings.Split(name, ":") + if len(arr) > 2 { + return Role{}, xerrors.Errorf("too many semicolons in role name") + } + + roleName := arr[0] + var scopeID string + if len(arr) > 1 { + scopeID = arr[1] + } + + // If the role requires a scope, the scope will be checked at the end + // of the switch statement. + var scopedRole Role + switch roleName { + case Admin: + return RoleAdmin, nil + case Member: + return RoleMember, nil + case OrganizationMember: + scopedRole = RoleOrgMember(scopeID) + case OrganizationAdmin: + scopedRole = RoleOrgAdmin(scopeID) + default: + // No role found + return Role{}, xerrors.Errorf("role %q not found", roleName) + } + + // Scoped roles should be checked their scope is set + if scopeID == "" { + return Role{}, xerrors.Errorf("%q requires a scope id", roleName) + } + + return scopedRole, nil +} + +func RoleName(name string, scopeID string) string { + if scopeID == "" { + return name + } + return name + ":" + scopeID +} diff --git a/coderd/rbac/example_test.go b/coderd/rbac/example_test.go index e9c4222f9c4a9..5baf4d950360c 100644 --- a/coderd/rbac/example_test.go +++ b/coderd/rbac/example_test.go @@ -22,8 +22,8 @@ func TestExample(t *testing.T) { user := subject{ UserID: "alice", Roles: []rbac.Role{ - rbac.RoleOrgAdmin("default"), - rbac.RoleMember, + must(rbac.RoleByName(rbac.Member)), + must(rbac.RoleByName(rbac.RoleName(rbac.OrganizationMember, "default"))), }, } @@ -52,3 +52,10 @@ func TestExample(t *testing.T) { 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/role.go b/coderd/rbac/role.go index 4788a189e6363..08023a54a289e 100644 --- a/coderd/rbac/role.go +++ b/coderd/rbac/role.go @@ -2,6 +2,7 @@ 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"` @@ -57,7 +58,7 @@ var ( func RoleOrgDenyAll(orgID string) Role { return Role{ - Name: "org-deny-" + orgID, + Name: RoleName("org-deny", orgID), Org: map[string][]Permission{ orgID: { { @@ -75,7 +76,7 @@ func RoleOrgDenyAll(orgID string) Role { // organization scope. func RoleOrgAdmin(orgID string) Role { return Role{ - Name: "org-admin-" + orgID, + Name: RoleName("org-admin:", orgID) Org: map[string][]Permission{ orgID: { { @@ -93,7 +94,7 @@ func RoleOrgAdmin(orgID string) Role { // organization scope. func RoleOrgMember(orgID string) Role { return Role{ - Name: "org-member-" + orgID, + Name: RoleName("org-member:" , orgID), Org: map[string][]Permission{ orgID: {}, }, From 1eabb07a56f3f558254742015e9d828af8399e05 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 26 Apr 2022 21:12:00 -0500 Subject: [PATCH 02/20] chore: Rework roles to be expandable by name alone --- coderd/rbac/authz_test.go | 53 +++++++++--- coderd/rbac/builtin.go | 163 +++++++++++++++++++++++++++++++----- coderd/rbac/example_test.go | 4 +- coderd/rbac/role.go | 121 ++------------------------ 4 files changed, 190 insertions(+), 151 deletions(-) diff --git a/coderd/rbac/authz_test.go b/coderd/rbac/authz_test.go index c37b3d0f83a10..b4b8c18271480 100644 --- a/coderd/rbac/authz_test.go +++ b/coderd/rbac/authz_test.go @@ -3,6 +3,7 @@ package rbac_test import ( "context" "encoding/json" + "fmt" "testing" "golang.org/x/xerrors" @@ -14,8 +15,11 @@ 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. @@ -26,7 +30,10 @@ func TestAuthorizeDomain(t *testing.T) { 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{ @@ -126,8 +133,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())), }, } @@ -173,8 +180,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())), }, } @@ -221,7 +228,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, + }, + }, + }, }, } @@ -439,8 +458,20 @@ func TestAuthorizeLevels(t *testing.T) { user := subject{ UserID: "me", Roles: []rbac.Role{ - rbac.RoleAdmin, - rbac.RoleOrgDenyAll(defOrg), + must(rbac.RoleByName(rbac.RoleAdmin())), + { + Name: "org-deny" + defOrg, + Org: map[string][]rbac.Permission{ + defOrg: { + { + Negate: true, + ResourceType: "*", + ResourceID: "*", + Action: "*", + }, + }, + }, + }, { Name: "user-deny-all", // List out deny permissions explicitly @@ -514,7 +545,7 @@ func TestAuthorizeLevels(t *testing.T) { }, }, }, - rbac.RoleOrgAdmin(defOrg), + must(rbac.RoleByName(rbac.RoleOrgAdmin(defOrg))), { Name: "user-deny-all", // List out deny permissions explicitly diff --git a/coderd/rbac/builtin.go b/coderd/rbac/builtin.go index c867004de807d..abad54e03477f 100644 --- a/coderd/rbac/builtin.go +++ b/coderd/rbac/builtin.go @@ -7,15 +7,113 @@ import ( ) const ( - Admin = "admin" - Member = "member" + admin string = "admin" + member string = "member" + auditor string = "auditor" - OrganizationMember = "organization-member" - OrganizationAdmin = "organization-admin" + orgAdmin string = "organization-admin" + orgMember string = "organization-member" +) + +// RoleName is a string that represents a registered rbac role. We want to store +// strings in the database to allow the underlying role permissions to be migrated +// or modified. +// All RoleNames should have an entry in 'builtInRoles'. +// We use functions to retrieve the name incase we need to add a scope. +type RoleName = string + +func RoleAdmin() string { + return roleName(admin, "") +} + +func RoleMember() string { + return roleName(member, "") +} + +func RoleOrgAdmin(organizationID string) RoleName { + return roleName(orgAdmin, organizationID) +} + +func RoleOrgMember(organizationID string) RoleName { + return roleName(orgMember, organizationID) +} + +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. If we store them in the database, we will need to store + // them such that the "org" permissions are dynamically changed by the + // scopeID passed in. This isn't a hard problem to solve, it's just easier + // as a function right now. + builtInRoles = map[string]func(scopeID 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{ + //ResourceAuditLogs: {ActionRead}, + // 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. +// This allows just the role names to be stored and expanded when required. func RoleByName(name string) (Role, error) { arr := strings.Split(name, ":") if len(arr) > 2 { @@ -28,34 +126,53 @@ func RoleByName(name string) (Role, error) { scopeID = arr[1] } - // If the role requires a scope, the scope will be checked at the end - // of the switch statement. - var scopedRole Role - switch roleName { - case Admin: - return RoleAdmin, nil - case Member: - return RoleMember, nil - case OrganizationMember: - scopedRole = RoleOrgMember(scopeID) - case OrganizationAdmin: - scopedRole = RoleOrgAdmin(scopeID) - default: + roleFunc, ok := builtInRoles[roleName] + if !ok { // No role found return Role{}, xerrors.Errorf("role %q not found", roleName) } - // Scoped roles should be checked their scope is set - if scopeID == "" { - return Role{}, xerrors.Errorf("%q requires a scope id", roleName) + // Ensure all org roles are properly scoped a non-empty organization id. + // This is just some defensive programming. + role := roleFunc(scopeID) + if len(role.Org) > 0 && scopeID == "" { + return Role{}, xerrors.Errorf("expect a scope id for role %q", roleName) } - return scopedRole, nil + return role, nil } -func RoleName(name string, scopeID string) string { +// 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, scopeID string) string { if scopeID == "" { return name } return name + ":" + scopeID } + +// 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 +} + +func must[T any](value T, err error) T { + if err != nil { + panic(err) + } + return value +} diff --git a/coderd/rbac/example_test.go b/coderd/rbac/example_test.go index 5baf4d950360c..8230b13cac943 100644 --- a/coderd/rbac/example_test.go +++ b/coderd/rbac/example_test.go @@ -22,8 +22,8 @@ func TestExample(t *testing.T) { user := subject{ UserID: "alice", Roles: []rbac.Role{ - must(rbac.RoleByName(rbac.Member)), - must(rbac.RoleByName(rbac.RoleName(rbac.OrganizationMember, "default"))), + must(rbac.RoleByName(rbac.RoleMember())), + must(rbac.RoleByName(rbac.RoleOrgAdmin("default"))), }, } diff --git a/coderd/rbac/role.go b/coderd/rbac/role.go index 08023a54a289e..362d11bf952b9 100644 --- a/coderd/rbac/role.go +++ b/coderd/rbac/role.go @@ -1,7 +1,5 @@ package rbac -import "fmt" - // Permission is the format passed into the rego. type Permission struct { // Negate makes this a negative permission @@ -15,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"` + Name RoleName `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: RoleName("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: RoleName("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: RoleName("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 -} From dc6010b476d3231f52c2fb3155bbb2ba2398e6a4 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 26 Apr 2022 21:30:42 -0500 Subject: [PATCH 03/20] Add role migrations --- .../migrations/000007_rbac_user_roles.down.sql | 2 ++ .../migrations/000007_rbac_user_roles.up.sql | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 coderd/database/migrations/000007_rbac_user_roles.down.sql create mode 100644 coderd/database/migrations/000007_rbac_user_roles.up.sql diff --git a/coderd/database/migrations/000007_rbac_user_roles.down.sql b/coderd/database/migrations/000007_rbac_user_roles.down.sql new file mode 100644 index 0000000000000..327561894eab6 --- /dev/null +++ b/coderd/database/migrations/000007_rbac_user_roles.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE ONLY workspaces + DROP COLUMN IF EXISTS rbac_roles; \ No newline at end of file diff --git a/coderd/database/migrations/000007_rbac_user_roles.up.sql b/coderd/database/migrations/000007_rbac_user_roles.up.sql new file mode 100644 index 0000000000000..adc7d72b24c20 --- /dev/null +++ b/coderd/database/migrations/000007_rbac_user_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) From af7fcf1d51748745818e39717d5c28973fe997fe Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 26 Apr 2022 21:32:19 -0500 Subject: [PATCH 04/20] Make gen --- coderd/database/dump.sql | 7 +--- coderd/database/models.go | 10 ++++++ coderd/database/queries.sql.go | 40 +++++++++++++++++++++ peerbroker/proto/peerbroker_drpc.pb.go | 2 +- provisionerd/proto/provisionerd_drpc.pb.go | 2 +- provisionersdk/proto/provisioner_drpc.pb.go | 2 +- 6 files changed, 54 insertions(+), 9 deletions(-) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index c536883997a51..57bdc39e43064 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -226,8 +226,7 @@ CREATE TABLE users ( username text DEFAULT ''::text NOT NULL, 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 + updated_at timestamp with time zone NOT NULL ); CREATE TABLE workspace_agents ( @@ -278,7 +277,6 @@ CREATE TABLE workspaces ( created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, owner_id uuid NOT NULL, - organization_id uuid NOT NULL, template_id uuid NOT NULL, deleted boolean DEFAULT false NOT NULL, name character varying(64) NOT NULL, @@ -430,9 +428,6 @@ ALTER TABLE ONLY workspace_builds ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; -ALTER TABLE ONLY workspaces - ADD CONSTRAINT workspaces_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT; - ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT; diff --git a/coderd/database/models.go b/coderd/database/models.go index 99f7622c5ae71..718a63bca8f01 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -391,6 +391,7 @@ type TemplateVersion struct { } type User struct { +<<<<<<< HEAD ID uuid.UUID `db:"id" json:"id"` Email string `db:"email" json:"email"` Username string `db:"username" json:"username"` @@ -398,6 +399,15 @@ 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"` +======= + ID uuid.UUID `db:"id" json:"id"` + Email string `db:"email" json:"email"` + Username string `db:"username" json:"username"` + 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"` +>>>>>>> Make gen } type Workspace struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 5f0574812b79c..4c583844708b4 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1782,7 +1782,11 @@ func (q *sqlQuerier) UpdateTemplateVersionByID(ctx context.Context, arg UpdateTe const getUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one SELECT +<<<<<<< HEAD id, email, username, hashed_password, created_at, updated_at, status +======= + id, email, username, hashed_password, created_at, updated_at, rbac_roles +>>>>>>> Make gen FROM users WHERE @@ -1807,14 +1811,22 @@ func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserBy &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, +<<<<<<< HEAD &i.Status, +======= + pq.Array(&i.RbacRoles), +>>>>>>> Make gen ) return i, err } const getUserByID = `-- name: GetUserByID :one SELECT +<<<<<<< HEAD id, email, username, hashed_password, created_at, updated_at, status +======= + id, email, username, hashed_password, created_at, updated_at, rbac_roles +>>>>>>> Make gen FROM users WHERE @@ -1833,7 +1845,11 @@ func (q *sqlQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (User, error &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, +<<<<<<< HEAD &i.Status, +======= + pq.Array(&i.RbacRoles), +>>>>>>> Make gen ) return i, err } @@ -1854,7 +1870,11 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context) (int64, error) { const getUsers = `-- name: GetUsers :many SELECT +<<<<<<< HEAD id, email, username, hashed_password, created_at, updated_at, status +======= + id, email, username, hashed_password, created_at, updated_at, rbac_roles +>>>>>>> Make gen FROM users WHERE @@ -1924,7 +1944,11 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]User, &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, +<<<<<<< HEAD &i.Status, +======= + pq.Array(&i.RbacRoles), +>>>>>>> Make gen ); err != nil { return nil, err } @@ -1950,7 +1974,11 @@ INSERT INTO updated_at ) VALUES +<<<<<<< HEAD ($1, $2, $3, $4, $5, $6) RETURNING id, email, username, hashed_password, created_at, updated_at, status +======= + ($1, $2, $3, $4, $5, $6) RETURNING id, email, username, hashed_password, created_at, updated_at, rbac_roles +>>>>>>> Make gen ` type InsertUserParams struct { @@ -1979,7 +2007,11 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, +<<<<<<< HEAD &i.Status, +======= + pq.Array(&i.RbacRoles), +>>>>>>> Make gen ) return i, err } @@ -1992,7 +2024,11 @@ SET username = $3, updated_at = $4 WHERE +<<<<<<< HEAD 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, rbac_roles +>>>>>>> Make gen ` type UpdateUserProfileParams struct { @@ -2017,6 +2053,7 @@ func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfil &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, +<<<<<<< HEAD &i.Status, ) return i, err @@ -2049,6 +2086,9 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP &i.CreatedAt, &i.UpdatedAt, &i.Status, +======= + pq.Array(&i.RbacRoles), +>>>>>>> Make gen ) return i, err } diff --git a/peerbroker/proto/peerbroker_drpc.pb.go b/peerbroker/proto/peerbroker_drpc.pb.go index ae06f79a01371..a31d184cbd639 100644 --- a/peerbroker/proto/peerbroker_drpc.pb.go +++ b/peerbroker/proto/peerbroker_drpc.pb.go @@ -1,5 +1,5 @@ // Code generated by protoc-gen-go-drpc. DO NOT EDIT. -// protoc-gen-go-drpc version: v0.0.26 +// protoc-gen-go-drpc version: v0.0.30 // source: peerbroker/proto/peerbroker.proto package proto diff --git a/provisionerd/proto/provisionerd_drpc.pb.go b/provisionerd/proto/provisionerd_drpc.pb.go index 646f855eabc70..c583c320c90e2 100644 --- a/provisionerd/proto/provisionerd_drpc.pb.go +++ b/provisionerd/proto/provisionerd_drpc.pb.go @@ -1,5 +1,5 @@ // Code generated by protoc-gen-go-drpc. DO NOT EDIT. -// protoc-gen-go-drpc version: v0.0.26 +// protoc-gen-go-drpc version: v0.0.30 // source: provisionerd/proto/provisionerd.proto package proto diff --git a/provisionersdk/proto/provisioner_drpc.pb.go b/provisionersdk/proto/provisioner_drpc.pb.go index c990f6f645b7f..0dba37415265b 100644 --- a/provisionersdk/proto/provisioner_drpc.pb.go +++ b/provisionersdk/proto/provisioner_drpc.pb.go @@ -1,5 +1,5 @@ // Code generated by protoc-gen-go-drpc. DO NOT EDIT. -// protoc-gen-go-drpc version: v0.0.26 +// protoc-gen-go-drpc version: v0.0.30 // source: provisionersdk/proto/provisioner.proto package proto From 5d478653b138b4224bb3e2b2963a948f19dffd36 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 26 Apr 2022 21:46:52 -0500 Subject: [PATCH 05/20] Add admin role to the first user --- coderd/database/querier.go | 7 +++--- coderd/database/queries.sql.go | 40 ++++++++++++++++++++++++++++++- coderd/database/queries/users.sql | 15 ++++++++++-- coderd/rbac/builtin.go | 9 +++---- coderd/users.go | 22 ++++++++++++++++- 5 files changed, 81 insertions(+), 12 deletions(-) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 3819eb3608934..00b7013bbfbd5 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -51,13 +51,13 @@ type querier interface { GetWorkspaceBuildByWorkspaceIDWithoutAfter(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) GetWorkspaceBuildsByWorkspaceIDsWithoutAfter(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Workspace, error) - GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWorkspaceByOwnerIDAndNameParams) (Workspace, error) + GetWorkspaceByUserIDAndName(ctx context.Context, arg GetWorkspaceByUserIDAndNameParams) (Workspace, error) GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, ids []uuid.UUID) ([]GetWorkspaceOwnerCountsByTemplateIDsRow, error) GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error) GetWorkspaceResourcesByJobID(ctx context.Context, jobID uuid.UUID) ([]WorkspaceResource, error) - GetWorkspacesByOrganizationID(ctx context.Context, arg GetWorkspacesByOrganizationIDParams) ([]Workspace, error) - GetWorkspacesByOwnerID(ctx context.Context, arg GetWorkspacesByOwnerIDParams) ([]Workspace, error) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorkspacesByTemplateIDParams) ([]Workspace, error) + GetWorkspacesByUserID(ctx context.Context, arg GetWorkspacesByUserIDParams) ([]Workspace, error) + GrantUserRole(ctx context.Context, arg GrantUserRoleParams) (User, error) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) InsertFile(ctx context.Context, arg InsertFileParams) (File, error) InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error) @@ -85,7 +85,6 @@ 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) - UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error UpdateWorkspaceAutostop(ctx context.Context, arg UpdateWorkspaceAutostopParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 4c583844708b4..b1670b2c160d5 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1963,6 +1963,37 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]User, return items, nil } +const grantUserRole = `-- name: GrantUserRole :one +UPDATE + users +SET + -- Append new roles and remove duplicates just to keep things clean. + rbac_roles = ARRAY(SELECT DISTINCT UNNEST(rbac_roles || $1 :: text[])) +WHERE + id = $2 +RETURNING id, email, username, hashed_password, created_at, updated_at, rbac_roles +` + +type GrantUserRoleParams struct { + GrantedRoles []string `db:"granted_roles" json:"granted_roles"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) GrantUserRole(ctx context.Context, arg GrantUserRoleParams) (User, error) { + row := q.db.QueryRowContext(ctx, grantUserRole, pq.Array(arg.GrantedRoles), arg.ID) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.Username, + &i.HashedPassword, + &i.CreatedAt, + &i.UpdatedAt, + pq.Array(&i.RbacRoles), + ) + return i, err +} + const insertUser = `-- name: InsertUser :one INSERT INTO users ( @@ -1971,14 +2002,19 @@ INSERT INTO username, hashed_password, created_at, - updated_at + updated_at, + rbac_roles ) VALUES +<<<<<<< HEAD <<<<<<< HEAD ($1, $2, $3, $4, $5, $6) RETURNING id, email, username, hashed_password, created_at, updated_at, status ======= ($1, $2, $3, $4, $5, $6) RETURNING id, email, username, hashed_password, created_at, updated_at, rbac_roles >>>>>>> Make gen +======= + ($1, $2, $3, $4, $5, $6, $7) RETURNING id, email, username, hashed_password, created_at, updated_at, rbac_roles +>>>>>>> Add admin role to the first user ` type InsertUserParams struct { @@ -1988,6 +2024,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) { @@ -1998,6 +2035,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( diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index bf99df6a3f785..cc3e5807f2e7c 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: GrantUserRole :one +UPDATE + users +SET + -- Append new roles and remove duplicates just to keep things clean. + rbac_roles = ARRAY(SELECT DISTINCT UNNEST(rbac_roles || @granted_roles :: text[])) +WHERE + id = @id +RETURNING *; + -- name: GetUsers :many SELECT * diff --git a/coderd/rbac/builtin.go b/coderd/rbac/builtin.go index abad54e03477f..9b253a7ef3e3f 100644 --- a/coderd/rbac/builtin.go +++ b/coderd/rbac/builtin.go @@ -1,6 +1,7 @@ package rbac import ( + "github.com/google/uuid" "strings" "golang.org/x/xerrors" @@ -30,12 +31,12 @@ func RoleMember() string { return roleName(member, "") } -func RoleOrgAdmin(organizationID string) RoleName { - return roleName(orgAdmin, organizationID) +func RoleOrgAdmin(organizationID uuid.UUID) RoleName { + return roleName(orgAdmin, organizationID.String()) } -func RoleOrgMember(organizationID string) RoleName { - return roleName(orgMember, organizationID) +func RoleOrgMember(organizationID uuid.UUID) RoleName { + return roleName(orgMember, organizationID.String()) } var ( diff --git a/coderd/users.go b/coderd/users.go index a38af8ba12f63..997e2952b5e7b 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -10,6 +10,8 @@ import ( "strconv" "time" + "github.com/coder/coder/coderd/rbac" + "github.com/go-chi/chi/v5" "github.com/go-chi/render" "github.com/google/uuid" @@ -82,6 +84,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.GrantUserRole(r.Context(), database.GrantUserRoleParams{ + GrantedRoles: []string{rbac.RoleAdmin()}, + 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, @@ -582,6 +599,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 != "" { @@ -617,7 +636,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: []string{rbac.RoleOrgMember(req.OrganizationID)}, }) if err != nil { return xerrors.Errorf("create organization member: %w", err) From d8a5ba1a22b71922a44d0aa4d057f9b62dd95a9c Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 27 Apr 2022 10:08:47 -0500 Subject: [PATCH 06/20] Implement builtin roles --- coderd/rbac/authz_test.go | 4 +- coderd/rbac/builtin.go | 90 +++++++++++++++++++++++++++++++++------ 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/coderd/rbac/authz_test.go b/coderd/rbac/authz_test.go index b4b8c18271480..69dfdf890b032 100644 --- a/coderd/rbac/authz_test.go +++ b/coderd/rbac/authz_test.go @@ -6,6 +6,8 @@ import ( "fmt" "testing" + "github.com/google/uuid" + "golang.org/x/xerrors" "github.com/stretchr/testify/require" @@ -25,7 +27,7 @@ type subject struct { // TestAuthorizeDomain test the very basic roles that are commonly used. func TestAuthorizeDomain(t *testing.T) { t.Parallel() - defOrg := "default" + defOrg := uuid.New() wrkID := "1234" user := subject{ diff --git a/coderd/rbac/builtin.go b/coderd/rbac/builtin.go index 9b253a7ef3e3f..cb39b303a6c74 100644 --- a/coderd/rbac/builtin.go +++ b/coderd/rbac/builtin.go @@ -12,8 +12,9 @@ const ( member string = "member" auditor string = "auditor" - orgAdmin string = "organization-admin" - orgMember string = "organization-member" + orgAdmin string = "organization-admin" + orgMember string = "organization-member" + orgManager string = "organization-manager" ) // RoleName is a string that represents a registered rbac role. We want to store @@ -23,11 +24,16 @@ const ( // We use functions to retrieve the name incase we need to add a scope. type RoleName = string -func RoleAdmin() string { +// 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 be be defined on the +// site and orgs, and these functions can be removed. + +func RoleAdmin() RoleName { return roleName(admin, "") } -func RoleMember() string { +func RoleMember() RoleName { return roleName(member, "") } @@ -35,6 +41,7 @@ func RoleOrgAdmin(organizationID uuid.UUID) RoleName { return roleName(orgAdmin, organizationID.String()) } +// member:uuid func RoleOrgMember(organizationID uuid.UUID) RoleName { return roleName(orgMember, organizationID.String()) } @@ -46,6 +53,9 @@ var ( // them such that the "org" permissions are dynamically changed by the // scopeID passed in. 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(scopeID string) Role{ // admin grants all actions to all resources. admin: func(_ string) Role { @@ -110,21 +120,63 @@ var ( }, } }, + + orgManager: func(organizationID string) Role { + return Role{ + Name: roleName(orgMember, organizationID), + Org: map[string][]Permission{ + organizationID: permissions(map[Object][]Action{ + ResourceWorkspace: {WildcardSymbol}, + }), + }, + } + }, } ) +// ListOrgRoles lists all roles that can be applied to an organization user +// in the given organization. +// Note: This should be a list in a database, but until then we build +// the list from the builtins. +func ListOrgRoles(organizationID uuid.UUID) []string { + var roles []string + for role, _ := range builtInRoles { + _, scope, err := roleSplit(role) + if err != nil { + // This should never happen + continue + } + if scope == organizationID.String() { + roles = append(roles, role) + } + } + return roles +} + +// ListSiteRoles lists all roles that can be applied to a user. +// Note: This should be a list in a database, but until then we build +// the list from the builtins. +func ListSiteRoles() []string { + var roles []string + for role, _ := range builtInRoles { + _, scope, err := roleSplit(role) + if err != nil { + // This should never happen + continue + } + if scope == "" { + roles = append(roles, role) + } + } + return roles +} + // 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) { - arr := strings.Split(name, ":") - if len(arr) > 2 { - return Role{}, xerrors.Errorf("too many semicolons in role name") - } - - roleName := arr[0] - var scopeID string - if len(arr) > 1 { - scopeID = arr[1] + roleName, scopeID, err := roleSplit(name) + if err != nil { + return Role{}, xerrors.Errorf(":%w", err) } roleFunc, ok := builtInRoles[roleName] @@ -153,6 +205,18 @@ func roleName(name string, scopeID string) string { return name + ":" + scopeID } +func roleSplit(role string) (name string, scopeID string, err error) { + arr := strings.Split(name, ":") + if len(arr) > 2 { + return "", "", xerrors.Errorf("too many colons in role name") + } + + 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 { From e8b2e33318246ef5f71ae70072bc7e4b6fcf26f2 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 27 Apr 2022 14:53:11 -0500 Subject: [PATCH 07/20] Add fake for GrantRoles --- coderd/database/databasefake/databasefake.go | 30 ++++ coderd/rbac/authz.go | 17 ++ coderd/rbac/authz_test.go | 170 ++++++++++--------- coderd/rbac/builtin.go | 6 +- coderd/rbac/example_test.go | 10 +- coderd/rbac/object.go | 8 +- 6 files changed, 150 insertions(+), 91 deletions(-) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index b48ede661296c..a21541849a96c 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -1144,6 +1144,36 @@ func (q *fakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam return user, nil } +func (q *fakeQuerier) GrantUserRole(ctx context.Context, arg database.GrantUserRoleParams) (database.User, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, user := range q.users { + if user.ID != arg.ID { + continue + } + + // Append the new roles + user.RbacRoles = append(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/rbac/authz.go b/coderd/rbac/authz.go index f1ae3335cc4d8..6c71f4f3d1ba2 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 []RoleName, 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 69dfdf890b032..3e797b238e982 100644 --- a/coderd/rbac/authz_test.go +++ b/coderd/rbac/authz_test.go @@ -28,6 +28,7 @@ type subject struct { func TestAuthorizeDomain(t *testing.T) { t.Parallel() defOrg := uuid.New() + unuseID := uuid.New() wrkID := "1234" user := subject{ @@ -53,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}, @@ -66,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}, @@ -108,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}, @@ -121,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}, @@ -155,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}, @@ -168,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}, @@ -202,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}, @@ -215,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}, @@ -266,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}, @@ -279,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}, @@ -309,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)}, @@ -322,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")}, @@ -342,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: "*", @@ -381,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}, @@ -394,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}, @@ -426,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)}, @@ -439,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")}, @@ -454,7 +455,8 @@ 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{ @@ -462,9 +464,9 @@ func TestAuthorizeLevels(t *testing.T) { Roles: []rbac.Role{ must(rbac.RoleByName(rbac.RoleAdmin())), { - Name: "org-deny" + defOrg, + Name: "org-deny:" + defOrg.String(), Org: map[string][]rbac.Permission{ - defOrg: { + defOrg.String(): { { Negate: true, ResourceType: "*", @@ -509,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)}, @@ -522,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")}, @@ -582,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}, @@ -595,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 index cb39b303a6c74..e945e9038fa8a 100644 --- a/coderd/rbac/builtin.go +++ b/coderd/rbac/builtin.go @@ -206,11 +206,15 @@ func roleName(name string, scopeID string) string { } func roleSplit(role string) (name string, scopeID string, err error) { - arr := strings.Split(name, ":") + 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 } diff --git a/coderd/rbac/example_test.go b/coderd/rbac/example_test.go index 8230b13cac943..092fbad967961 100644 --- a/coderd/rbac/example_test.go +++ b/coderd/rbac/example_test.go @@ -2,6 +2,7 @@ package rbac_test import ( "context" + "github.com/google/uuid" "testing" "github.com/stretchr/testify/require" @@ -16,6 +17,7 @@ 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. @@ -23,7 +25,7 @@ func TestExample(t *testing.T) { UserID: "alice", Roles: []rbac.Role{ must(rbac.RoleByName(rbac.RoleMember())), - must(rbac.RoleByName(rbac.RoleOrgAdmin("default"))), + must(rbac.RoleByName(rbac.RoleOrgAdmin(defaultOrg))), }, } @@ -38,17 +40,17 @@ 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'") }) } 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, } } From eae626caf4c97da5e1d536b279f5676f4b449a65 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 27 Apr 2022 14:57:17 -0500 Subject: [PATCH 08/20] Correct indentation on sql --- coderd/database/queries/users.sql | 2 +- coderd/httpmw/rbac.go | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) delete mode 100644 coderd/httpmw/rbac.go diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index cc3e5807f2e7c..0bcc81a76bb30 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -56,7 +56,7 @@ SET -- Append new roles and remove duplicates just to keep things clean. rbac_roles = ARRAY(SELECT DISTINCT UNNEST(rbac_roles || @granted_roles :: text[])) WHERE - id = @id + id = @id RETURNING *; -- name: GetUsers :many diff --git a/coderd/httpmw/rbac.go b/coderd/httpmw/rbac.go deleted file mode 100644 index 63377cd4ca7fb..0000000000000 --- a/coderd/httpmw/rbac.go +++ /dev/null @@ -1,9 +0,0 @@ -package httpmw - -//func Can(action rbac.Action, object rbac.Object) func(http.Handler) http.Handler { -// return func(next http.Handler) http.Handler { -// return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { -// -// }, -// } -//} From 5aba5a2396ecaec2c6b8a8df14847f307c2d4ea2 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 27 Apr 2022 15:41:35 -0500 Subject: [PATCH 09/20] Add endpoints to grant new roles to a user Currently can't remove roles. So be careful --- coderd/coderd.go | 5 +- coderd/database/databasefake/databasefake.go | 45 +++++------ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 40 +++++++++- .../database/queries/organizationmembers.sql | 9 +++ coderd/rbac/builtin.go | 25 ++---- coderd/users.go | 80 ++++++++++++++++++- coderd/users_test.go | 57 +++++++++++++ codersdk/users.go | 37 +++++++++ site/src/api/typesGenerated.ts | 8 ++ 10 files changed, 259 insertions(+), 48 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 6f255826088c4..b501363f06f8e 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -182,7 +182,10 @@ func New(options *Options) (http.Handler, func()) { r.Use(httpmw.ExtractUserParam(options.Database)) 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.getUserRoles) 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 a21541849a96c..f1cf9769e472f 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -709,6 +709,21 @@ func (q *fakeQuerier) GetOrganizationMemberByUserID(_ context.Context, arg datab return database.OrganizationMember{}, sql.ErrNoRows } +func (q *fakeQuerier) GetOrganizationMembershipsByUserID(ctx 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) GetProvisionerDaemons(_ context.Context) ([]database.ProvisionerDaemon, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -1139,6 +1154,7 @@ 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 @@ -1190,35 +1206,18 @@ func (q *fakeQuerier) UpdateUserProfile(_ context.Context, arg database.UpdateUs return database.User{}, sql.ErrNoRows } -func (q *fakeQuerier) UpdateUserStatus(_ context.Context, arg database.UpdateUserStatusParams) (database.User, error) { - q.mutex.Lock() - defer q.mutex.Unlock() - - for index, user := range q.users { - if user.ID != arg.ID { - continue - } - user.Status = arg.Status - user.UpdatedAt = arg.UpdatedAt - q.users[index] = user - return user, nil - } - return database.User{}, sql.ErrNoRows -} - func (q *fakeQuerier) InsertWorkspace(_ context.Context, arg database.InsertWorkspaceParams) (database.Workspace, error) { q.mutex.Lock() defer q.mutex.Unlock() //nolint:gosimple workspace := database.Workspace{ - ID: arg.ID, - CreatedAt: arg.CreatedAt, - UpdatedAt: arg.UpdatedAt, - OwnerID: arg.OwnerID, - OrganizationID: arg.OrganizationID, - TemplateID: arg.TemplateID, - Name: arg.Name, + ID: arg.ID, + CreatedAt: arg.CreatedAt, + UpdatedAt: arg.UpdatedAt, + OwnerID: arg.OwnerID, + TemplateID: arg.TemplateID, + Name: arg.Name, } q.workspaces = append(q.workspaces, workspace) return workspace, nil diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 00b7013bbfbd5..0cf42280f4af5 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -18,6 +18,7 @@ type querier interface { GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error) GetOrganizationByName(ctx context.Context, name string) (Organization, 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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b1670b2c160d5..216b237e65cb3 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -336,6 +336,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 ( @@ -1970,7 +2008,7 @@ SET -- Append new roles and remove duplicates just to keep things clean. rbac_roles = ARRAY(SELECT DISTINCT UNNEST(rbac_roles || $1 :: text[])) WHERE - id = $2 + id = $2 RETURNING id, email, username, hashed_password, created_at, updated_at, rbac_roles ` diff --git a/coderd/database/queries/organizationmembers.sql b/coderd/database/queries/organizationmembers.sql index 243bdc26c9878..cc3bfac9c48cb 100644 --- a/coderd/database/queries/organizationmembers.sql +++ b/coderd/database/queries/organizationmembers.sql @@ -20,3 +20,12 @@ INSERT INTO ) VALUES ($1, $2, $3, $4, $5) RETURNING *; + + +-- name: GetOrganizationMembershipsByUserID :many +SELECT + * +FROM + organization_members +WHERE + user_id = $1; \ No newline at end of file diff --git a/coderd/rbac/builtin.go b/coderd/rbac/builtin.go index e945e9038fa8a..f1eae9e705bf7 100644 --- a/coderd/rbac/builtin.go +++ b/coderd/rbac/builtin.go @@ -12,9 +12,8 @@ const ( member string = "member" auditor string = "auditor" - orgAdmin string = "organization-admin" - orgMember string = "organization-member" - orgManager string = "organization-manager" + orgAdmin string = "organization-admin" + orgMember string = "organization-member" ) // RoleName is a string that represents a registered rbac role. We want to store @@ -26,7 +25,7 @@ type RoleName = string // 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 be be defined on the +// 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() RoleName { @@ -49,10 +48,9 @@ func RoleOrgMember(organizationID uuid.UUID) RoleName { 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. If we store them in the database, we will need to store - // them such that the "org" permissions are dynamically changed by the - // scopeID passed in. This isn't a hard problem to solve, it's just easier - // as a function right now. + // 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 @@ -120,17 +118,6 @@ var ( }, } }, - - orgManager: func(organizationID string) Role { - return Role{ - Name: roleName(orgMember, organizationID), - Org: map[string][]Permission{ - organizationID: permissions(map[Object][]Action{ - ResourceWorkspace: {WildcardSymbol}, - }), - }, - } - }, } ) diff --git a/coderd/users.go b/coderd/users.go index 997e2952b5e7b..2a55e5b0f9e57 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -4,23 +4,24 @@ import ( "context" "crypto/sha256" "database/sql" + "encoding/json" "errors" "fmt" "net/http" "strconv" "time" - "github.com/coder/coder/coderd/rbac" - "github.com/go-chi/chi/v5" "github.com/go-chi/render" "github.com/google/uuid" + "github.com/moby/moby/pkg/namesgenerator" "golang.org/x/xerrors" "github.com/coder/coder/coderd/database" "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" @@ -317,6 +318,69 @@ func (api *api) putUserSuspend(rw http.ResponseWriter, r *http.Request) { httpapi.Write(rw, http.StatusOK, convertUser(suspendedUser)) } +func (api *api) getUserRoles(rw http.ResponseWriter, r *http.Request) { + user := httpmw.UserParam(r) + + resp := codersdk.UserRoles{ + Roles: user.RbacRoles, + } + + 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.Roles = append(resp.Roles, 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.GrantUserRoles + if !httpapi.Read(rw, r, ¶ms) { + return + } + + for _, r := range params.Roles { + if _, err := rbac.RoleByName(r); err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("%q is not a supported role", r), + }) + return + } + } + + updatedUser, err := api.Database.GrantUserRole(r.Context(), database.GrantUserRoleParams{ + GrantedRoles: params.Roles, + ID: user.ID, + }) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get user: %s", err), + }) + return + } + + httpapi.Write(rw, http.StatusOK, convertUser(updatedUser)) +} + // Returns organizations the parameterized user has access to. func (api *api) organizationsByUser(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) @@ -413,7 +477,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) @@ -579,6 +647,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{ @@ -591,7 +660,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(), @@ -637,7 +709,7 @@ func (api *api) createUser(ctx context.Context, req codersdk.CreateUserRequest) CreatedAt: database.Now(), UpdatedAt: database.Now(), // By default give them membership to the organization - Roles: []string{rbac.RoleOrgMember(req.OrganizationID)}, + 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 8ee4db1c6f80e..f98327d835ca4 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -7,6 +7,8 @@ import ( "sort" "testing" + "github.com/coder/coder/coderd/rbac" + "github.com/google/uuid" "github.com/stretchr/testify/require" @@ -286,6 +288,61 @@ func TestUpdateUserProfile(t *testing.T) { }) } +func TestGrantRoles(t *testing.T) { + t.Parallel() + 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(), + 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(), + rbac.RoleOrgMember(first.OrganizationID), + }, "should be a member and admin") + + // Grant + // TODO: @emyrk this should be 'admin.GrantUserRoles' once proper authz + // is enforced. + _, err = member.GrantUserRoles(ctx, codersdk.Me, codersdk.GrantUserRoles{ + Roles: []string{ + rbac.RoleAdmin(), + rbac.RoleOrgAdmin(first.OrganizationID), + }, + }) + require.NoError(t, err, "grant member admin role") + + roles, err = member.GetUserRoles(ctx, codersdk.Me) + require.NoError(t, err) + require.ElementsMatch(t, roles.Roles, []string{ + rbac.RoleMember(), + rbac.RoleOrgMember(first.OrganizationID), + + rbac.RoleAdmin(), + rbac.RoleOrgAdmin(first.OrganizationID), + }, "should be a member and admin") + }) +} + func TestPutUserSuspend(t *testing.T) { t.Parallel() diff --git a/codersdk/users.go b/codersdk/users.go index 38506f03cd192..cab2b7e7b2aa9 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -69,6 +69,14 @@ type UpdateUserProfileRequest struct { Username string `json:"username" validate:"required,username"` } +type GrantUserRoles struct { + Roles []string `json:"roles" validate:"required"` +} + +type UserRoles struct { + Roles []string `json:"roles"` +} + // LoginWithPasswordRequest enables callers to authenticate with email and password. type LoginWithPasswordRequest struct { Email string `json:"email" validate:"required,email"` @@ -168,6 +176,35 @@ func (c *Client) SuspendUser(ctx context.Context, userID uuid.UUID) (User, error return user, json.NewDecoder(res.Body).Decode(&user) } +// GrantUserRoles grants the userID the specified roles +func (c *Client) GrantUserRoles(ctx context.Context, userID uuid.UUID, req GrantUserRoles) (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) +} + +// 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 268d1f837c347..d8439d6c51ed0 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -133,6 +133,14 @@ export interface CreateOrganizationRequest { } // From codersdk/users.go:93:6. +<<<<<<< HEAD +======= +export interface CreateWorkspaceRequest { + readonly name: string +} + +// From codersdk/users.go:102:6. +>>>>>>> Add endpoints to grant new roles to a user export interface AuthMethods { readonly password: boolean readonly github: boolean From 5b902de9679fb599cebe06c5ae088e8fb5862723 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 27 Apr 2022 15:58:09 -0500 Subject: [PATCH 10/20] Make gen after rebase --- coderd/database/dump.sql | 8 ++- ...wn.sql => 000008_rbac_user_roles.down.sql} | 0 ...s.up.sql => 000008_rbac_user_roles.up.sql} | 0 coderd/database/models.go | 11 +--- coderd/database/querier.go | 6 +- coderd/database/queries.sql.go | 55 +++---------------- site/src/api/typesGenerated.ts | 18 ++---- 7 files changed, 26 insertions(+), 72 deletions(-) rename coderd/database/migrations/{000007_rbac_user_roles.down.sql => 000008_rbac_user_roles.down.sql} (100%) rename coderd/database/migrations/{000007_rbac_user_roles.up.sql => 000008_rbac_user_roles.up.sql} (100%) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 57bdc39e43064..3e63572f89962 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -226,7 +226,9 @@ CREATE TABLE users ( username text DEFAULT ''::text NOT NULL, hashed_password bytea NOT NULL, created_at timestamp with time zone NOT NULL, - updated_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, + rbac_roles text[] DEFAULT '{}'::text[] NOT NULL ); CREATE TABLE workspace_agents ( @@ -277,6 +279,7 @@ CREATE TABLE workspaces ( created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, owner_id uuid NOT NULL, + organization_id uuid NOT NULL, template_id uuid NOT NULL, deleted boolean DEFAULT false NOT NULL, name character varying(64) NOT NULL, @@ -428,6 +431,9 @@ ALTER TABLE ONLY workspace_builds ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; +ALTER TABLE ONLY workspaces + ADD CONSTRAINT workspaces_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT; + ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT; diff --git a/coderd/database/migrations/000007_rbac_user_roles.down.sql b/coderd/database/migrations/000008_rbac_user_roles.down.sql similarity index 100% rename from coderd/database/migrations/000007_rbac_user_roles.down.sql rename to coderd/database/migrations/000008_rbac_user_roles.down.sql diff --git a/coderd/database/migrations/000007_rbac_user_roles.up.sql b/coderd/database/migrations/000008_rbac_user_roles.up.sql similarity index 100% rename from coderd/database/migrations/000007_rbac_user_roles.up.sql rename to coderd/database/migrations/000008_rbac_user_roles.up.sql diff --git a/coderd/database/models.go b/coderd/database/models.go index 718a63bca8f01..18016f6810183 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -391,7 +391,6 @@ type TemplateVersion struct { } type User struct { -<<<<<<< HEAD ID uuid.UUID `db:"id" json:"id"` Email string `db:"email" json:"email"` Username string `db:"username" json:"username"` @@ -399,15 +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"` -======= - ID uuid.UUID `db:"id" json:"id"` - Email string `db:"email" json:"email"` - Username string `db:"username" json:"username"` - 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"` ->>>>>>> Make gen + RbacRoles []string `db:"rbac_roles" json:"rbac_roles"` } type Workspace struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 0cf42280f4af5..f06c0689ccf29 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -52,12 +52,13 @@ type querier interface { GetWorkspaceBuildByWorkspaceIDWithoutAfter(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) GetWorkspaceBuildsByWorkspaceIDsWithoutAfter(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Workspace, error) - GetWorkspaceByUserIDAndName(ctx context.Context, arg GetWorkspaceByUserIDAndNameParams) (Workspace, error) + GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWorkspaceByOwnerIDAndNameParams) (Workspace, error) GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, ids []uuid.UUID) ([]GetWorkspaceOwnerCountsByTemplateIDsRow, error) GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error) GetWorkspaceResourcesByJobID(ctx context.Context, jobID uuid.UUID) ([]WorkspaceResource, error) + GetWorkspacesByOrganizationID(ctx context.Context, arg GetWorkspacesByOrganizationIDParams) ([]Workspace, error) + GetWorkspacesByOwnerID(ctx context.Context, arg GetWorkspacesByOwnerIDParams) ([]Workspace, error) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorkspacesByTemplateIDParams) ([]Workspace, error) - GetWorkspacesByUserID(ctx context.Context, arg GetWorkspacesByUserIDParams) ([]Workspace, error) GrantUserRole(ctx context.Context, arg GrantUserRoleParams) (User, error) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) InsertFile(ctx context.Context, arg InsertFileParams) (File, error) @@ -86,6 +87,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) + UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error UpdateWorkspaceAutostop(ctx context.Context, arg UpdateWorkspaceAutostopParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 216b237e65cb3..51c322ba5ea6e 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1820,11 +1820,7 @@ func (q *sqlQuerier) UpdateTemplateVersionByID(ctx context.Context, arg UpdateTe const getUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one SELECT -<<<<<<< HEAD - id, email, username, hashed_password, created_at, updated_at, status -======= - id, email, username, hashed_password, created_at, updated_at, rbac_roles ->>>>>>> Make gen + id, email, username, hashed_password, created_at, updated_at, status, rbac_roles FROM users WHERE @@ -1849,22 +1845,15 @@ func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserBy &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, -<<<<<<< HEAD &i.Status, -======= pq.Array(&i.RbacRoles), ->>>>>>> Make gen ) return i, err } const getUserByID = `-- name: GetUserByID :one SELECT -<<<<<<< HEAD - id, email, username, hashed_password, created_at, updated_at, status -======= - id, email, username, hashed_password, created_at, updated_at, rbac_roles ->>>>>>> Make gen + id, email, username, hashed_password, created_at, updated_at, status, rbac_roles FROM users WHERE @@ -1883,11 +1872,8 @@ func (q *sqlQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (User, error &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, -<<<<<<< HEAD &i.Status, -======= pq.Array(&i.RbacRoles), ->>>>>>> Make gen ) return i, err } @@ -1908,11 +1894,7 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context) (int64, error) { const getUsers = `-- name: GetUsers :many SELECT -<<<<<<< HEAD - id, email, username, hashed_password, created_at, updated_at, status -======= - id, email, username, hashed_password, created_at, updated_at, rbac_roles ->>>>>>> Make gen + id, email, username, hashed_password, created_at, updated_at, status, rbac_roles FROM users WHERE @@ -1982,11 +1964,8 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]User, &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, -<<<<<<< HEAD &i.Status, -======= pq.Array(&i.RbacRoles), ->>>>>>> Make gen ); err != nil { return nil, err } @@ -2009,7 +1988,7 @@ SET rbac_roles = ARRAY(SELECT DISTINCT UNNEST(rbac_roles || $1 :: text[])) WHERE id = $2 -RETURNING id, email, username, hashed_password, created_at, updated_at, rbac_roles +RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles ` type GrantUserRoleParams struct { @@ -2027,6 +2006,7 @@ func (q *sqlQuerier) GrantUserRole(ctx context.Context, arg GrantUserRoleParams) &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, + &i.Status, pq.Array(&i.RbacRoles), ) return i, err @@ -2044,15 +2024,7 @@ INSERT INTO rbac_roles ) VALUES -<<<<<<< HEAD -<<<<<<< HEAD - ($1, $2, $3, $4, $5, $6) RETURNING id, email, username, hashed_password, created_at, updated_at, status -======= - ($1, $2, $3, $4, $5, $6) RETURNING id, email, username, hashed_password, created_at, updated_at, rbac_roles ->>>>>>> Make gen -======= - ($1, $2, $3, $4, $5, $6, $7) RETURNING id, email, username, hashed_password, created_at, updated_at, rbac_roles ->>>>>>> Add admin role to the first user + ($1, $2, $3, $4, $5, $6, $7) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles ` type InsertUserParams struct { @@ -2083,11 +2055,8 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, -<<<<<<< HEAD &i.Status, -======= pq.Array(&i.RbacRoles), ->>>>>>> Make gen ) return i, err } @@ -2100,11 +2069,7 @@ SET username = $3, updated_at = $4 WHERE -<<<<<<< HEAD - 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, rbac_roles ->>>>>>> Make gen + id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles ` type UpdateUserProfileParams struct { @@ -2129,8 +2094,8 @@ func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfil &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, -<<<<<<< HEAD &i.Status, + pq.Array(&i.RbacRoles), ) return i, err } @@ -2142,7 +2107,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 { @@ -2162,9 +2127,7 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP &i.CreatedAt, &i.UpdatedAt, &i.Status, -======= pq.Array(&i.RbacRoles), ->>>>>>> Make gen ) return i, err } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d8439d6c51ed0..d6bada6ea9151 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -111,36 +111,28 @@ export interface UpdateUserProfileRequest { readonly username: string } -// From codersdk/users.go:73:6. +// From codersdk/users.go:81:6. export interface LoginWithPasswordRequest { readonly email: string readonly password: string } -// From codersdk/users.go:79:6. +// From codersdk/users.go:87:6. export interface LoginWithPasswordResponse { readonly session_token: string } -// From codersdk/users.go:84:6. +// From codersdk/users.go:92:6. export interface GenerateAPIKeyResponse { readonly key: string } -// From codersdk/users.go:88:6. +// From codersdk/users.go:96:6. export interface CreateOrganizationRequest { readonly name: string } -// From codersdk/users.go:93:6. -<<<<<<< HEAD -======= -export interface CreateWorkspaceRequest { - readonly name: string -} - -// From codersdk/users.go:102:6. ->>>>>>> Add endpoints to grant new roles to a user +// From codersdk/users.go:101:6. export interface AuthMethods { readonly password: boolean readonly github: boolean From 7a32c38332a798ab9e1aaecc53ed79dcbd1bb776 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 27 Apr 2022 16:01:05 -0500 Subject: [PATCH 11/20] re insert code lost in merge --- coderd/coderd.go | 1 + coderd/database/databasefake/databasefake.go | 13 +++++++------ coderd/users_test.go | 3 +-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index b501363f06f8e..e4b36ef893b35 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -182,6 +182,7 @@ func New(options *Options) (http.Handler, func()) { r.Use(httpmw.ExtractUserParam(options.Database)) 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) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index f1cf9769e472f..de7b79efe790f 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -1212,12 +1212,13 @@ func (q *fakeQuerier) InsertWorkspace(_ context.Context, arg database.InsertWork //nolint:gosimple workspace := database.Workspace{ - ID: arg.ID, - CreatedAt: arg.CreatedAt, - UpdatedAt: arg.UpdatedAt, - OwnerID: arg.OwnerID, - TemplateID: arg.TemplateID, - Name: arg.Name, + ID: arg.ID, + CreatedAt: arg.CreatedAt, + UpdatedAt: arg.UpdatedAt, + OwnerID: arg.OwnerID, + OrganizationID: arg.OrganizationID, + TemplateID: arg.TemplateID, + Name: arg.Name, } q.workspaces = append(q.workspaces, workspace) return workspace, nil diff --git a/coderd/users_test.go b/coderd/users_test.go index f98327d835ca4..ba5fc4dc6d96c 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -7,13 +7,12 @@ import ( "sort" "testing" - "github.com/coder/coder/coderd/rbac" - "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/codersdk" ) From 3b183173dcd2f8d4bab693c1f5b5f06bc1a81afd Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 27 Apr 2022 19:24:41 -0500 Subject: [PATCH 12/20] Appease the linter --- coderd/coderd.go | 2 +- coderd/database/databasefake/databasefake.go | 20 ++++++++++++++++++-- coderd/rbac/builtin.go | 14 ++++---------- coderd/rbac/example_test.go | 3 ++- coderd/users.go | 4 +--- codersdk/users.go | 1 - 6 files changed, 26 insertions(+), 18 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index e4b36ef893b35..2caf9128646ea 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -186,7 +186,7 @@ func New(options *Options) (http.Handler, func()) { // 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.getUserRoles) + 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 de7b79efe790f..c8ffa97227c8a 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -709,7 +709,7 @@ func (q *fakeQuerier) GetOrganizationMemberByUserID(_ context.Context, arg datab return database.OrganizationMember{}, sql.ErrNoRows } -func (q *fakeQuerier) GetOrganizationMembershipsByUserID(ctx context.Context, userID uuid.UUID) ([]database.OrganizationMember, error) { +func (q *fakeQuerier) GetOrganizationMembershipsByUserID(_ context.Context, userID uuid.UUID) ([]database.OrganizationMember, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -1160,7 +1160,7 @@ func (q *fakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam return user, nil } -func (q *fakeQuerier) GrantUserRole(ctx context.Context, arg database.GrantUserRoleParams) (database.User, error) { +func (q *fakeQuerier) GrantUserRole(_ context.Context, arg database.GrantUserRoleParams) (database.User, error) { q.mutex.Lock() defer q.mutex.Unlock() @@ -1190,6 +1190,22 @@ func (q *fakeQuerier) GrantUserRole(ctx context.Context, arg database.GrantUserR return database.User{}, sql.ErrNoRows } +func (q *fakeQuerier) UpdateUserStatus(_ context.Context, arg database.UpdateUserStatusParams) (database.User, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, user := range q.users { + if user.ID != arg.ID { + continue + } + user.Status = arg.Status + user.UpdatedAt = arg.UpdatedAt + 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/rbac/builtin.go b/coderd/rbac/builtin.go index f1eae9e705bf7..0c12e3a8520be 100644 --- a/coderd/rbac/builtin.go +++ b/coderd/rbac/builtin.go @@ -1,9 +1,10 @@ package rbac import ( - "github.com/google/uuid" "strings" + "github.com/google/uuid" + "golang.org/x/xerrors" ) @@ -127,7 +128,7 @@ var ( // the list from the builtins. func ListOrgRoles(organizationID uuid.UUID) []string { var roles []string - for role, _ := range builtInRoles { + for role := range builtInRoles { _, scope, err := roleSplit(role) if err != nil { // This should never happen @@ -145,7 +146,7 @@ func ListOrgRoles(organizationID uuid.UUID) []string { // the list from the builtins. func ListSiteRoles() []string { var roles []string - for role, _ := range builtInRoles { + for role := range builtInRoles { _, scope, err := roleSplit(role) if err != nil { // This should never happen @@ -225,10 +226,3 @@ func permissions(perms map[Object][]Action) []Permission { } return list } - -func must[T any](value T, err error) T { - if err != nil { - panic(err) - } - return value -} diff --git a/coderd/rbac/example_test.go b/coderd/rbac/example_test.go index 092fbad967961..3d5847a0f5635 100644 --- a/coderd/rbac/example_test.go +++ b/coderd/rbac/example_test.go @@ -2,9 +2,10 @@ package rbac_test import ( "context" - "github.com/google/uuid" "testing" + "github.com/google/uuid" + "github.com/stretchr/testify/require" "github.com/coder/coder/coderd/rbac" diff --git a/coderd/users.go b/coderd/users.go index 2a55e5b0f9e57..24a7e3f47c5f6 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -4,7 +4,6 @@ import ( "context" "crypto/sha256" "database/sql" - "encoding/json" "errors" "fmt" "net/http" @@ -14,7 +13,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/render" "github.com/google/uuid" - "github.com/moby/moby/pkg/namesgenerator" "golang.org/x/xerrors" "github.com/coder/coder/coderd/database" @@ -318,7 +316,7 @@ func (api *api) putUserSuspend(rw http.ResponseWriter, r *http.Request) { httpapi.Write(rw, http.StatusOK, convertUser(suspendedUser)) } -func (api *api) getUserRoles(rw http.ResponseWriter, r *http.Request) { +func (api *api) userRoles(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) resp := codersdk.UserRoles{ diff --git a/codersdk/users.go b/codersdk/users.go index cab2b7e7b2aa9..16c43c84b1e70 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -204,7 +204,6 @@ func (c *Client) GetUserRoles(ctx context.Context, userID uuid.UUID) (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) From 8316deb4b3bc26eb857aabb2bd611092051e84ab Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 28 Apr 2022 00:35:50 +0000 Subject: [PATCH 13/20] Make gen --- peerbroker/proto/peerbroker_drpc.pb.go | 2 +- provisionerd/proto/provisionerd_drpc.pb.go | 2 +- provisionersdk/proto/provisioner_drpc.pb.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/peerbroker/proto/peerbroker_drpc.pb.go b/peerbroker/proto/peerbroker_drpc.pb.go index a31d184cbd639..ae06f79a01371 100644 --- a/peerbroker/proto/peerbroker_drpc.pb.go +++ b/peerbroker/proto/peerbroker_drpc.pb.go @@ -1,5 +1,5 @@ // Code generated by protoc-gen-go-drpc. DO NOT EDIT. -// protoc-gen-go-drpc version: v0.0.30 +// protoc-gen-go-drpc version: v0.0.26 // source: peerbroker/proto/peerbroker.proto package proto diff --git a/provisionerd/proto/provisionerd_drpc.pb.go b/provisionerd/proto/provisionerd_drpc.pb.go index c583c320c90e2..646f855eabc70 100644 --- a/provisionerd/proto/provisionerd_drpc.pb.go +++ b/provisionerd/proto/provisionerd_drpc.pb.go @@ -1,5 +1,5 @@ // Code generated by protoc-gen-go-drpc. DO NOT EDIT. -// protoc-gen-go-drpc version: v0.0.30 +// protoc-gen-go-drpc version: v0.0.26 // source: provisionerd/proto/provisionerd.proto package proto diff --git a/provisionersdk/proto/provisioner_drpc.pb.go b/provisionersdk/proto/provisioner_drpc.pb.go index 0dba37415265b..c990f6f645b7f 100644 --- a/provisionersdk/proto/provisioner_drpc.pb.go +++ b/provisionersdk/proto/provisioner_drpc.pb.go @@ -1,5 +1,5 @@ // Code generated by protoc-gen-go-drpc. DO NOT EDIT. -// protoc-gen-go-drpc version: v0.0.30 +// protoc-gen-go-drpc version: v0.0.26 // source: provisionersdk/proto/provisioner.proto package proto From 7d0355421d0c6d6add02435a1ab773dff23ef3f0 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 28 Apr 2022 08:27:33 -0500 Subject: [PATCH 14/20] Audit log track rbac_roles --- coderd/audit/table.go | 1 + 1 file changed, 1 insertion(+) 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. From b732d03a14a23ad2fe8d90d2e2dd4bf716c6cac4 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 28 Apr 2022 10:39:52 -0500 Subject: [PATCH 15/20] PR comments - Remove string type alias, use string - Drop unused funcs - Fix down migration --- ...es.down.sql => 000008_rbac_roles.down.sql} | 2 +- ..._roles.up.sql => 000008_rbac_roles.up.sql} | 0 coderd/rbac/authz.go | 2 +- coderd/rbac/builtin.go | 54 ++----------------- 4 files changed, 6 insertions(+), 52 deletions(-) rename coderd/database/migrations/{000008_rbac_user_roles.down.sql => 000008_rbac_roles.down.sql} (54%) rename coderd/database/migrations/{000008_rbac_user_roles.up.sql => 000008_rbac_roles.up.sql} (100%) diff --git a/coderd/database/migrations/000008_rbac_user_roles.down.sql b/coderd/database/migrations/000008_rbac_roles.down.sql similarity index 54% rename from coderd/database/migrations/000008_rbac_user_roles.down.sql rename to coderd/database/migrations/000008_rbac_roles.down.sql index 327561894eab6..981559af10f0f 100644 --- a/coderd/database/migrations/000008_rbac_user_roles.down.sql +++ b/coderd/database/migrations/000008_rbac_roles.down.sql @@ -1,2 +1,2 @@ -ALTER TABLE ONLY workspaces +ALTER TABLE ONLY users DROP COLUMN IF EXISTS rbac_roles; \ No newline at end of file diff --git a/coderd/database/migrations/000008_rbac_user_roles.up.sql b/coderd/database/migrations/000008_rbac_roles.up.sql similarity index 100% rename from coderd/database/migrations/000008_rbac_user_roles.up.sql rename to coderd/database/migrations/000008_rbac_roles.up.sql diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index 6c71f4f3d1ba2..f11508ad2b099 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -41,7 +41,7 @@ type authSubject struct { // 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 []RoleName, action Action, object Object) error { +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) diff --git a/coderd/rbac/builtin.go b/coderd/rbac/builtin.go index 0c12e3a8520be..d20774b6e0257 100644 --- a/coderd/rbac/builtin.go +++ b/coderd/rbac/builtin.go @@ -17,32 +17,24 @@ const ( orgMember string = "organization-member" ) -// RoleName is a string that represents a registered rbac role. We want to store -// strings in the database to allow the underlying role permissions to be migrated -// or modified. -// All RoleNames should have an entry in 'builtInRoles'. -// We use functions to retrieve the name incase we need to add a scope. -type RoleName = string - // 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() RoleName { +func RoleAdmin() string { return roleName(admin, "") } -func RoleMember() RoleName { +func RoleMember() string { return roleName(member, "") } -func RoleOrgAdmin(organizationID uuid.UUID) RoleName { +func RoleOrgAdmin(organizationID uuid.UUID) string { return roleName(orgAdmin, organizationID.String()) } -// member:uuid -func RoleOrgMember(organizationID uuid.UUID) RoleName { +func RoleOrgMember(organizationID uuid.UUID) string { return roleName(orgMember, organizationID.String()) } @@ -83,7 +75,6 @@ var ( return Role{ Name: "auditor", Site: permissions(map[Object][]Action{ - //ResourceAuditLogs: {ActionRead}, // Should be able to read all template details, even in orgs they // are not in. ResourceTemplate: {ActionRead}, @@ -122,43 +113,6 @@ var ( } ) -// ListOrgRoles lists all roles that can be applied to an organization user -// in the given organization. -// Note: This should be a list in a database, but until then we build -// the list from the builtins. -func ListOrgRoles(organizationID uuid.UUID) []string { - var roles []string - for role := range builtInRoles { - _, scope, err := roleSplit(role) - if err != nil { - // This should never happen - continue - } - if scope == organizationID.String() { - roles = append(roles, role) - } - } - return roles -} - -// ListSiteRoles lists all roles that can be applied to a user. -// Note: This should be a list in a database, but until then we build -// the list from the builtins. -func ListSiteRoles() []string { - var roles []string - for role := range builtInRoles { - _, scope, err := roleSplit(role) - if err != nil { - // This should never happen - continue - } - if scope == "" { - roles = append(roles, role) - } - } - return roles -} - // 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) { From a77d535c7593894c471d8cb81a4a82e5cc2c42b8 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 28 Apr 2022 14:43:24 -0500 Subject: [PATCH 16/20] Add orgids to user response --- coderd/rbac/role.go | 2 +- coderd/users.go | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/coderd/rbac/role.go b/coderd/rbac/role.go index 362d11bf952b9..a083b2a11ac80 100644 --- a/coderd/rbac/role.go +++ b/coderd/rbac/role.go @@ -17,7 +17,7 @@ type Permission struct { // 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 RoleName `json:"name"` + 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 diff --git a/coderd/users.go b/coderd/users.go index 0b4d033a1d9cc..b0efdca95ed55 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -418,7 +418,15 @@ func (api *api) putUserRoles(rw http.ResponseWriter, r *http.Request) { return } - httpapi.Write(rw, http.StatusOK, convertUser(updatedUser)) + 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)) } // Returns organizations the parameterized user has access to. From 32f5dc01925f2bcb3a471ad43a46c6e01f30bd5a Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 28 Apr 2022 15:40:02 -0500 Subject: [PATCH 17/20] Rename type to use proper casing --- coderd/database/models.go | 2 +- coderd/database/queries.sql.go | 18 +++++++++--------- coderd/database/sqlc.yaml | 1 + 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/coderd/database/models.go b/coderd/database/models.go index 18016f6810183..b1f521acda963 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -398,7 +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"` + RBACRoles []string `db:"rbac_roles" json:"rbac_roles"` } type Workspace struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2e348af2f5279..35727b8090fe0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1885,7 +1885,7 @@ func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserBy &i.CreatedAt, &i.UpdatedAt, &i.Status, - pq.Array(&i.RbacRoles), + pq.Array(&i.RBACRoles), ) return i, err } @@ -1912,7 +1912,7 @@ func (q *sqlQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (User, error &i.CreatedAt, &i.UpdatedAt, &i.Status, - pq.Array(&i.RbacRoles), + pq.Array(&i.RBACRoles), ) return i, err } @@ -2004,7 +2004,7 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]User, &i.CreatedAt, &i.UpdatedAt, &i.Status, - pq.Array(&i.RbacRoles), + pq.Array(&i.RBACRoles), ); err != nil { return nil, err } @@ -2046,7 +2046,7 @@ func (q *sqlQuerier) GrantUserRole(ctx context.Context, arg GrantUserRoleParams) &i.CreatedAt, &i.UpdatedAt, &i.Status, - pq.Array(&i.RbacRoles), + pq.Array(&i.RBACRoles), ) return i, err } @@ -2073,7 +2073,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"` + RBACRoles []string `db:"rbac_roles" json:"rbac_roles"` } func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) { @@ -2084,7 +2084,7 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User arg.HashedPassword, arg.CreatedAt, arg.UpdatedAt, - pq.Array(arg.RbacRoles), + pq.Array(arg.RBACRoles), ) var i User err := row.Scan( @@ -2095,7 +2095,7 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User &i.CreatedAt, &i.UpdatedAt, &i.Status, - pq.Array(&i.RbacRoles), + pq.Array(&i.RBACRoles), ) return i, err } @@ -2134,7 +2134,7 @@ func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfil &i.CreatedAt, &i.UpdatedAt, &i.Status, - pq.Array(&i.RbacRoles), + pq.Array(&i.RBACRoles), ) return i, err } @@ -2166,7 +2166,7 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP &i.CreatedAt, &i.UpdatedAt, &i.Status, - pq.Array(&i.RbacRoles), + pq.Array(&i.RBACRoles), ) return i, err } 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 From 2c33a82b07f41c2a086a01b94c83d9ac253b7481 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 28 Apr 2022 16:28:49 -0500 Subject: [PATCH 18/20] feat: Split org/site roles - UpdateRoles vs Grant/RemoveRoles - UpdateRoles and UpdateMemberRoles --- coderd/coderd.go | 8 ++ coderd/database/databasefake/databasefake.go | 36 +++++-- coderd/database/querier.go | 3 +- coderd/database/queries.sql.go | 95 ++++++++++++------- .../database/queries/organizationmembers.sql | 11 +++ coderd/database/queries/users.sql | 6 +- coderd/members.go | 92 ++++++++++++++++++ coderd/rbac/builtin.go | 26 +++-- coderd/users.go | 47 +++++---- coderd/users_test.go | 53 +++++++++-- codersdk/organizationmember.go | 15 +++ codersdk/users.go | 25 ++++- site/src/api/typesGenerated.ts | 30 ++++-- 13 files changed, 357 insertions(+), 90 deletions(-) create mode 100644 coderd/members.go create mode 100644 codersdk/organizationmember.go diff --git a/coderd/coderd.go b/coderd/coderd.go index 2caf9128646ea..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) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index c53cca3facbbb..33b6109822394 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -747,6 +747,28 @@ func (q *fakeQuerier) GetOrganizationMembershipsByUserID(_ context.Context, user 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() @@ -1177,13 +1199,13 @@ func (q *fakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam UpdatedAt: arg.UpdatedAt, Username: arg.Username, Status: database.UserStatusActive, - RbacRoles: arg.RbacRoles, + RBACRoles: arg.RBACRoles, } q.users = append(q.users, user) return user, nil } -func (q *fakeQuerier) GrantUserRole(_ context.Context, arg database.GrantUserRoleParams) (database.User, error) { +func (q *fakeQuerier) UpdateUserRoles(_ context.Context, arg database.UpdateUserRolesParams) (database.User, error) { q.mutex.Lock() defer q.mutex.Unlock() @@ -1192,12 +1214,12 @@ func (q *fakeQuerier) GrantUserRole(_ context.Context, arg database.GrantUserRol continue } - // Append the new roles - user.RbacRoles = append(user.RbacRoles, arg.GrantedRoles...) + // Set new roles + user.RBACRoles = arg.GrantedRoles // Remove duplicates and sort - uniqueRoles := make([]string, 0, len(user.RbacRoles)) + uniqueRoles := make([]string, 0, len(user.RBACRoles)) exist := make(map[string]struct{}) - for _, r := range user.RbacRoles { + for _, r := range user.RBACRoles { if _, ok := exist[r]; ok { continue } @@ -1205,7 +1227,7 @@ func (q *fakeQuerier) GrantUserRole(_ context.Context, arg database.GrantUserRol uniqueRoles = append(uniqueRoles, r) } sort.Strings(uniqueRoles) - user.RbacRoles = uniqueRoles + user.RBACRoles = uniqueRoles q.users[index] = user return user, nil diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 1d23495342ddf..24c459d9d3744 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -60,7 +60,6 @@ type querier interface { GetWorkspacesByOrganizationID(ctx context.Context, arg GetWorkspacesByOrganizationIDParams) ([]Workspace, error) GetWorkspacesByOwnerID(ctx context.Context, arg GetWorkspacesByOwnerIDParams) ([]Workspace, error) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorkspacesByTemplateIDParams) ([]Workspace, error) - GrantUserRole(ctx context.Context, arg GrantUserRoleParams) (User, error) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) InsertFile(ctx context.Context, arg InsertFileParams) (File, error) InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error) @@ -80,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 @@ -88,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 35727b8090fe0..dae89237367a8 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -453,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 @@ -2019,38 +2050,6 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]User, return items, nil } -const grantUserRole = `-- name: GrantUserRole :one -UPDATE - users -SET - -- Append new roles and remove duplicates just to keep things clean. - rbac_roles = ARRAY(SELECT DISTINCT UNNEST(rbac_roles || $1 :: text[])) -WHERE - id = $2 -RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles -` - -type GrantUserRoleParams struct { - GrantedRoles []string `db:"granted_roles" json:"granted_roles"` - ID uuid.UUID `db:"id" json:"id"` -} - -func (q *sqlQuerier) GrantUserRole(ctx context.Context, arg GrantUserRoleParams) (User, error) { - row := q.db.QueryRowContext(ctx, grantUserRole, 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 -} - const insertUser = `-- name: InsertUser :one INSERT INTO users ( @@ -2139,6 +2138,38 @@ func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfil 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 +} + const updateUserStatus = `-- name: UpdateUserStatus :one UPDATE users diff --git a/coderd/database/queries/organizationmembers.sql b/coderd/database/queries/organizationmembers.sql index dd5b848704f6d..34d1d8635c711 100644 --- a/coderd/database/queries/organizationmembers.sql +++ b/coderd/database/queries/organizationmembers.sql @@ -39,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 0bcc81a76bb30..5c7fa9651216c 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -49,12 +49,12 @@ SET WHERE id = $1 RETURNING *; --- name: GrantUserRole :one +-- name: UpdateUserRoles :one UPDATE users SET - -- Append new roles and remove duplicates just to keep things clean. - rbac_roles = ARRAY(SELECT DISTINCT UNNEST(rbac_roles || @granted_roles :: text[])) + -- Remove all duplicates from the roles. + rbac_roles = ARRAY(SELECT DISTINCT UNNEST(@granted_roles :: text[])) WHERE id = @id RETURNING *; diff --git a/coderd/members.go b/coderd/members.go new file mode 100644 index 0000000000000..87badc29986d4 --- /dev/null +++ b/coderd/members.go @@ -0,0 +1,92 @@ +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) + 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/builtin.go b/coderd/rbac/builtin.go index d20774b6e0257..4d659e6e8e39f 100644 --- a/coderd/rbac/builtin.go +++ b/coderd/rbac/builtin.go @@ -47,7 +47,7 @@ var ( // // This map will be replaced by database storage defined by this ticket. // https://github.com/coder/coder/issues/1194 - builtInRoles = map[string]func(scopeID string) Role{ + builtInRoles = map[string]func(orgID string) Role{ // admin grants all actions to all resources. admin: func(_ string) Role { return Role{ @@ -116,7 +116,7 @@ var ( // 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, scopeID, err := roleSplit(name) + roleName, orgID, err := roleSplit(name) if err != nil { return Role{}, xerrors.Errorf(":%w", err) } @@ -129,25 +129,33 @@ func RoleByName(name string) (Role, error) { // Ensure all org roles are properly scoped a non-empty organization id. // This is just some defensive programming. - role := roleFunc(scopeID) - if len(role.Org) > 0 && scopeID == "" { - return Role{}, xerrors.Errorf("expect a scope id for role %q", roleName) + 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, scopeID string) string { - if scopeID == "" { +func roleName(name string, orgID string) string { + if orgID == "" { return name } - return name + ":" + scopeID + return name + ":" + orgID } -func roleSplit(role string) (name string, scopeID string, err error) { +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") diff --git a/coderd/users.go b/coderd/users.go index b0efdca95ed55..ad8960fa7a21e 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -87,8 +87,8 @@ func (api *api) postFirstUser(rw http.ResponseWriter, r *http.Request) { // 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.GrantUserRole(r.Context(), database.GrantUserRoleParams{ - GrantedRoles: []string{rbac.RoleAdmin()}, + _, err = api.Database.UpdateUserRoles(r.Context(), database.UpdateUserRolesParams{ + GrantedRoles: []string{rbac.RoleAdmin(), rbac.RoleMember()}, ID: user.ID, }) if err != nil { @@ -362,7 +362,8 @@ func (api *api) userRoles(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) resp := codersdk.UserRoles{ - Roles: user.RbacRoles, + Roles: user.RBACRoles, + OrganizationRoles: make(map[uuid.UUID][]string), } memberships, err := api.Database.GetOrganizationMembershipsByUserID(r.Context(), user.ID) @@ -374,7 +375,7 @@ func (api *api) userRoles(rw http.ResponseWriter, r *http.Request) { } for _, mem := range memberships { - resp.Roles = append(resp.Roles, mem.Roles...) + resp.OrganizationRoles[mem.OrganizationID] = mem.Roles } httpapi.Write(rw, http.StatusOK, resp) @@ -393,27 +394,18 @@ func (api *api) putUserRoles(rw http.ResponseWriter, r *http.Request) { return } - var params codersdk.GrantUserRoles + var params codersdk.UpdateRoles if !httpapi.Read(rw, r, ¶ms) { return } - for _, r := range params.Roles { - if _, err := rbac.RoleByName(r); err != nil { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: fmt.Sprintf("%q is not a supported role", r), - }) - return - } - } - - updatedUser, err := api.Database.GrantUserRole(r.Context(), database.GrantUserRoleParams{ + updatedUser, err := api.updateSiteUserRoles(r.Context(), database.UpdateUserRolesParams{ GrantedRoles: params.Roles, ID: user.ID, }) if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get user: %s", err), + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: err.Error(), }) return } @@ -429,6 +421,25 @@ func (api *api) putUserRoles(rw http.ResponseWriter, r *http.Request) { 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) @@ -718,7 +729,7 @@ func (api *api) createUser(ctx context.Context, req codersdk.CreateUserRequest) CreatedAt: database.Now(), UpdatedAt: database.Now(), // All new users are defaulted to members of the site. - RbacRoles: []string{rbac.RoleMember()}, + RBACRoles: []string{rbac.RoleMember()}, } // If a user signs up with OAuth, they can have no password! if req.Password != "" { diff --git a/coderd/users_test.go b/coderd/users_test.go index 75dedc2b2e1ba..3b93098201f8e 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -289,6 +289,28 @@ func TestUpdateUserProfile(t *testing.T) { func TestGrantRoles(t *testing.T) { t.Parallel() + t.Run("UpdateIncorrectRoles", func(t *testing.T) { + t.Parallel() + ctx := context.Background() + client := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, client) + + _, err := client.UpdateUserRoles(ctx, codersdk.Me, codersdk.UpdateRoles{ + Roles: []string{rbac.RoleOrgMember(first.OrganizationID)}, + }) + require.Error(t, err, "org role in site") + + _, err = client.UpdateOrganizationMemberRoles(ctx, first.OrganizationID, codersdk.Me, codersdk.UpdateRoles{ + Roles: []string{rbac.RoleMember()}, + }) + require.Error(t, err, "site role in org") + + _, err = client.UpdateOrganizationMemberRoles(ctx, uuid.New(), codersdk.Me, codersdk.UpdateRoles{ + Roles: []string{rbac.RoleMember()}, + }) + require.Error(t, err, "role in org without membership") + }) + t.Run("FirstUserRoles", func(t *testing.T) { t.Parallel() ctx := context.Background() @@ -300,6 +322,9 @@ func TestGrantRoles(t *testing.T) { 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") @@ -316,27 +341,43 @@ func TestGrantRoles(t *testing.T) { require.NoError(t, err) require.ElementsMatch(t, roles.Roles, []string{ rbac.RoleMember(), - rbac.RoleOrgMember(first.OrganizationID), }, "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.GrantUserRoles' once proper authz + // TODO: @emyrk this should be 'admin.UpdateUserRoles' once proper authz // is enforced. - _, err = member.GrantUserRoles(ctx, codersdk.Me, codersdk.GrantUserRoles{ + _, err = member.UpdateUserRoles(ctx, codersdk.Me, codersdk.UpdateRoles{ Roles: []string{ + // Promote to site admin + rbac.RoleMember(), rbac.RoleAdmin(), - rbac.RoleOrgAdmin(first.OrganizationID), }, }) 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.RoleOrgMember(first.OrganizationID), - 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") }) 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 b1039c8472a59..130109b7f0e5d 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -70,12 +70,13 @@ type UpdateUserProfileRequest struct { Username string `json:"username" validate:"required,username"` } -type GrantUserRoles struct { +type UpdateRoles struct { Roles []string `json:"roles" validate:"required"` } type UserRoles struct { - Roles []string `json:"roles"` + Roles []string `json:"roles"` + OrganizationRoles map[uuid.UUID][]string `json:"organization_roles"` } // LoginWithPasswordRequest enables callers to authenticate with email and password. @@ -177,8 +178,9 @@ func (c *Client) SuspendUser(ctx context.Context, userID uuid.UUID) (User, error return user, json.NewDecoder(res.Body).Decode(&user) } -// GrantUserRoles grants the userID the specified roles -func (c *Client) GrantUserRoles(ctx context.Context, userID uuid.UUID, req GrantUserRoles) (User, error) { +// 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 @@ -191,6 +193,21 @@ func (c *Client) GrantUserRoles(ctx context.Context, userID uuid.UUID, req Grant 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) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c3ede8ac2618b..48721a2fd5772 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:102:6 +// From codersdk/users.go:103: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:97:6 +// From codersdk/users.go:98:6 export interface CreateOrganizationRequest { readonly name: string } @@ -100,7 +100,7 @@ export interface CreateWorkspaceRequest { readonly parameter_values: CreateParameterRequest[] } -// From codersdk/users.go:93:6 +// From codersdk/users.go:94:6 export interface GenerateAPIKeyResponse { readonly key: string } @@ -118,18 +118,13 @@ export interface GoogleInstanceIdentityToken { readonly json_web_token: string } -// From codersdk/users.go:73:6 -export interface GrantUserRoles { - readonly roles: string[] -} - -// From codersdk/users.go:82:6 +// From codersdk/users.go:83:6 export interface LoginWithPasswordRequest { readonly email: string readonly password: string } -// From codersdk/users.go:88:6 +// From codersdk/users.go:89:6 export interface LoginWithPasswordResponse { readonly session_token: string } @@ -142,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 @@ -250,6 +254,11 @@ export interface UpdateActiveTemplateVersion { readonly id: string } +// From codersdk/users.go:73:6 +export interface UpdateRoles { + readonly roles: string[] +} + // From codersdk/users.go:68:6 export interface UpdateUserProfileRequest { readonly email: string @@ -284,6 +293,7 @@ export interface User { // From codersdk/users.go:77:6 export interface UserRoles { readonly roles: string[] + readonly organization_roles: Record } // From codersdk/users.go:17:6 From 0e3fc298169b666aa0dd4dcbd726f8730244d5d1 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 28 Apr 2022 16:49:59 -0500 Subject: [PATCH 19/20] Add more unit testing --- coderd/rbac/builtin_internal_test.go | 55 ++++++++++++++++++++++++ coderd/rbac/builtin_test.go | 62 ++++++++++++++++++++++++++++ coderd/users_test.go | 26 +++++++++--- 3 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 coderd/rbac/builtin_internal_test.go create mode 100644 coderd/rbac/builtin_test.go 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/users_test.go b/coderd/users_test.go index 3b93098201f8e..8c4f5be0f5c32 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -292,23 +292,39 @@ func TestGrantRoles(t *testing.T) { t.Run("UpdateIncorrectRoles", func(t *testing.T) { t.Parallel() ctx := context.Background() - client := coderdtest.New(t, nil) - first := coderdtest.CreateFirstUser(t, client) + admin := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, admin) + member := coderdtest.CreateAnotherUser(t, admin, first.OrganizationID) - _, err := client.UpdateUserRoles(ctx, codersdk.Me, codersdk.UpdateRoles{ + _, err := admin.UpdateUserRoles(ctx, codersdk.Me, codersdk.UpdateRoles{ Roles: []string{rbac.RoleOrgMember(first.OrganizationID)}, }) require.Error(t, err, "org role in site") - _, err = client.UpdateOrganizationMemberRoles(ctx, first.OrganizationID, codersdk.Me, codersdk.UpdateRoles{ + _, 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 = client.UpdateOrganizationMemberRoles(ctx, uuid.New(), codersdk.Me, codersdk.UpdateRoles{ + _, 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) { From 9d47b74ac39ce55c8b835357da790a8313b756fa Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 29 Apr 2022 08:26:58 -0500 Subject: [PATCH 20/20] Update comment --- coderd/members.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/coderd/members.go b/coderd/members.go index 87badc29986d4..cff26042619ea 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -20,10 +20,13 @@ import ( 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. + // 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"),