From 5a47132313b5280381106bfcac249ea3f8d0e0b0 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 13 Sep 2022 12:32:51 -0400 Subject: [PATCH 01/37] feat: Add ACL list support to rego objects --- coderd/rbac/authz_internal_test.go | 13 +++++++++ coderd/rbac/builtin.go | 46 +++++++++++++++--------------- coderd/rbac/object.go | 14 ++++++++- coderd/rbac/partial.go | 1 + coderd/rbac/policy.rego | 9 +++++- 5 files changed, 58 insertions(+), 25 deletions(-) diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index b91130d0f4def..71c08531ff9e9 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -191,6 +191,19 @@ func TestAuthorizeDomain(t *testing.T) { }, } + testAuthorize(t, "ACLList", user, []authTestCase{ + { + resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithACL(map[Action][]string{ + ActionRead: {user.UserID}, + ActionDelete: {user.UserID}, + ActionCreate: {user.UserID}, + ActionUpdate: {user.UserID}, + }), + actions: allActions(), + allow: true, + }, + }) + testAuthorize(t, "Member", user, []authTestCase{ // Org + me {resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), actions: allActions(), allow: true}, diff --git a/coderd/rbac/builtin.go b/coderd/rbac/builtin.go index 4540538ce3c4c..7fcca090e067f 100644 --- a/coderd/rbac/builtin.go +++ b/coderd/rbac/builtin.go @@ -63,8 +63,8 @@ var ( return Role{ Name: owner, DisplayName: "Owner", - Site: permissions(map[Object][]Action{ - ResourceWildcard: {WildcardSymbol}, + Site: permissions(map[string][]Action{ + ResourceWildcard.Type: {WildcardSymbol}, }), } }, @@ -74,15 +74,15 @@ var ( return Role{ Name: member, DisplayName: "", - Site: permissions(map[Object][]Action{ + Site: permissions(map[string][]Action{ // All users can read all other users and know they exist. - ResourceUser: {ActionRead}, - ResourceRoleAssignment: {ActionRead}, + ResourceUser.Type: {ActionRead}, + ResourceRoleAssignment.Type: {ActionRead}, // All users can see the provisioner daemons. - ResourceProvisionerDaemon: {ActionRead}, + ResourceProvisionerDaemon.Type: {ActionRead}, }), - User: permissions(map[Object][]Action{ - ResourceWildcard: {WildcardSymbol}, + User: permissions(map[string][]Action{ + ResourceWildcard.Type: {WildcardSymbol}, }), } }, @@ -94,11 +94,11 @@ var ( return Role{ Name: auditor, DisplayName: "Auditor", - Site: permissions(map[Object][]Action{ + Site: permissions(map[string][]Action{ // Should be able to read all template details, even in orgs they // are not in. - ResourceTemplate: {ActionRead}, - ResourceAuditLog: {ActionRead}, + ResourceTemplate.Type: {ActionRead}, + ResourceAuditLog.Type: {ActionRead}, }), } }, @@ -107,13 +107,13 @@ var ( return Role{ Name: templateAdmin, DisplayName: "Template Admin", - Site: permissions(map[Object][]Action{ - ResourceTemplate: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + Site: permissions(map[string][]Action{ + ResourceTemplate.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, // CRUD all files, even those they did not upload. - ResourceFile: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, - ResourceWorkspace: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + ResourceFile.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + ResourceWorkspace.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, // CRUD to provisioner daemons for now. - ResourceProvisionerDaemon: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + ResourceProvisionerDaemon.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, }), } }, @@ -122,11 +122,11 @@ var ( return Role{ Name: userAdmin, DisplayName: "User Admin", - Site: permissions(map[Object][]Action{ - ResourceRoleAssignment: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, - ResourceUser: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + Site: permissions(map[string][]Action{ + ResourceRoleAssignment.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + ResourceUser.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, // Full perms to manage org members - ResourceOrganizationMember: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + ResourceOrganizationMember.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, }), } }, @@ -390,14 +390,14 @@ func roleSplit(role string) (name string, orgID string, err error) { // 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 { +func permissions(perms map[string][]Action) []Permission { list := make([]Permission, 0, len(perms)) - for k, actions := range perms { + for objectType, actions := range perms { for _, act := range actions { act := act list = append(list, Permission{ Negate: false, - ResourceType: k.Type, + ResourceType: objectType, Action: act, }) } diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 45d084ea42313..dabb123001cdb 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -138,7 +138,9 @@ type Object struct { // Type is "workspace", "project", "app", etc Type string `json:"type"` - // TODO: SharedUsers? + + // map[action][]user_id + ACLList map[Action][]string ` json:"acl_list"` } func (z Object) RBACObject() Object { @@ -171,3 +173,13 @@ func (z Object) WithOwner(ownerID string) Object { Type: z.Type, } } + +// WithACL adds an ACL list to a given object +func (z Object) WithACL(acl map[Action][]string) Object { + return Object{ + Owner: z.Owner, + OrgID: z.OrgID, + Type: z.Type, + ACLList: acl, + } +} diff --git a/coderd/rbac/partial.go b/coderd/rbac/partial.go index 8ff0f1d17593f..80469b434490e 100644 --- a/coderd/rbac/partial.go +++ b/coderd/rbac/partial.go @@ -43,6 +43,7 @@ func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, a rego.Unknowns([]string{ "input.object.owner", "input.object.org_owner", + "input.object.acl_list", }), rego.Input(input), ).Partial(ctx) diff --git a/coderd/rbac/policy.rego b/coderd/rbac/policy.rego index 4b94eafa91eb5..0d55574433f5f 100644 --- a/coderd/rbac/policy.rego +++ b/coderd/rbac/policy.rego @@ -3,7 +3,7 @@ import future.keywords # A great playground: https://play.openpolicyagent.org/ # Helpful cli commands to debug. # opa eval --format=pretty 'data.authz.allow = true' -d policy.rego -i input.json -# opa eval --partial --format=pretty 'data.authz.allow = true' -d policy.rego --unknowns input.object.owner --unknowns input.object.org_owner -i input.json +# opa eval --partial --format=pretty 'data.authz.allow = true' -d policy.rego --unknowns input.object.owner --unknowns input.object.org_owner --unknowns input.object.acl_list -i input.json # # This policy is specifically constructed to compress to a set of queries if the @@ -156,3 +156,10 @@ allow { org_mem user = 1 } + +# ACL Allow +allow { + # Should you have to be a member of the org too? + input.subject.id in input.object.acl_list[input.action] +} + From 03f69bf9819e784d71699182aed76d8707ff3bdf Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 13 Sep 2022 12:36:46 -0400 Subject: [PATCH 02/37] Add unit tests --- coderd/rbac/authz_internal_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index 71c08531ff9e9..6fb6d77fb533a 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -202,6 +202,22 @@ func TestAuthorizeDomain(t *testing.T) { actions: allActions(), allow: true, }, + { + resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithACL(map[Action][]string{ + ActionRead: {user.UserID}, + ActionUpdate: {user.UserID}, + }), + actions: []Action{ActionCreate, ActionDelete}, + allow: false, + }, + { + // By default users cannot update templates + resource: ResourceTemplate.InOrg(defOrg).WithACL(map[Action][]string{ + ActionUpdate: {user.UserID}, + }), + actions: []Action{ActionRead, ActionUpdate}, + allow: true, + }, }) testAuthorize(t, "Member", user, []authTestCase{ From 91a358d104c72caa147ddfe2b96b7da3cd72f494 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 13 Sep 2022 12:50:56 -0400 Subject: [PATCH 03/37] Rename ACL list --- coderd/rbac/authz_internal_test.go | 6 +++--- coderd/rbac/object.go | 14 +++++++------- coderd/rbac/partial.go | 2 +- coderd/rbac/policy.rego | 5 +++-- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index 6fb6d77fb533a..6da9cc2f5bed1 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -193,7 +193,7 @@ func TestAuthorizeDomain(t *testing.T) { testAuthorize(t, "ACLList", user, []authTestCase{ { - resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithACL(map[Action][]string{ + resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithACLUserList(map[Action][]string{ ActionRead: {user.UserID}, ActionDelete: {user.UserID}, ActionCreate: {user.UserID}, @@ -203,7 +203,7 @@ func TestAuthorizeDomain(t *testing.T) { allow: true, }, { - resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithACL(map[Action][]string{ + resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithACLUserList(map[Action][]string{ ActionRead: {user.UserID}, ActionUpdate: {user.UserID}, }), @@ -212,7 +212,7 @@ func TestAuthorizeDomain(t *testing.T) { }, { // By default users cannot update templates - resource: ResourceTemplate.InOrg(defOrg).WithACL(map[Action][]string{ + resource: ResourceTemplate.InOrg(defOrg).WithACLUserList(map[Action][]string{ ActionUpdate: {user.UserID}, }), actions: []Action{ActionRead, ActionUpdate}, diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index dabb123001cdb..7e53c2945f7c0 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -140,7 +140,7 @@ type Object struct { Type string `json:"type"` // map[action][]user_id - ACLList map[Action][]string ` json:"acl_list"` + ACLUserList map[Action][]string ` json:"acl_user_list"` } func (z Object) RBACObject() Object { @@ -174,12 +174,12 @@ func (z Object) WithOwner(ownerID string) Object { } } -// WithACL adds an ACL list to a given object -func (z Object) WithACL(acl map[Action][]string) Object { +// WithACLUserList adds an ACL list to a given object +func (z Object) WithACLUserList(acl map[Action][]string) Object { return Object{ - Owner: z.Owner, - OrgID: z.OrgID, - Type: z.Type, - ACLList: acl, + Owner: z.Owner, + OrgID: z.OrgID, + Type: z.Type, + ACLUserList: acl, } } diff --git a/coderd/rbac/partial.go b/coderd/rbac/partial.go index 80469b434490e..6f48fa3bb2b40 100644 --- a/coderd/rbac/partial.go +++ b/coderd/rbac/partial.go @@ -43,7 +43,7 @@ func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, a rego.Unknowns([]string{ "input.object.owner", "input.object.org_owner", - "input.object.acl_list", + "input.object.acl_user_list", }), rego.Input(input), ).Partial(ctx) diff --git a/coderd/rbac/policy.rego b/coderd/rbac/policy.rego index 0d55574433f5f..61003afadc875 100644 --- a/coderd/rbac/policy.rego +++ b/coderd/rbac/policy.rego @@ -3,7 +3,7 @@ import future.keywords # A great playground: https://play.openpolicyagent.org/ # Helpful cli commands to debug. # opa eval --format=pretty 'data.authz.allow = true' -d policy.rego -i input.json -# opa eval --partial --format=pretty 'data.authz.allow = true' -d policy.rego --unknowns input.object.owner --unknowns input.object.org_owner --unknowns input.object.acl_list -i input.json +# opa eval --partial --format=pretty 'data.authz.allow = true' -d policy.rego --unknowns input.object.owner --unknowns input.object.org_owner --unknowns input.object.acl_user_list -i input.json # # This policy is specifically constructed to compress to a set of queries if the @@ -160,6 +160,7 @@ allow { # ACL Allow allow { # Should you have to be a member of the org too? - input.subject.id in input.object.acl_list[input.action] + input.subject.id in input.object.acl_user_list[input.action] } + From 8f837b7be4d979b727215e6ddc2e15c920fbf5e3 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 14 Sep 2022 20:33:58 -0400 Subject: [PATCH 04/37] Flip rego json to key by user id --- coderd/rbac/authz_internal_test.go | 16 ++++++---------- coderd/rbac/object.go | 6 +++--- coderd/rbac/policy.rego | 5 ++--- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index 6da9cc2f5bed1..a88dcef03e08c 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -193,27 +193,23 @@ func TestAuthorizeDomain(t *testing.T) { testAuthorize(t, "ACLList", user, []authTestCase{ { - resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithACLUserList(map[Action][]string{ - ActionRead: {user.UserID}, - ActionDelete: {user.UserID}, - ActionCreate: {user.UserID}, - ActionUpdate: {user.UserID}, + resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithACLUserList(map[string][]Action{ + user.UserID: allActions(), }), actions: allActions(), allow: true, }, { - resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithACLUserList(map[Action][]string{ - ActionRead: {user.UserID}, - ActionUpdate: {user.UserID}, + resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithACLUserList(map[string][]Action{ + user.UserID: {ActionRead, ActionUpdate}, }), actions: []Action{ActionCreate, ActionDelete}, allow: false, }, { // By default users cannot update templates - resource: ResourceTemplate.InOrg(defOrg).WithACLUserList(map[Action][]string{ - ActionUpdate: {user.UserID}, + resource: ResourceTemplate.InOrg(defOrg).WithACLUserList(map[string][]Action{ + user.UserID: {ActionUpdate}, }), actions: []Action{ActionRead, ActionUpdate}, allow: true, diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 7e53c2945f7c0..bd487657eb44b 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -139,8 +139,8 @@ type Object struct { // Type is "workspace", "project", "app", etc Type string `json:"type"` - // map[action][]user_id - ACLUserList map[Action][]string ` json:"acl_user_list"` + // map[string][]Action + ACLUserList map[string][]Action ` json:"acl_user_list"` } func (z Object) RBACObject() Object { @@ -175,7 +175,7 @@ func (z Object) WithOwner(ownerID string) Object { } // WithACLUserList adds an ACL list to a given object -func (z Object) WithACLUserList(acl map[Action][]string) Object { +func (z Object) WithACLUserList(acl map[string][]Action) Object { return Object{ Owner: z.Owner, OrgID: z.OrgID, diff --git a/coderd/rbac/policy.rego b/coderd/rbac/policy.rego index 61003afadc875..f6115fad1ab8f 100644 --- a/coderd/rbac/policy.rego +++ b/coderd/rbac/policy.rego @@ -160,7 +160,6 @@ allow { # ACL Allow allow { # Should you have to be a member of the org too? - input.subject.id in input.object.acl_user_list[input.action] + perms := input.object.acl_user_list[input.subject.id] + input.action in perms } - - From 8378c9bbc67f5cbef5122bd394e8b11a0d7543e7 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Sat, 17 Sep 2022 23:06:42 +0000 Subject: [PATCH 05/37] feat: add template ACL --- coderd/database/databasefake/databasefake.go | 15 +++ coderd/database/db.go | 14 ++- coderd/database/dump.sql | 9 +- coderd/database/generate.sh | 2 +- .../migrations/000050_template_acl.down.sql | 0 .../migrations/000050_template_acl.up.sql | 11 ++ coderd/database/modelmethods.go | 53 ++++++++- coderd/database/modelqueries.go | 47 ++++++++ coderd/database/models.go | 21 ++++ coderd/database/models_custom.go | 1 + coderd/database/querier.go | 4 +- coderd/database/queries.sql.go | 23 ++-- coderd/database/queries/templates.sql | 5 +- coderd/database/sqlc.yaml | 1 + coderd/httpmw/templateversionparam.go | 17 +++ coderd/parameters.go | 14 ++- coderd/rbac/builtin.go | 5 - coderd/templates.go | 103 ++++++++++++++++-- coderd/templateversions.go | 60 +++++++--- codersdk/templates.go | 29 +++-- go.mod | 2 + go.sum | 3 + 22 files changed, 380 insertions(+), 59 deletions(-) create mode 100644 coderd/database/migrations/000050_template_acl.down.sql create mode 100644 coderd/database/migrations/000050_template_acl.up.sql create mode 100644 coderd/database/modelqueries.go create mode 100644 coderd/database/models_custom.go diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 76c0d278b8abe..a0d3446fc2ff4 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -1202,6 +1202,20 @@ func (q *fakeQuerier) GetTemplates(_ context.Context) ([]database.Template, erro return templates, nil } +func (q *fakeQuerier) UpdateTemplateUserACLByID(_ context.Context, id uuid.UUID, acl database.UserACL) error { + q.mutex.RLock() + defer q.mutex.RUnlock() + + for i, t := range q.templates { + if t.ID == id { + t = t.SetUserACL(acl) + q.templates[i] = t + return nil + } + } + return sql.ErrNoRows +} + func (q *fakeQuerier) GetOrganizationMemberByUserID(_ context.Context, arg database.GetOrganizationMemberByUserIDParams) (database.OrganizationMember, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -1657,6 +1671,7 @@ func (q *fakeQuerier) InsertTemplate(_ context.Context, arg database.InsertTempl MinAutostartInterval: arg.MinAutostartInterval, CreatedBy: arg.CreatedBy, } + template = template.SetUserACL(database.UserACL{}) q.templates = append(q.templates, template) return template, nil } diff --git a/coderd/database/db.go b/coderd/database/db.go index 0a9e8928df253..351dfc1e8897d 100644 --- a/coderd/database/db.go +++ b/coderd/database/db.go @@ -13,6 +13,7 @@ import ( "database/sql" "errors" + "github.com/jmoiron/sqlx" "golang.org/x/xerrors" ) @@ -36,18 +37,25 @@ type DBTX interface { func New(sdb *sql.DB) Store { return &sqlQuerier{ db: sdb, - sdb: sdb, + sdb: sqlx.NewDb(sdb, "postgres"), } } +// queries encompasses both are sqlc generated +// queries and our custom queries. +type querier interface { + sqlcQuerier + customQuerier +} + type sqlQuerier struct { - sdb *sql.DB + sdb *sqlx.DB db DBTX } // InTx performs database operations inside a transaction. func (q *sqlQuerier) InTx(function func(Store) error) error { - if _, ok := q.db.(*sql.Tx); ok { + if _, ok := q.db.(*sqlx.Tx); ok { // If the current inner "db" is already a transaction, we just reuse it. // We do not need to handle commit/rollback as the outer tx will handle // that. diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 4b91a6bdd5f08..0ec72fec24b88 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -78,6 +78,12 @@ CREATE TYPE resource_type AS ENUM ( 'api_key' ); +CREATE TYPE template_role AS ENUM ( + 'read', + 'write', + 'admin' +); + CREATE TYPE user_status AS ENUM ( 'active', 'suspended' @@ -279,7 +285,8 @@ CREATE TABLE templates ( max_ttl bigint DEFAULT '604800000000000'::bigint NOT NULL, min_autostart_interval bigint DEFAULT '3600000000000'::bigint NOT NULL, created_by uuid NOT NULL, - icon character varying(256) DEFAULT ''::character varying NOT NULL + icon character varying(256) DEFAULT ''::character varying NOT NULL, + user_acl jsonb DEFAULT '{}'::jsonb NOT NULL ); CREATE TABLE user_links ( diff --git a/coderd/database/generate.sh b/coderd/database/generate.sh index b6734acfddc65..2a5a54ecf786d 100755 --- a/coderd/database/generate.sh +++ b/coderd/database/generate.sh @@ -42,7 +42,7 @@ SCRIPT_DIR=$(dirname "${BASH_SOURCE[0]}") rm -f queries/*.go # Fix struct/interface names. - gofmt -w -r 'Querier -> querier' -- *.go + gofmt -w -r 'Querier -> sqlcQuerier' -- *.go gofmt -w -r 'Queries -> sqlQuerier' -- *.go # Ensure correct imports exist. Modules must all be downloaded so we get correct diff --git a/coderd/database/migrations/000050_template_acl.down.sql b/coderd/database/migrations/000050_template_acl.down.sql new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/coderd/database/migrations/000050_template_acl.up.sql b/coderd/database/migrations/000050_template_acl.up.sql new file mode 100644 index 0000000000000..01f5f6ad343b1 --- /dev/null +++ b/coderd/database/migrations/000050_template_acl.up.sql @@ -0,0 +1,11 @@ +BEGIN; + +ALTER TABLE templates ADD COLUMN user_acl jsonb NOT NULL default '{}'; + +CREATE TYPE template_role AS ENUM ( + 'read', + 'write', + 'admin' +); + +COMMIT; diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 6df4d67716f7d..e048c56cbc174 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -1,16 +1,63 @@ package database import ( + "encoding/json" + "fmt" + "github.com/coder/coder/coderd/rbac" ) +// UserACL is a map of user_ids to permissions. +type UserACL map[string]TemplateRole + +func (u UserACL) Actions() map[string][]rbac.Action { + aclRBAC := make(map[string][]rbac.Action, len(u)) + for k, v := range u { + aclRBAC[k] = templateRoleToActions(v) + } + + return aclRBAC +} + +func (t Template) UserACL() UserACL { + var acl UserACL + err := json.Unmarshal(t.userACL, &acl) + if err != nil { + panic(fmt.Sprintf("failed to unmarshal template.userACL: %v", err.Error())) + } + + return acl +} + +func (t Template) SetUserACL(acl UserACL) Template { + raw, err := json.Marshal(acl) + if err != nil { + panic(fmt.Sprintf("marshal user acl: %v", err)) + } + + t.userACL = raw + return t +} + +func templateRoleToActions(t TemplateRole) []rbac.Action { + switch t { + case TemplateRoleRead: + return []rbac.Action{rbac.ActionRead} + case TemplateRoleWrite: + return []rbac.Action{rbac.ActionRead, rbac.ActionUpdate} + case TemplateRoleAdmin: + return []rbac.Action{rbac.WildcardSymbol} + } + return nil +} + func (t Template) RBACObject() rbac.Object { - return rbac.ResourceTemplate.InOrg(t.OrganizationID) + return rbac.ResourceTemplate.InOrg(t.OrganizationID).WithACLUserList(t.UserACL().Actions()) } -func (t TemplateVersion) RBACObject() rbac.Object { +func (t TemplateVersion) RBACObject(template Template) rbac.Object { // Just use the parent template resource for controlling versions - return rbac.ResourceTemplate.InOrg(t.OrganizationID) + return rbac.ResourceTemplate.InOrg(t.OrganizationID).WithACLUserList(template.UserACL().Actions()) } func (w Workspace) RBACObject() rbac.Object { diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go new file mode 100644 index 0000000000000..6c8e8b5e2293e --- /dev/null +++ b/coderd/database/modelqueries.go @@ -0,0 +1,47 @@ +package database + +import ( + "context" + "encoding/json" + + "github.com/google/uuid" + "golang.org/x/xerrors" +) + +// customQuerier encompasses all non-generated queries. +// It provides a flexible way to write queries for cases +// where sqlc proves inadequate. +type customQuerier interface { + templateQuerier +} + +type templateQuerier interface { + UpdateTemplateUserACLByID(ctx context.Context, id uuid.UUID, acl UserACL) error +} + +type TemplateUser struct { + User + Role TemplateRole `db:"role"` +} + +func (q *sqlQuerier) UpdateTemplateUserACLByID(ctx context.Context, id uuid.UUID, acl UserACL) error { + raw, err := json.Marshal(acl) + if err != nil { + return xerrors.Errorf("marshal user acl: %w", err) + } + + const query = ` +UPDATE + templates +SET + user_acl = $2 +WHERE + id = $1` + + _, err = q.db.ExecContext(ctx, query, id.String(), raw) + if err != nil { + return xerrors.Errorf("update user acl: %w", err) + } + + return nil +} diff --git a/coderd/database/models.go b/coderd/database/models.go index c850b011bdffb..b0552631e0feb 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -274,6 +274,26 @@ func (e *ResourceType) Scan(src interface{}) error { return nil } +type TemplateRole string + +const ( + TemplateRoleRead TemplateRole = "read" + TemplateRoleWrite TemplateRole = "write" + TemplateRoleAdmin TemplateRole = "admin" +) + +func (e *TemplateRole) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = TemplateRole(s) + case string: + *e = TemplateRole(s) + default: + return fmt.Errorf("unsupported scan type for TemplateRole: %T", src) + } + return nil +} + type UserStatus string const ( @@ -481,6 +501,7 @@ type Template struct { MinAutostartInterval int64 `db:"min_autostart_interval" json:"min_autostart_interval"` CreatedBy uuid.UUID `db:"created_by" json:"created_by"` Icon string `db:"icon" json:"icon"` + userACL json.RawMessage `db:"user_acl" json:"user_acl"` } type TemplateVersion struct { diff --git a/coderd/database/models_custom.go b/coderd/database/models_custom.go new file mode 100644 index 0000000000000..636bab89ae8a6 --- /dev/null +++ b/coderd/database/models_custom.go @@ -0,0 +1 @@ +package database diff --git a/coderd/database/querier.go b/coderd/database/querier.go index c254a4ea62947..a271fa94213b8 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -11,7 +11,7 @@ import ( "github.com/google/uuid" ) -type querier interface { +type sqlcQuerier interface { // Acquires the lock for a single job that isn't started, completed, // canceled, and that matches an array of provisioner types. // @@ -154,4 +154,4 @@ type querier interface { UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error } -var _ querier = (*sqlQuerier)(nil) +var _ sqlcQuerier = (*sqlQuerier)(nil) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 88dd2091a7718..d076420cb480e 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2060,7 +2060,7 @@ func (q *sqlQuerier) InsertDeploymentID(ctx context.Context, value string) error const getTemplateByID = `-- name: GetTemplateByID :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl FROM templates WHERE @@ -2086,13 +2086,14 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.MinAutostartInterval, &i.CreatedBy, &i.Icon, + &i.userACL, ) return i, err } const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl FROM templates WHERE @@ -2126,12 +2127,13 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.MinAutostartInterval, &i.CreatedBy, &i.Icon, + &i.userACL, ) return i, err } const getTemplates = `-- name: GetTemplates :many -SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon FROM templates +SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl FROM templates ORDER BY (name, id) ASC ` @@ -2158,6 +2160,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.MinAutostartInterval, &i.CreatedBy, &i.Icon, + &i.userACL, ); err != nil { return nil, err } @@ -2174,7 +2177,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl FROM templates WHERE @@ -2236,6 +2239,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.MinAutostartInterval, &i.CreatedBy, &i.Icon, + &i.userACL, ); err != nil { return nil, err } @@ -2264,10 +2268,11 @@ INSERT INTO max_ttl, min_autostart_interval, created_by, - icon + icon, + user_acl ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl ` type InsertTemplateParams struct { @@ -2283,6 +2288,7 @@ type InsertTemplateParams struct { MinAutostartInterval int64 `db:"min_autostart_interval" json:"min_autostart_interval"` CreatedBy uuid.UUID `db:"created_by" json:"created_by"` Icon string `db:"icon" json:"icon"` + userACL json.RawMessage `db:"user_acl" json:"user_acl"` } func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) { @@ -2299,6 +2305,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam arg.MinAutostartInterval, arg.CreatedBy, arg.Icon, + arg.userACL, ) var i Template err := row.Scan( @@ -2315,6 +2322,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam &i.MinAutostartInterval, &i.CreatedBy, &i.Icon, + &i.userACL, ) return i, err } @@ -2374,7 +2382,7 @@ SET WHERE id = $1 RETURNING - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl ` type UpdateTemplateMetaByIDParams struct { @@ -2412,6 +2420,7 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl &i.MinAutostartInterval, &i.CreatedBy, &i.Icon, + &i.userACL, ) return i, err } diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 4d552443356fe..1e0d785b648ac 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -68,10 +68,11 @@ INSERT INTO max_ttl, min_autostart_interval, created_by, - icon + icon, + user_acl ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *; -- name: UpdateTemplateActiveVersionByID :exec UPDATE diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 800b94983488d..5c87b0ddac941 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -32,3 +32,4 @@ rename: ip_addresses: IPAddresses ids: IDs jwt: JWT + user_acl: userACL diff --git a/coderd/httpmw/templateversionparam.go b/coderd/httpmw/templateversionparam.go index 762f5dae44440..28d6f82acac80 100644 --- a/coderd/httpmw/templateversionparam.go +++ b/coderd/httpmw/templateversionparam.go @@ -45,8 +45,25 @@ func ExtractTemplateVersionParam(db database.Store) func(http.Handler) http.Hand return } + template, err := db.GetTemplateByID(r.Context(), templateVersion.TemplateID.UUID) + if errors.Is(err, sql.ErrNoRows) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching template.", + Detail: err.Error(), + }) + return + } + ctx := context.WithValue(r.Context(), templateVersionParamContextKey{}, templateVersion) chi.RouteContext(ctx).URLParams.Add("organization", templateVersion.OrganizationID.String()) + + ctx = context.WithValue(r.Context(), templateParamContextKey{}, template) + chi.RouteContext(ctx).URLParams.Add("organization", template.OrganizationID.String()) + next.ServeHTTP(rw, r.WithContext(ctx)) }) } diff --git a/coderd/parameters.go b/coderd/parameters.go index 7675e9ff9b1a8..8bf7c3382774f 100644 --- a/coderd/parameters.go +++ b/coderd/parameters.go @@ -215,7 +215,19 @@ func (api *API) parameterRBACResource(rw http.ResponseWriter, r *http.Request, s case database.ParameterScopeWorkspace: resource, err = api.Database.GetWorkspaceByID(ctx, scopeID) case database.ParameterScopeImportJob: - resource, err = api.Database.GetTemplateVersionByJobID(ctx, scopeID) + // I hate myself. + var version database.TemplateVersion + version, err = api.Database.GetTemplateVersionByJobID(ctx, scopeID) + if err != nil { + break + } + var template database.Template + template, err = api.Database.GetTemplateByID(ctx, version.TemplateID.UUID) + if err != nil { + break + } + resource = version.RBACObject(template) + case database.ParameterScopeTemplate: resource, err = api.Database.GetTemplateByID(ctx, scopeID) default: diff --git a/coderd/rbac/builtin.go b/coderd/rbac/builtin.go index 7fcca090e067f..008838708f22b 100644 --- a/coderd/rbac/builtin.go +++ b/coderd/rbac/builtin.go @@ -167,11 +167,6 @@ var ( ResourceType: ResourceOrganization.Type, Action: ActionRead, }, - { - // All org members can read templates in the org - ResourceType: ResourceTemplate.Type, - Action: ActionRead, - }, { // Can read available roles. ResourceType: ResourceOrgRoleAssignment.Type, diff --git a/coderd/templates.go b/coderd/templates.go index c48531a25c226..ccf0d28ad8f46 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -455,6 +455,15 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { return } + // Only users who are able to create templates (aka template admins) + // are able to control user permissions. + // TODO: It'd be nice to also assert delete since a template admin + // should be able to do both. + if len(req.UserPerms) > 0 && !api.Authorize(r, rbac.ActionCreate, template) { + httpapi.ResourceNotFound(rw) + return + } + var validErrs []codersdk.ValidationError if req.MaxTTLMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "max_ttl_ms", Detail: "Must be a positive integer."}) @@ -463,13 +472,13 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { validErrs = append(validErrs, codersdk.ValidationError{Field: "min_autostart_interval_ms", Detail: "Must be a positive integer."}) } if req.MaxTTLMillis > maxTTLDefault.Milliseconds() { - httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid create template request.", - Validations: []codersdk.ValidationError{ - {Field: "max_ttl_ms", Detail: "Cannot be greater than " + maxTTLDefault.String()}, - }, - }) - return + validErrs = append(validErrs, codersdk.ValidationError{Field: "max_ttl_ms", Detail: "Cannot be greater than " + maxTTLDefault.String()}) + } + + for _, v := range req.UserPerms { + if err := validateTemplateRole(v); err != nil { + validErrs = append(validErrs, codersdk.ValidationError{Field: "user_perms", Detail: err.Error()}) + } } if len(validErrs) > 0 { @@ -482,9 +491,9 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { count := uint32(0) var updated database.Template - err := api.Database.InTx(func(s database.Store) error { + err := api.Database.InTx(func(tx database.Store) error { // Fetch workspace counts - workspaceCounts, err := s.GetWorkspaceOwnerCountsByTemplateIDs(r.Context(), []uuid.UUID{template.ID}) + workspaceCounts, err := tx.GetWorkspaceOwnerCountsByTemplateIDs(r.Context(), []uuid.UUID{template.ID}) if xerrors.Is(err, sql.ErrNoRows) { err = nil } @@ -521,7 +530,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { minAutostartInterval = time.Duration(template.MinAutostartInterval) } - updated, err = s.UpdateTemplateMetaByID(r.Context(), database.UpdateTemplateMetaByIDParams{ + updated, err = tx.UpdateTemplateMetaByID(r.Context(), database.UpdateTemplateMetaByIDParams{ ID: template.ID, UpdatedAt: database.Now(), Name: name, @@ -534,6 +543,22 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { return err } + if len(req.UserPerms) > 0 { + userACL := template.UserACL() + for k, v := range req.UserPerms { + if len(v) == 0 { + delete(userACL, k) + continue + } + userACL[k] = database.TemplateRole(v) + } + + err = tx.UpdateTemplateUserACLByID(r.Context(), template.ID, userACL) + if err != nil { + return xerrors.Errorf("update template user ACL: %w", err) + } + } + return nil }) if err != nil { @@ -775,5 +800,63 @@ func (api *API) convertTemplate( MinAutostartIntervalMillis: time.Duration(template.MinAutostartInterval).Milliseconds(), CreatedByID: template.CreatedBy, CreatedByName: createdByName, + UserRoles: convertTemplateACL(template.UserACL()), } } + +func convertTemplateACL(acl database.UserACL) codersdk.TemplateUserACL { + userACL := make(codersdk.TemplateUserACL, len(acl)) + for k, v := range acl { + userACL[k] = convertDatabaseTemplateRole(v) + } + + return userACL +} + +func convertDatabaseTemplateRole(role database.TemplateRole) codersdk.TemplateRole { + switch role { + case database.TemplateRoleAdmin: + return codersdk.TemplateRoleAdmin + case database.TemplateRoleWrite: + return codersdk.TemplateRoleWrite + case database.TemplateRoleRead: + return codersdk.TemplateRoleRead + } + + return "" +} + +func convertSDKTemplateRole(role codersdk.TemplateRole) database.TemplateRole { + switch role { + case codersdk.TemplateRoleAdmin: + return database.TemplateRoleAdmin + case codersdk.TemplateRoleWrite: + return database.TemplateRoleWrite + case codersdk.TemplateRoleRead: + return database.TemplateRoleRead + } + + return "" +} + +func templateRoleToActions(role codersdk.TemplateRole) []string { + switch role { + case codersdk.TemplateRoleAdmin: + return []string{rbac.WildcardSymbol} + case codersdk.TemplateRoleWrite: + return []string{rbac.ActionRead, rbac.ActionUpdate} + case codersdk.TemplateRoleRead: + return []string{rbac.ActionRead} + } + + return nil +} + +func validateTemplateRole(role codersdk.TemplateRole) error { + dbRole := convertSDKTemplateRole(role) + if dbRole == "" { + return xerrors.Errorf("role %q is not a valid Template role", role) + } + + return nil +} diff --git a/coderd/templateversions.go b/coderd/templateversions.go index ef8a3ff85c94e..0a2f2fb98123f 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -23,8 +23,11 @@ import ( ) func (api *API) templateVersion(rw http.ResponseWriter, r *http.Request) { - templateVersion := httpmw.TemplateVersionParam(r) - if !api.Authorize(r, rbac.ActionRead, templateVersion) { + var ( + templateVersion = httpmw.TemplateVersionParam(r) + template = httpmw.TemplateParam(r) + ) + if !api.Authorize(r, rbac.ActionRead, templateVersion.RBACObject(template)) { httpapi.ResourceNotFound(rw) return } @@ -51,8 +54,11 @@ func (api *API) templateVersion(rw http.ResponseWriter, r *http.Request) { } func (api *API) patchCancelTemplateVersion(rw http.ResponseWriter, r *http.Request) { - templateVersion := httpmw.TemplateVersionParam(r) - if !api.Authorize(r, rbac.ActionUpdate, templateVersion) { + var ( + templateVersion = httpmw.TemplateVersionParam(r) + template = httpmw.TemplateParam(r) + ) + if !api.Authorize(r, rbac.ActionUpdate, templateVersion.RBACObject(template)) { httpapi.ResourceNotFound(rw) return } @@ -97,8 +103,12 @@ func (api *API) patchCancelTemplateVersion(rw http.ResponseWriter, r *http.Reque } func (api *API) templateVersionSchema(rw http.ResponseWriter, r *http.Request) { - templateVersion := httpmw.TemplateVersionParam(r) - if !api.Authorize(r, rbac.ActionRead, templateVersion) { + var ( + templateVersion = httpmw.TemplateVersionParam(r) + template = httpmw.TemplateParam(r) + ) + + if !api.Authorize(r, rbac.ActionRead, templateVersion.RBACObject(template)) { httpapi.ResourceNotFound(rw) return } @@ -144,9 +154,12 @@ func (api *API) templateVersionSchema(rw http.ResponseWriter, r *http.Request) { } func (api *API) templateVersionParameters(rw http.ResponseWriter, r *http.Request) { - apiKey := httpmw.APIKey(r) - templateVersion := httpmw.TemplateVersionParam(r) - if !api.Authorize(r, rbac.ActionRead, templateVersion) { + var ( + apiKey = httpmw.APIKey(r) + templateVersion = httpmw.TemplateVersionParam(r) + template = httpmw.TemplateParam(r) + ) + if !api.Authorize(r, rbac.ActionRead, templateVersion.RBACObject(template)) { httpapi.ResourceNotFound(rw) return } @@ -188,9 +201,12 @@ func (api *API) templateVersionParameters(rw http.ResponseWriter, r *http.Reques } func (api *API) postTemplateVersionDryRun(rw http.ResponseWriter, r *http.Request) { - apiKey := httpmw.APIKey(r) - templateVersion := httpmw.TemplateVersionParam(r) - if !api.Authorize(r, rbac.ActionRead, templateVersion) { + var ( + apiKey = httpmw.APIKey(r) + templateVersion = httpmw.TemplateVersionParam(r) + template = httpmw.TemplateParam(r) + ) + if !api.Authorize(r, rbac.ActionRead, templateVersion.RBACObject(template)) { httpapi.ResourceNotFound(rw) return } @@ -352,9 +368,11 @@ func (api *API) patchTemplateVersionDryRunCancel(rw http.ResponseWriter, r *http func (api *API) fetchTemplateVersionDryRunJob(rw http.ResponseWriter, r *http.Request) (database.ProvisionerJob, bool) { var ( templateVersion = httpmw.TemplateVersionParam(r) + template = httpmw.TemplateParam(r) jobID = chi.URLParam(r, "jobID") ) - if !api.Authorize(r, rbac.ActionRead, templateVersion) { + + if !api.Authorize(r, rbac.ActionRead, templateVersion.RBACObject(template)) { httpapi.ResourceNotFound(rw) return database.ProvisionerJob{}, false } @@ -825,8 +843,12 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht // The agents returned are informative of the template version, and do not // return agents associated with any particular workspace. func (api *API) templateVersionResources(rw http.ResponseWriter, r *http.Request) { - templateVersion := httpmw.TemplateVersionParam(r) - if !api.Authorize(r, rbac.ActionRead, templateVersion) { + var ( + templateVersion = httpmw.TemplateVersionParam(r) + template = httpmw.TemplateParam(r) + ) + + if !api.Authorize(r, rbac.ActionRead, templateVersion.RBACObject(template)) { httpapi.ResourceNotFound(rw) return } @@ -847,8 +869,12 @@ func (api *API) templateVersionResources(rw http.ResponseWriter, r *http.Request // and not any build logs for a workspace. // Eg: Logs returned from 'terraform plan' when uploading a new terraform file. func (api *API) templateVersionLogs(rw http.ResponseWriter, r *http.Request) { - templateVersion := httpmw.TemplateVersionParam(r) - if !api.Authorize(r, rbac.ActionRead, templateVersion) { + var ( + templateVersion = httpmw.TemplateVersionParam(r) + template = httpmw.TemplateParam(r) + ) + + if !api.Authorize(r, rbac.ActionRead, templateVersion.RBACObject(template)) { httpapi.ResourceNotFound(rw) return } diff --git a/codersdk/templates.go b/codersdk/templates.go index 3af058cc19719..187a5e72d5e75 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -23,25 +23,40 @@ type Template struct { ActiveVersionID uuid.UUID `json:"active_version_id"` WorkspaceOwnerCount uint32 `json:"workspace_owner_count"` // ActiveUserCount is set to -1 when loading. - ActiveUserCount int `json:"active_user_count"` - Description string `json:"description"` - Icon string `json:"icon"` - MaxTTLMillis int64 `json:"max_ttl_ms"` - MinAutostartIntervalMillis int64 `json:"min_autostart_interval_ms"` - CreatedByID uuid.UUID `json:"created_by_id"` - CreatedByName string `json:"created_by_name"` + ActiveUserCount int `json:"active_user_count"` + Description string `json:"description"` + Icon string `json:"icon"` + MaxTTLMillis int64 `json:"max_ttl_ms"` + MinAutostartIntervalMillis int64 `json:"min_autostart_interval_ms"` + CreatedByID uuid.UUID `json:"created_by_id"` + CreatedByName string `json:"created_by_name"` + UserRoles map[string]TemplateRole `json:"user_roles"` } type UpdateActiveTemplateVersion struct { ID uuid.UUID `json:"id" validate:"required"` } +type TemplateUserACL map[string]TemplateRole + +type TemplateRole string + +var ( + TemplateRoleAdmin TemplateRole = "admin" + TemplateRoleWrite TemplateRole = "write" + TemplateRoleRead TemplateRole = "read" +) + type UpdateTemplateMeta struct { Name string `json:"name,omitempty" validate:"omitempty,username"` Description string `json:"description,omitempty"` Icon string `json:"icon,omitempty"` MaxTTLMillis int64 `json:"max_ttl_ms,omitempty"` MinAutostartIntervalMillis int64 `json:"min_autostart_interval_ms,omitempty"` + // UserPerms is a map of user IDs to their corresponding role. + // In order to delete a user's permissions set a user's + // role to the empty string. + UserPerms map[string]TemplateRole `json:"user_perms"` } // Template returns a single template. diff --git a/go.mod b/go.mod index 13832bcfd4cf2..544874b7f94e9 100644 --- a/go.mod +++ b/go.mod @@ -170,6 +170,8 @@ require ( tailscale.com v1.30.0 ) +require github.com/jmoiron/sqlx v1.3.5 // indirect + require ( filippo.io/edwards25519 v1.0.0-rc.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect diff --git a/go.sum b/go.sum index e3d95afa7bd3a..852d94bb0b6da 100644 --- a/go.sum +++ b/go.sum @@ -719,6 +719,7 @@ github.com/go-redis/redis v6.15.8+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8w github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= @@ -1126,6 +1127,8 @@ github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHW github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= github.com/jmoiron/sqlx v1.3.1/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= github.com/joefitzgerald/rainbow-reporter v0.1.0/go.mod h1:481CNgqmVHQZzdIbN52CupLJyoVwB10FQ/IQlF1pdL8= github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 h1:rp+c0RAYOWj8l6qbCUTSiRLG/iKnW3K3/QfPPuSsBt4= github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= From 54a0d13c13de82c58d84272a01357dc9351067be Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 19 Sep 2022 16:05:30 +0000 Subject: [PATCH 06/37] add down migration --- coderd/database/migrations/000050_template_acl.down.sql | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/coderd/database/migrations/000050_template_acl.down.sql b/coderd/database/migrations/000050_template_acl.down.sql index e69de29bb2d1d..8db5815e79a73 100644 --- a/coderd/database/migrations/000050_template_acl.down.sql +++ b/coderd/database/migrations/000050_template_acl.down.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TABLE templates DROP COLUMN user_acl; +DROP TYPE template_role; + +COMMIT; From 72ea751bd5d4ccf8aaa0c462c8ca9c5f3e8b9f85 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 19 Sep 2022 16:06:34 +0000 Subject: [PATCH 07/37] remove unused file --- coderd/database/models_custom.go | 1 - 1 file changed, 1 deletion(-) delete mode 100644 coderd/database/models_custom.go diff --git a/coderd/database/models_custom.go b/coderd/database/models_custom.go deleted file mode 100644 index 636bab89ae8a6..0000000000000 --- a/coderd/database/models_custom.go +++ /dev/null @@ -1 +0,0 @@ -package database From d533a16fccb96523af8b1b795bbddcbfc4e97492 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 19 Sep 2022 16:07:30 +0000 Subject: [PATCH 08/37] undo insert templates query change --- coderd/database/queries/templates.sql | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 1e0d785b648ac..4d552443356fe 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -68,11 +68,10 @@ INSERT INTO max_ttl, min_autostart_interval, created_by, - icon, - user_acl + icon ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *; -- name: UpdateTemplateActiveVersionByID :exec UPDATE From f56fcf9ff082e70a84e51139a593817e76e2cbf3 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 19 Sep 2022 17:31:10 +0000 Subject: [PATCH 09/37] add patch endpoint tests --- coderd/database/modelmethods.go | 3 +- coderd/templates.go | 69 ++++++----- coderd/templates_test.go | 211 ++++++++++++++++++++++++++++++++ coderd/workspaces_test.go | 4 +- codersdk/error.go | 5 + codersdk/templates.go | 7 +- 6 files changed, 261 insertions(+), 38 deletions(-) diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index e048c56cbc174..2501f842df21b 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -46,7 +46,8 @@ func templateRoleToActions(t TemplateRole) []rbac.Action { case TemplateRoleWrite: return []rbac.Action{rbac.ActionRead, rbac.ActionUpdate} case TemplateRoleAdmin: - return []rbac.Action{rbac.WildcardSymbol} + // TODO: Why does rbac.Wildcard not work here? + return []rbac.Action{rbac.ActionRead, rbac.ActionUpdate, rbac.ActionCreate, rbac.ActionDelete} } return nil } diff --git a/coderd/templates.go b/coderd/templates.go index ccf0d28ad8f46..326fe3825f881 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -457,8 +457,9 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { // Only users who are able to create templates (aka template admins) // are able to control user permissions. - // TODO: It'd be nice to also assert delete since a template admin - // should be able to do both. + // TODO: It might be cleaner to control template perms access + // via a separate RBAC resource, and restrict all actions to the template + // admin role. if len(req.UserPerms) > 0 && !api.Authorize(r, rbac.ActionCreate, template) { httpapi.ResourceNotFound(rw) return @@ -475,9 +476,23 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { validErrs = append(validErrs, codersdk.ValidationError{Field: "max_ttl_ms", Detail: "Cannot be greater than " + maxTTLDefault.String()}) } - for _, v := range req.UserPerms { + for k, v := range req.UserPerms { if err := validateTemplateRole(v); err != nil { validErrs = append(validErrs, codersdk.ValidationError{Field: "user_perms", Detail: err.Error()}) + continue + } + + userID, err := uuid.Parse(k) + if err != nil { + validErrs = append(validErrs, codersdk.ValidationError{Field: "user_perms", Detail: "User ID " + k + "must be a valid UUID."}) + continue + } + + // This could get slow if we get a ton of user perm updates. + _, err = api.Database.GetUserByID(r.Context(), userID) + if err != nil { + validErrs = append(validErrs, codersdk.ValidationError{Field: "user_perms", Detail: fmt.Sprintf("Failed to find user with ID %q: %v", k, err.Error())}) + continue } } @@ -509,7 +524,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { req.Description == template.Description && req.Icon == template.Icon && req.MaxTTLMillis == time.Duration(template.MaxTtl).Milliseconds() && - req.MinAutostartIntervalMillis == time.Duration(template.MinAutostartInterval).Milliseconds() { + req.MinAutostartIntervalMillis == time.Duration(template.MinAutostartInterval).Milliseconds() && + len(req.UserPerms) == 0 { return nil } @@ -530,23 +546,12 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { minAutostartInterval = time.Duration(template.MinAutostartInterval) } - updated, err = tx.UpdateTemplateMetaByID(r.Context(), database.UpdateTemplateMetaByIDParams{ - ID: template.ID, - UpdatedAt: database.Now(), - Name: name, - Description: desc, - Icon: icon, - MaxTtl: int64(maxTTL), - MinAutostartInterval: int64(minAutostartInterval), - }) - if err != nil { - return err - } - if len(req.UserPerms) > 0 { userACL := template.UserACL() for k, v := range req.UserPerms { - if len(v) == 0 { + // A user with an empty string implies + // deletion. + if v == "" { delete(userACL, k) continue } @@ -559,6 +564,19 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { } } + updated, err = tx.UpdateTemplateMetaByID(r.Context(), database.UpdateTemplateMetaByIDParams{ + ID: template.ID, + UpdatedAt: database.Now(), + Name: name, + Description: desc, + Icon: icon, + MaxTtl: int64(maxTTL), + MinAutostartInterval: int64(minAutostartInterval), + }) + if err != nil { + return err + } + return nil }) if err != nil { @@ -839,22 +857,9 @@ func convertSDKTemplateRole(role codersdk.TemplateRole) database.TemplateRole { return "" } -func templateRoleToActions(role codersdk.TemplateRole) []string { - switch role { - case codersdk.TemplateRoleAdmin: - return []string{rbac.WildcardSymbol} - case codersdk.TemplateRoleWrite: - return []string{rbac.ActionRead, rbac.ActionUpdate} - case codersdk.TemplateRoleRead: - return []string{rbac.ActionRead} - } - - return nil -} - func validateTemplateRole(role codersdk.TemplateRole) error { dbRole := convertSDKTemplateRole(role) - if dbRole == "" { + if dbRole == "" && role != codersdk.TemplateRoleDeleted { return xerrors.Errorf("role %q is not a valid Template role", role) } diff --git a/coderd/templates_test.go b/coderd/templates_test.go index d3bcbd47dc33a..5b979a102f6da 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -519,6 +519,217 @@ func TestPatchTemplateMeta(t *testing.T) { require.NoError(t, err) assert.Equal(t, updated.Icon, "") }) + + t.Run("UserPerms", func(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + req := codersdk.UpdateTemplateMeta{ + UserPerms: map[string]codersdk.TemplateRole{ + user2.ID.String(): codersdk.TemplateRoleRead, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + template, err := client.UpdateTemplateMeta(ctx, template.ID, req) + require.NoError(t, err) + + role, ok := template.UserRoles[user2.ID.String()] + require.True(t, ok, "User not contained within user_roles map") + require.Equal(t, codersdk.TemplateRoleRead, role) + }) + + t.Run("DeleteUser", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + req := codersdk.UpdateTemplateMeta{ + UserPerms: map[string]codersdk.TemplateRole{ + user2.ID.String(): codersdk.TemplateRoleRead, + user3.ID.String(): codersdk.TemplateRoleWrite, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + template, err := client.UpdateTemplateMeta(ctx, template.ID, req) + require.NoError(t, err) + + role, ok := template.UserRoles[user2.ID.String()] + require.True(t, ok, "User not contained within user_roles map") + require.Equal(t, codersdk.TemplateRoleRead, role) + + role, ok = template.UserRoles[user3.ID.String()] + require.True(t, ok, "User not contained within user_roles map") + require.Equal(t, codersdk.TemplateRoleWrite, role) + + req = codersdk.UpdateTemplateMeta{ + UserPerms: map[string]codersdk.TemplateRole{ + user2.ID.String(): codersdk.TemplateRoleAdmin, + user3.ID.String(): codersdk.TemplateRoleDeleted, + }, + } + + template, err = client.UpdateTemplateMeta(ctx, template.ID, req) + require.NoError(t, err) + + role, ok = template.UserRoles[user2.ID.String()] + require.True(t, ok, "User not contained within user_roles map") + require.Equal(t, codersdk.TemplateRoleAdmin, role) + + _, ok = template.UserRoles[user3.ID.String()] + require.False(t, ok, "User should have been deleted from user_roles map") + }) + + t.Run("InvalidUUID", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + req := codersdk.UpdateTemplateMeta{ + UserPerms: map[string]codersdk.TemplateRole{ + "hi": "admin", + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.UpdateTemplateMeta(ctx, template.ID, req) + require.Error(t, err) + cerr, _ := codersdk.AsError(err) + require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) + }) + + t.Run("InvalidUser", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + req := codersdk.UpdateTemplateMeta{ + UserPerms: map[string]codersdk.TemplateRole{ + uuid.NewString(): "admin", + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.UpdateTemplateMeta(ctx, template.ID, req) + require.Error(t, err) + cerr, _ := codersdk.AsError(err) + require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) + }) + + t.Run("InvalidRole", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + req := codersdk.UpdateTemplateMeta{ + UserPerms: map[string]codersdk.TemplateRole{ + user2.ID.String(): "updater", + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.UpdateTemplateMeta(ctx, template.ID, req) + require.Error(t, err) + cerr, _ := codersdk.AsError(err) + require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) + }) + + t.Run("RegularUserCannotUpdatePerms", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + client2, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + req := codersdk.UpdateTemplateMeta{ + UserPerms: map[string]codersdk.TemplateRole{ + user2.ID.String(): codersdk.TemplateRoleWrite, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + template, err := client.UpdateTemplateMeta(ctx, template.ID, req) + require.NoError(t, err) + + req = codersdk.UpdateTemplateMeta{ + UserPerms: map[string]codersdk.TemplateRole{ + user2.ID.String(): codersdk.TemplateRoleAdmin, + }, + } + + template, err = client2.UpdateTemplateMeta(ctx, template.ID, req) + require.Error(t, err) + cerr, _ := codersdk.AsError(err) + require.Equal(t, http.StatusNotFound, cerr.StatusCode()) + }) + + t.Run("RegularUserWithAdminCanUpdate", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + client2, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + req := codersdk.UpdateTemplateMeta{ + UserPerms: map[string]codersdk.TemplateRole{ + user2.ID.String(): codersdk.TemplateRoleAdmin, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + template, err := client.UpdateTemplateMeta(ctx, template.ID, req) + require.NoError(t, err) + + req = codersdk.UpdateTemplateMeta{ + UserPerms: map[string]codersdk.TemplateRole{ + user3.ID.String(): codersdk.TemplateRoleRead, + }, + } + + template, err = client2.UpdateTemplateMeta(ctx, template.ID, req) + require.NoError(t, err) + + role, ok := template.UserRoles[user3.ID.String()] + require.True(t, ok, "User not contained within user_roles map") + require.Equal(t, codersdk.TemplateRoleRead, role) + }) + }) } func TestDeleteTemplate(t *testing.T) { diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index bbaba4e37924c..442b51258ffa0 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -25,10 +25,10 @@ import ( "github.com/coder/coder/testutil" ) -func TestWorkspace(t *testing.T) { +func TestWorkspaces(t *testing.T) { t.Parallel() - t.Run("OK", func(t *testing.T) { + t.Run("OKK", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user := coderdtest.CreateFirstUser(t, client) diff --git a/codersdk/error.go b/codersdk/error.go index 9b99ef97cfe18..f215bac67e90f 100644 --- a/codersdk/error.go +++ b/codersdk/error.go @@ -51,3 +51,8 @@ func IsConnectionErr(err error) bool { return xerrors.As(err, &dnsErr) || xerrors.As(err, &opErr) } + +func AsError(err error) (*Error, bool) { + var e *Error + return e, xerrors.As(err, &e) +} diff --git a/codersdk/templates.go b/codersdk/templates.go index 187a5e72d5e75..c9b937e670bf0 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -42,9 +42,10 @@ type TemplateUserACL map[string]TemplateRole type TemplateRole string var ( - TemplateRoleAdmin TemplateRole = "admin" - TemplateRoleWrite TemplateRole = "write" - TemplateRoleRead TemplateRole = "read" + TemplateRoleAdmin TemplateRole = "admin" + TemplateRoleWrite TemplateRole = "write" + TemplateRoleRead TemplateRole = "read" + TemplateRoleDeleted TemplateRole = "" ) type UpdateTemplateMeta struct { From f162694902da1d884dadcdaff2a0429a7b6be772 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 19 Sep 2022 13:49:53 -0400 Subject: [PATCH 10/37] Unit test use shadowed copied value --- coderd/rbac/authz_internal_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index a88dcef03e08c..a2bed17833713 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -662,6 +662,7 @@ func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTes require.NoError(t, err) for _, cases := range sets { for _, c := range cases { + c := c t.Run(name, func(t *testing.T) { t.Parallel() for _, a := range c.actions { From ea25c088c6ece25f56365742d16823bf2fe8ca48 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 19 Sep 2022 14:08:27 -0400 Subject: [PATCH 11/37] Allow wildcards for ACL list --- coderd/rbac/authz_internal_test.go | 7 +++++++ coderd/rbac/policy.rego | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index a2bed17833713..72619cd10b8d3 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -199,6 +199,13 @@ func TestAuthorizeDomain(t *testing.T) { actions: allActions(), allow: true, }, + { + resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithACLUserList(map[string][]Action{ + user.UserID: {WildcardSymbol}, + }), + actions: allActions(), + allow: true, + }, { resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithACLUserList(map[string][]Action{ user.UserID: {ActionRead, ActionUpdate}, diff --git a/coderd/rbac/policy.rego b/coderd/rbac/policy.rego index f6115fad1ab8f..9b0761edae247 100644 --- a/coderd/rbac/policy.rego +++ b/coderd/rbac/policy.rego @@ -163,3 +163,8 @@ allow { perms := input.object.acl_user_list[input.subject.id] input.action in perms } + +# ACL wildcard allow +allow { + "*" in input.object.acl_user_list[input.subject.id] +} From 5a081eb6ceb33725a45f29563f333787007625dd Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 19 Sep 2022 18:25:52 +0000 Subject: [PATCH 12/37] fix authorize bug --- coderd/coderdtest/coderdtest.go | 1 + coderd/database/modelmethods.go | 4 ++++ coderd/httpmw/templateversionparam.go | 11 ++++------- coderd/templateversions.go | 1 + 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 490dce5a125a6..c80142a949964 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -399,6 +399,7 @@ func createAnotherUserRetry(t *testing.T, client *codersdk.Client, organizationI // with the responses provided. It uses the "echo" provisioner for compatibility // with testing. func CreateTemplateVersion(t *testing.T, client *codersdk.Client, organizationID uuid.UUID, res *echo.Responses) codersdk.TemplateVersion { + t.Helper() data, err := echo.Tar(res) require.NoError(t, err) file, err := client.Upload(context.Background(), codersdk.ContentTypeTar, data) diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 2501f842df21b..735a53394c67c 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -21,6 +21,10 @@ func (u UserACL) Actions() map[string][]rbac.Action { func (t Template) UserACL() UserACL { var acl UserACL + if len(t.userACL) == 0 { + return acl + } + err := json.Unmarshal(t.userACL, &acl) if err != nil { panic(fmt.Sprintf("failed to unmarshal template.userACL: %v", err.Error())) diff --git a/coderd/httpmw/templateversionparam.go b/coderd/httpmw/templateversionparam.go index 28d6f82acac80..d142768bc99f1 100644 --- a/coderd/httpmw/templateversionparam.go +++ b/coderd/httpmw/templateversionparam.go @@ -7,6 +7,7 @@ import ( "net/http" "github.com/go-chi/chi/v5" + "golang.org/x/xerrors" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" @@ -32,6 +33,7 @@ func ExtractTemplateVersionParam(db database.Store) func(http.Handler) http.Hand if !parsed { return } + templateVersion, err := db.GetTemplateVersionByID(r.Context(), templateVersionID) if errors.Is(err, sql.ErrNoRows) { httpapi.ResourceNotFound(rw) @@ -46,11 +48,7 @@ func ExtractTemplateVersionParam(db database.Store) func(http.Handler) http.Hand } template, err := db.GetTemplateByID(r.Context(), templateVersion.TemplateID.UUID) - if errors.Is(err, sql.ErrNoRows) { - httpapi.ResourceNotFound(rw) - return - } - if err != nil { + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching template.", Detail: err.Error(), @@ -61,8 +59,7 @@ func ExtractTemplateVersionParam(db database.Store) func(http.Handler) http.Hand ctx := context.WithValue(r.Context(), templateVersionParamContextKey{}, templateVersion) chi.RouteContext(ctx).URLParams.Add("organization", templateVersion.OrganizationID.String()) - ctx = context.WithValue(r.Context(), templateParamContextKey{}, template) - chi.RouteContext(ctx).URLParams.Add("organization", template.OrganizationID.String()) + ctx = context.WithValue(ctx, templateParamContextKey{}, template) next.ServeHTTP(rw, r.WithContext(ctx)) }) diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 0a2f2fb98123f..055864b0d7530 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -27,6 +27,7 @@ func (api *API) templateVersion(rw http.ResponseWriter, r *http.Request) { templateVersion = httpmw.TemplateVersionParam(r) template = httpmw.TemplateParam(r) ) + if !api.Authorize(r, rbac.ActionRead, templateVersion.RBACObject(template)) { httpapi.ResourceNotFound(rw) return From 072b3e4627cbb4ff51c0b5e52d991554f5e31728 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 19 Sep 2022 15:10:30 -0400 Subject: [PATCH 13/37] feat: Allow filter to accept objects of multiple types --- coderd/rbac/authz.go | 28 +++++++++++++++++----------- coderd/rbac/authz_internal_test.go | 9 --------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index d124aae29bc24..54c5955c22cae 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -19,28 +19,34 @@ type PreparedAuthorized interface { } // Filter takes in a list of objects, and will filter the list removing all -// the elements the subject does not have permission for. All objects must be -// of the same type. +// the elements the subject does not have permission for. This function slows +// down if the list contains objects of multiple types. Attempt to only +// filter objects of the same type for faster performance. func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, subjRoles []string, action Action, objects []O) ([]O, error) { if len(objects) == 0 { // Nothing to filter return objects, nil } - objectType := objects[0].RBACObject().Type filtered := make([]O, 0) - prepared, err := auth.PrepareByRoleName(ctx, subjID, subjRoles, action, objectType) - if err != nil { - return nil, xerrors.Errorf("prepare: %w", err) - } + prepared := make(map[string]PreparedAuthorized) for i := range objects { object := objects[i] - rbacObj := object.RBACObject() - if rbacObj.Type != objectType { - return nil, xerrors.Errorf("object types must be uniform across the set (%s), found %s", objectType, object.RBACObject().Type) + objectType := object.RBACObject().Type + // objectAuth is the prepared authorization for the object type. + objectAuth, ok := prepared[object.RBACObject().Type] + if !ok { + var err error + objectAuth, err = auth.PrepareByRoleName(ctx, subjID, subjRoles, action, objectType) + if err != nil { + return nil, xerrors.Errorf("prepare: %w", err) + } + prepared[objectType] = objectAuth } - err := prepared.Authorize(ctx, rbacObj) + + rbacObj := object.RBACObject() + err := objectAuth.Authorize(ctx, rbacObj) if err == nil { filtered = append(filtered, object) } diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index 72619cd10b8d3..2958ac34a9898 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -37,15 +37,6 @@ func (w fakeObject) RBACObject() Object { } } -func TestFilterError(t *testing.T) { - t.Parallel() - auth, err := NewAuthorizer() - require.NoError(t, err) - - _, err = Filter(context.Background(), auth, uuid.NewString(), []string{}, ActionRead, []Object{ResourceUser, ResourceWorkspace}) - require.ErrorContains(t, err, "object types must be uniform") -} - // TestFilter ensures the filter acts the same as an individual authorize. // It generates a random set of objects, then runs the Filter batch function // against the singular ByRoleName function. From 205c36c7718eba89ab1f45be997298b7640b2059 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 19 Sep 2022 20:24:36 +0000 Subject: [PATCH 14/37] add support for private templates --- coderd/database/databasefake/databasefake.go | 2 + coderd/database/dump.sql | 3 +- .../migrations/000050_template_acl.down.sql | 1 + .../migrations/000050_template_acl.up.sql | 1 + coderd/database/modelmethods.go | 10 ++- coderd/database/models.go | 1 + coderd/database/queries.sql.go | 29 ++++--- coderd/database/queries/templates.sql | 8 +- coderd/rbac/builtin.go | 13 ++- coderd/rbac/object.go | 4 + coderd/templates.go | 16 ++-- coderd/templates_test.go | 86 +++++++++++++++++++ codersdk/organizations.go | 1 + codersdk/templates.go | 17 ++-- 14 files changed, 160 insertions(+), 32 deletions(-) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index a0d3446fc2ff4..5863d25a690fb 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -970,6 +970,7 @@ func (q *fakeQuerier) UpdateTemplateMetaByID(_ context.Context, arg database.Upd tpl.Icon = arg.Icon tpl.MaxTtl = arg.MaxTtl tpl.MinAutostartInterval = arg.MinAutostartInterval + tpl.IsPrivate = arg.IsPrivate q.templates[idx] = tpl return tpl, nil } @@ -1670,6 +1671,7 @@ func (q *fakeQuerier) InsertTemplate(_ context.Context, arg database.InsertTempl MaxTtl: arg.MaxTtl, MinAutostartInterval: arg.MinAutostartInterval, CreatedBy: arg.CreatedBy, + IsPrivate: arg.IsPrivate, } template = template.SetUserACL(database.UserACL{}) q.templates = append(q.templates, template) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 0ec72fec24b88..596c4672a5e02 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -286,7 +286,8 @@ CREATE TABLE templates ( min_autostart_interval bigint DEFAULT '3600000000000'::bigint NOT NULL, created_by uuid NOT NULL, icon character varying(256) DEFAULT ''::character varying NOT NULL, - user_acl jsonb DEFAULT '{}'::jsonb NOT NULL + user_acl jsonb DEFAULT '{}'::jsonb NOT NULL, + is_private boolean DEFAULT false NOT NULL ); CREATE TABLE user_links ( diff --git a/coderd/database/migrations/000050_template_acl.down.sql b/coderd/database/migrations/000050_template_acl.down.sql index 8db5815e79a73..55019e33d326d 100644 --- a/coderd/database/migrations/000050_template_acl.down.sql +++ b/coderd/database/migrations/000050_template_acl.down.sql @@ -1,6 +1,7 @@ BEGIN; ALTER TABLE templates DROP COLUMN user_acl; +ALTER TABLE templates DROP COLUMN is_private; DROP TYPE template_role; COMMIT; diff --git a/coderd/database/migrations/000050_template_acl.up.sql b/coderd/database/migrations/000050_template_acl.up.sql index 01f5f6ad343b1..5f98045f53cbe 100644 --- a/coderd/database/migrations/000050_template_acl.up.sql +++ b/coderd/database/migrations/000050_template_acl.up.sql @@ -1,6 +1,7 @@ BEGIN; ALTER TABLE templates ADD COLUMN user_acl jsonb NOT NULL default '{}'; +ALTER TABLE templates ADD COLUMN is_private boolean NOT NULL default 'false'; CREATE TYPE template_role AS ENUM ( 'read', diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 735a53394c67c..dbacbdcca42bf 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -57,12 +57,16 @@ func templateRoleToActions(t TemplateRole) []rbac.Action { } func (t Template) RBACObject() rbac.Object { - return rbac.ResourceTemplate.InOrg(t.OrganizationID).WithACLUserList(t.UserACL().Actions()) + obj := rbac.ResourceTemplate + if t.IsPrivate { + obj = rbac.ResourceTemplatePrivate + } + return obj.InOrg(t.OrganizationID).WithACLUserList(t.UserACL().Actions()) } -func (t TemplateVersion) RBACObject(template Template) rbac.Object { +func (TemplateVersion) RBACObject(template Template) rbac.Object { // Just use the parent template resource for controlling versions - return rbac.ResourceTemplate.InOrg(t.OrganizationID).WithACLUserList(template.UserACL().Actions()) + return template.RBACObject() } func (w Workspace) RBACObject() rbac.Object { diff --git a/coderd/database/models.go b/coderd/database/models.go index b0552631e0feb..322e5c08e87c0 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -502,6 +502,7 @@ type Template struct { CreatedBy uuid.UUID `db:"created_by" json:"created_by"` Icon string `db:"icon" json:"icon"` userACL json.RawMessage `db:"user_acl" json:"user_acl"` + IsPrivate bool `db:"is_private" json:"is_private"` } type TemplateVersion struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index d076420cb480e..dccdf2778d345 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2060,7 +2060,7 @@ func (q *sqlQuerier) InsertDeploymentID(ctx context.Context, value string) error const getTemplateByID = `-- name: GetTemplateByID :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl, is_private FROM templates WHERE @@ -2087,13 +2087,14 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.CreatedBy, &i.Icon, &i.userACL, + &i.IsPrivate, ) return i, err } const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl, is_private FROM templates WHERE @@ -2128,12 +2129,13 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.CreatedBy, &i.Icon, &i.userACL, + &i.IsPrivate, ) return i, err } const getTemplates = `-- name: GetTemplates :many -SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl FROM templates +SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl, is_private FROM templates ORDER BY (name, id) ASC ` @@ -2161,6 +2163,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.CreatedBy, &i.Icon, &i.userACL, + &i.IsPrivate, ); err != nil { return nil, err } @@ -2177,7 +2180,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl, is_private FROM templates WHERE @@ -2240,6 +2243,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.CreatedBy, &i.Icon, &i.userACL, + &i.IsPrivate, ); err != nil { return nil, err } @@ -2269,10 +2273,10 @@ INSERT INTO min_autostart_interval, created_by, icon, - user_acl + is_private ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl, is_private ` type InsertTemplateParams struct { @@ -2288,7 +2292,7 @@ type InsertTemplateParams struct { MinAutostartInterval int64 `db:"min_autostart_interval" json:"min_autostart_interval"` CreatedBy uuid.UUID `db:"created_by" json:"created_by"` Icon string `db:"icon" json:"icon"` - userACL json.RawMessage `db:"user_acl" json:"user_acl"` + IsPrivate bool `db:"is_private" json:"is_private"` } func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) { @@ -2305,7 +2309,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam arg.MinAutostartInterval, arg.CreatedBy, arg.Icon, - arg.userACL, + arg.IsPrivate, ) var i Template err := row.Scan( @@ -2323,6 +2327,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam &i.CreatedBy, &i.Icon, &i.userACL, + &i.IsPrivate, ) return i, err } @@ -2378,11 +2383,12 @@ SET max_ttl = $4, min_autostart_interval = $5, name = $6, - icon = $7 + icon = $7, + is_private = $8 WHERE id = $1 RETURNING - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl, is_private ` type UpdateTemplateMetaByIDParams struct { @@ -2393,6 +2399,7 @@ type UpdateTemplateMetaByIDParams struct { MinAutostartInterval int64 `db:"min_autostart_interval" json:"min_autostart_interval"` Name string `db:"name" json:"name"` Icon string `db:"icon" json:"icon"` + IsPrivate bool `db:"is_private" json:"is_private"` } func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) (Template, error) { @@ -2404,6 +2411,7 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl arg.MinAutostartInterval, arg.Name, arg.Icon, + arg.IsPrivate, ) var i Template err := row.Scan( @@ -2421,6 +2429,7 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl &i.CreatedBy, &i.Icon, &i.userACL, + &i.IsPrivate, ) return i, err } diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 4d552443356fe..720f5f49890df 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -68,10 +68,11 @@ INSERT INTO max_ttl, min_autostart_interval, created_by, - icon + icon, + is_private ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *; -- name: UpdateTemplateActiveVersionByID :exec UPDATE @@ -100,7 +101,8 @@ SET max_ttl = $4, min_autostart_interval = $5, name = $6, - icon = $7 + icon = $7, + is_private = $8 WHERE id = $1 RETURNING diff --git a/coderd/rbac/builtin.go b/coderd/rbac/builtin.go index 008838708f22b..ab834ee6d8e50 100644 --- a/coderd/rbac/builtin.go +++ b/coderd/rbac/builtin.go @@ -108,7 +108,8 @@ var ( Name: templateAdmin, DisplayName: "Template Admin", Site: permissions(map[string][]Action{ - ResourceTemplate.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + ResourceTemplate.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + ResourceTemplatePrivate.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, // CRUD all files, even those they did not upload. ResourceFile.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, ResourceWorkspace.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, @@ -167,11 +168,21 @@ var ( ResourceType: ResourceOrganization.Type, Action: ActionRead, }, + { + // All org members can read templates in the org + ResourceType: ResourceTemplate.Type, + Action: ActionRead, + }, { // Can read available roles. ResourceType: ResourceOrgRoleAssignment.Type, Action: ActionRead, }, + { + // Can read public templates. + ResourceType: ResourceTemplate.Type, + Action: ActionRead, + }, }, }, } diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index bd487657eb44b..13c256effd8fc 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -45,6 +45,10 @@ var ( Type: "template", } + ResourceTemplatePrivate = Object{ + Type: "template_private", + } + ResourceFile = Object{ Type: "file", } diff --git a/coderd/templates.go b/coderd/templates.go index 326fe3825f881..4fb8539bbf444 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -257,6 +257,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque MaxTtl: int64(maxTTL), MinAutostartInterval: int64(minAutostartInterval), CreatedBy: apiKey.UserID, + IsPrivate: createTemplate.IsPrivate, }) if err != nil { return xerrors.Errorf("insert template: %s", err) @@ -457,10 +458,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { // Only users who are able to create templates (aka template admins) // are able to control user permissions. - // TODO: It might be cleaner to control template perms access - // via a separate RBAC resource, and restrict all actions to the template - // admin role. - if len(req.UserPerms) > 0 && !api.Authorize(r, rbac.ActionCreate, template) { + if (len(req.UserPerms) > 0 || req.IsPrivate != nil) && + !api.Authorize(r, rbac.ActionCreate, template) { httpapi.ResourceNotFound(rw) return } @@ -525,7 +524,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { req.Icon == template.Icon && req.MaxTTLMillis == time.Duration(template.MaxTtl).Milliseconds() && req.MinAutostartIntervalMillis == time.Duration(template.MinAutostartInterval).Milliseconds() && - len(req.UserPerms) == 0 { + len(req.UserPerms) == 0 && + (req.IsPrivate == nil || req.IsPrivate != nil && *req.IsPrivate == template.IsPrivate) { return nil } @@ -535,6 +535,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { icon := req.Icon maxTTL := time.Duration(req.MaxTTLMillis) * time.Millisecond minAutostartInterval := time.Duration(req.MinAutostartIntervalMillis) * time.Millisecond + isPrivate := template.IsPrivate if name == "" { name = template.Name @@ -545,6 +546,9 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { if minAutostartInterval == 0 { minAutostartInterval = time.Duration(template.MinAutostartInterval) } + if req.IsPrivate != nil { + isPrivate = *req.IsPrivate + } if len(req.UserPerms) > 0 { userACL := template.UserACL() @@ -572,6 +576,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { Icon: icon, MaxTtl: int64(maxTTL), MinAutostartInterval: int64(minAutostartInterval), + IsPrivate: isPrivate, }) if err != nil { return err @@ -819,6 +824,7 @@ func (api *API) convertTemplate( CreatedByID: template.CreatedBy, CreatedByName: createdByName, UserRoles: convertTemplateACL(template.UserACL()), + IsPrivate: template.IsPrivate, } } diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 5b979a102f6da..f9c536aa64d75 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -41,6 +41,53 @@ func TestTemplate(t *testing.T) { require.NoError(t, err) }) + // Test that a regular user cannot get a private template. + t.Run("GetPrivate", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + client2, _ := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, + func(r *codersdk.CreateTemplateRequest) { + r.IsPrivate = true + }, + ) + + require.True(t, template.IsPrivate) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client2.Template(ctx, template.ID) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusNotFound, cerr.StatusCode()) + }) + + // Test that a privileged user can get a private template. + t.Run("GetPrivateOwner", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, + func(r *codersdk.CreateTemplateRequest) { + r.IsPrivate = true + }, + ) + + require.True(t, template.IsPrivate) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + template, err := client.Template(ctx, template.ID) + require.NoError(t, err) + require.True(t, template.IsPrivate) + }) + t.Run("WorkspaceCount", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) @@ -315,6 +362,7 @@ func TestPatchTemplateMeta(t *testing.T) { Icon: "/icons/new-icon.png", MaxTTLMillis: 12 * time.Hour.Milliseconds(), MinAutostartIntervalMillis: time.Minute.Milliseconds(), + IsPrivate: boolPtr(true), } // It is unfortunate we need to sleep, but the test can fail if the // updatedAt is too close together. @@ -331,6 +379,7 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, req.Icon, updated.Icon) assert.Equal(t, req.MaxTTLMillis, updated.MaxTTLMillis) assert.Equal(t, req.MinAutostartIntervalMillis, updated.MinAutostartIntervalMillis) + assert.True(t, updated.IsPrivate) // Extra paranoid: did it _really_ happen? updated, err = client.Template(ctx, template.ID) @@ -341,6 +390,7 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, req.Icon, updated.Icon) assert.Equal(t, req.MaxTTLMillis, updated.MaxTTLMillis) assert.Equal(t, req.MinAutostartIntervalMillis, updated.MinAutostartIntervalMillis) + assert.Equal(t, *req.IsPrivate, updated.IsPrivate) require.Len(t, auditor.AuditLogs, 4) assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[3].Action) @@ -548,6 +598,38 @@ func TestPatchTemplateMeta(t *testing.T) { require.Equal(t, codersdk.TemplateRoleRead, role) }) + // Test that a regular user can access a private template + // if given access. + t.Run("PrivateTemplate", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, + func(r *codersdk.CreateTemplateRequest) { + r.IsPrivate = true + }, + ) + req := codersdk.UpdateTemplateMeta{ + UserPerms: map[string]codersdk.TemplateRole{ + user2.ID.String(): codersdk.TemplateRoleRead, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + template, err := client.UpdateTemplateMeta(ctx, template.ID, req) + require.NoError(t, err) + + role, ok := template.UserRoles[user2.ID.String()] + require.True(t, ok, "User not contained within user_roles map") + require.Equal(t, codersdk.TemplateRoleRead, role) + + }) + t.Run("DeleteUser", func(t *testing.T) { t.Parallel() @@ -879,3 +961,7 @@ func TestTemplateDAUs(t *testing.T) { database.Now(), workspaces[0].LastUsedAt, time.Minute, ) } + +func boolPtr(b bool) *bool { + return &b +} diff --git a/codersdk/organizations.go b/codersdk/organizations.go index a3ef0a7a000e3..d9b10b4dd038c 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -73,6 +73,7 @@ type CreateTemplateRequest struct { // allowable duration between autostarts for all workspaces created from // this template. MinAutostartIntervalMillis *int64 `json:"min_autostart_interval_ms,omitempty"` + IsPrivate bool `json:"is_private"` } // CreateWorkspaceRequest provides options for creating a new workspace. diff --git a/codersdk/templates.go b/codersdk/templates.go index c9b937e670bf0..0fa761fdac155 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -31,6 +31,7 @@ type Template struct { CreatedByID uuid.UUID `json:"created_by_id"` CreatedByName string `json:"created_by_name"` UserRoles map[string]TemplateRole `json:"user_roles"` + IsPrivate bool `json:"is_private"` } type UpdateActiveTemplateVersion struct { @@ -49,15 +50,13 @@ var ( ) type UpdateTemplateMeta struct { - Name string `json:"name,omitempty" validate:"omitempty,username"` - Description string `json:"description,omitempty"` - Icon string `json:"icon,omitempty"` - MaxTTLMillis int64 `json:"max_ttl_ms,omitempty"` - MinAutostartIntervalMillis int64 `json:"min_autostart_interval_ms,omitempty"` - // UserPerms is a map of user IDs to their corresponding role. - // In order to delete a user's permissions set a user's - // role to the empty string. - UserPerms map[string]TemplateRole `json:"user_perms"` + Name string `json:"name,omitempty" validate:"omitempty,username"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` + MaxTTLMillis int64 `json:"max_ttl_ms,omitempty"` + MinAutostartIntervalMillis int64 `json:"min_autostart_interval_ms,omitempty"` + UserPerms map[string]TemplateRole `json:"user_perms,omitempty"` + IsPrivate *bool `json:"is_private,omitempty"` } // Template returns a single template. From ba32928bf74bd944cebb0d31475e0c105cea0f06 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 19 Sep 2022 20:34:39 +0000 Subject: [PATCH 15/37] go.mod --- go.mod | 2 +- go.sum | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 544874b7f94e9..7db0d37aa4af9 100644 --- a/go.mod +++ b/go.mod @@ -170,7 +170,7 @@ require ( tailscale.com v1.30.0 ) -require github.com/jmoiron/sqlx v1.3.5 // indirect +require github.com/jmoiron/sqlx v1.3.5 require ( filippo.io/edwards25519 v1.0.0-rc.1 // indirect diff --git a/go.sum b/go.sum index 852d94bb0b6da..961731dd43d96 100644 --- a/go.sum +++ b/go.sum @@ -1307,6 +1307,7 @@ github.com/mattn/go-shellwords v1.0.6/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vq github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.10 h1:MLn+5bFRlWMGoSRmJour3CL1w/qL96mvipqpwQW/Sfk= github.com/mattn/go-sqlite3 v1.14.10/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= From ef159087329f99ea3c5deac7adf82dff61c4308c Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 19 Sep 2022 21:13:39 +0000 Subject: [PATCH 16/37] fix rbac merge woes --- coderd/rbac/builtin.go | 4 ++-- coderd/rbac/scopes.go | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/coderd/rbac/builtin.go b/coderd/rbac/builtin.go index ab834ee6d8e50..1cc2fbb93dd97 100644 --- a/coderd/rbac/builtin.go +++ b/coderd/rbac/builtin.go @@ -398,12 +398,12 @@ func roleSplit(role string) (name string, orgID string, err error) { // and actions a bit easier. func permissions(perms map[string][]Action) []Permission { list := make([]Permission, 0, len(perms)) - for objectType, actions := range perms { + for k, actions := range perms { for _, act := range actions { act := act list = append(list, Permission{ Negate: false, - ResourceType: objectType, + ResourceType: k, Action: act, }) } diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go index 9f5268f2cb735..57ed21bb644b6 100644 --- a/coderd/rbac/scopes.go +++ b/coderd/rbac/scopes.go @@ -19,8 +19,8 @@ var builtinScopes map[Scope]Role = map[Scope]Role{ ScopeAll: { Name: fmt.Sprintf("Scope_%s", ScopeAll), DisplayName: "All operations", - Site: permissions(map[Object][]Action{ - ResourceWildcard: {WildcardSymbol}, + Site: permissions(map[string][]Action{ + ResourceWildcard.Type: {WildcardSymbol}, }), Org: map[string][]Permission{}, User: []Permission{}, @@ -29,8 +29,8 @@ var builtinScopes map[Scope]Role = map[Scope]Role{ ScopeApplicationConnect: { Name: fmt.Sprintf("Scope_%s", ScopeApplicationConnect), DisplayName: "Ability to connect to applications", - Site: permissions(map[Object][]Action{ - ResourceWorkspaceApplicationConnect: {ActionCreate}, + Site: permissions(map[string][]Action{ + ResourceWorkspaceApplicationConnect.Type: {ActionCreate}, }), Org: map[string][]Permission{}, User: []Permission{}, From 8ab5200aa7e136028553bd9f9ee103e7a36bcd4b Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 19 Sep 2022 21:17:05 +0000 Subject: [PATCH 17/37] update migration --- ...{000050_template_acl.down.sql => 000051_template_acl.down.sql} | 0 .../{000050_template_acl.up.sql => 000051_template_acl.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000050_template_acl.down.sql => 000051_template_acl.down.sql} (100%) rename coderd/database/migrations/{000050_template_acl.up.sql => 000051_template_acl.up.sql} (100%) diff --git a/coderd/database/migrations/000050_template_acl.down.sql b/coderd/database/migrations/000051_template_acl.down.sql similarity index 100% rename from coderd/database/migrations/000050_template_acl.down.sql rename to coderd/database/migrations/000051_template_acl.down.sql diff --git a/coderd/database/migrations/000050_template_acl.up.sql b/coderd/database/migrations/000051_template_acl.up.sql similarity index 100% rename from coderd/database/migrations/000050_template_acl.up.sql rename to coderd/database/migrations/000051_template_acl.up.sql From c040e8ea5a56728a14c9b3581f0d14d7f4fe6644 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 19 Sep 2022 21:35:02 +0000 Subject: [PATCH 18/37] fix workspaces_test --- coderd/workspaces_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index f1fcb1c859a69..11e23ea341c60 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -25,10 +25,10 @@ import ( "github.com/coder/coder/testutil" ) -func TestWorkspaces(t *testing.T) { +func TestWorkspace(t *testing.T) { t.Parallel() - t.Run("OKK", func(t *testing.T) { + t.Run("OK", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user := coderdtest.CreateFirstUser(t, client) From 1f4ceee48bdaef9d08287d87518583b44684b696 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 19 Sep 2022 21:35:43 +0000 Subject: [PATCH 19/37] remove sqlx --- coderd/database/db.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/coderd/database/db.go b/coderd/database/db.go index 351dfc1e8897d..b3cc3f13c2a25 100644 --- a/coderd/database/db.go +++ b/coderd/database/db.go @@ -13,7 +13,6 @@ import ( "database/sql" "errors" - "github.com/jmoiron/sqlx" "golang.org/x/xerrors" ) @@ -37,7 +36,7 @@ type DBTX interface { func New(sdb *sql.DB) Store { return &sqlQuerier{ db: sdb, - sdb: sqlx.NewDb(sdb, "postgres"), + sdb: sdb, } } @@ -49,13 +48,13 @@ type querier interface { } type sqlQuerier struct { - sdb *sqlx.DB + sdb *sql.DB db DBTX } // InTx performs database operations inside a transaction. func (q *sqlQuerier) InTx(function func(Store) error) error { - if _, ok := q.db.(*sqlx.Tx); ok { + if _, ok := q.db.(*sql.Tx); ok { // If the current inner "db" is already a transaction, we just reuse it. // We do not need to handle commit/rollback as the outer tx will handle // that. From 7cc71e1f66580cc33eec2fafb53e58375b8049ae Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 19 Sep 2022 22:10:35 +0000 Subject: [PATCH 20/37] fix audit --- enterprise/audit/diff.go | 4 ++++ enterprise/audit/table.go | 1 + 2 files changed, 5 insertions(+) diff --git a/enterprise/audit/diff.go b/enterprise/audit/diff.go index 7602826bd30cc..dac3ab437bdcf 100644 --- a/enterprise/audit/diff.go +++ b/enterprise/audit/diff.go @@ -31,6 +31,10 @@ func diffValues(left, right any, table Table) audit.Map { } for i := 0; i < rightT.NumField(); i++ { + if !rightT.Field(i).IsExported() { + continue + } + var ( leftF = leftV.Field(i) rightF = rightV.Field(i) diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index eaa2dd1bb9654..3327fadcaffbc 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -61,6 +61,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{ "max_ttl": ActionTrack, "min_autostart_interval": ActionTrack, "created_by": ActionTrack, + "is_private": ActionTrack, }, &database.TemplateVersion{}: { "id": ActionTrack, From 131d5ed45313f20e5ee4fe90d4233f9123117431 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 19 Sep 2022 22:22:58 +0000 Subject: [PATCH 21/37] fix lint --- coderd/templates_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/coderd/templates_test.go b/coderd/templates_test.go index f9c536aa64d75..a9f9aa985b5d4 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -627,7 +627,6 @@ func TestPatchTemplateMeta(t *testing.T) { role, ok := template.UserRoles[user2.ID.String()] require.True(t, ok, "User not contained within user_roles map") require.Equal(t, codersdk.TemplateRoleRead, role) - }) t.Run("DeleteUser", func(t *testing.T) { From 8c3ee6a70b6c65f9da3c630129417b87a7c422aa Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 19 Sep 2022 22:31:28 +0000 Subject: [PATCH 22/37] Revert "remove sqlx" This reverts commit 1f4ceee48bdaef9d08287d87518583b44684b696. --- coderd/database/db.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/coderd/database/db.go b/coderd/database/db.go index b3cc3f13c2a25..351dfc1e8897d 100644 --- a/coderd/database/db.go +++ b/coderd/database/db.go @@ -13,6 +13,7 @@ import ( "database/sql" "errors" + "github.com/jmoiron/sqlx" "golang.org/x/xerrors" ) @@ -36,7 +37,7 @@ type DBTX interface { func New(sdb *sql.DB) Store { return &sqlQuerier{ db: sdb, - sdb: sdb, + sdb: sqlx.NewDb(sdb, "postgres"), } } @@ -48,13 +49,13 @@ type querier interface { } type sqlQuerier struct { - sdb *sql.DB + sdb *sqlx.DB db DBTX } // InTx performs database operations inside a transaction. func (q *sqlQuerier) InTx(function func(Store) error) error { - if _, ok := q.db.(*sql.Tx); ok { + if _, ok := q.db.(*sqlx.Tx); ok { // If the current inner "db" is already a transaction, we just reuse it. // We do not need to handle commit/rollback as the outer tx will handle // that. From fe2af91eaba95f8a7fe09865cdaf722cc89e9dbc Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 20 Sep 2022 00:06:34 +0000 Subject: [PATCH 23/37] add test for list templates --- coderd/templates_test.go | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/coderd/templates_test.go b/coderd/templates_test.go index a9f9aa985b5d4..ce4133cf09ae3 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -307,6 +307,50 @@ func TestTemplatesByOrganization(t *testing.T) { require.NoError(t, err) require.Len(t, templates, 2) }) + + t.Run("ListPrivate", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + client2, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + + template1 := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, + func(r *codersdk.CreateTemplateRequest) { + r.IsPrivate = true + }, + ) + template2 := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, + func(r *codersdk.CreateTemplateRequest) { + r.IsPrivate = true + }, + ) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + templates, err := client2.TemplatesByOrganization(ctx, user.OrganizationID) + require.NoError(t, err) + require.Len(t, templates, 0, "user should not be able to read any templates") + + req := codersdk.UpdateTemplateMeta{ + UserPerms: map[string]codersdk.TemplateRole{ + user2.ID.String(): codersdk.TemplateRoleRead, + }, + } + + _, err = client.UpdateTemplateMeta(ctx, template1.ID, req) + require.NoError(t, err) + + _, err = client.UpdateTemplateMeta(ctx, template2.ID, req) + require.NoError(t, err) + + templates, err = client2.TemplatesByOrganization(ctx, user.OrganizationID) + require.NoError(t, err) + require.Len(t, templates, 2, "user should not be able to read any templates") + }) } func TestTemplateByOrganizationAndName(t *testing.T) { From 0218c4e7e28ee98bf5f8e41aec8276d82b2fad2a Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 20 Sep 2022 00:07:48 +0000 Subject: [PATCH 24/37] fix error msg --- coderd/templates_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/templates_test.go b/coderd/templates_test.go index ce4133cf09ae3..87f81326b8b99 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -349,7 +349,7 @@ func TestTemplatesByOrganization(t *testing.T) { templates, err = client2.TemplatesByOrganization(ctx, user.OrganizationID) require.NoError(t, err) - require.Len(t, templates, 2, "user should not be able to read any templates") + require.Len(t, templates, 2, "user should be able to read both templates") }) } From 68831064d891331aeda220815751fe8bce65ddc1 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 20 Sep 2022 02:13:07 +0000 Subject: [PATCH 25/37] fix sqlx woes --- coderd/coderd.go | 1 + coderd/database/databasefake/databasefake.go | 40 ++++++++++++++ coderd/database/db.go | 9 ++- coderd/database/modelqueries.go | 36 ++++++++++++ coderd/database/models.go | 3 +- coderd/database/queries.sql.go | 34 ++++++------ coderd/database/sqlc.yaml | 4 ++ coderd/templates.go | 58 +++++++++++++++++++- coderd/templates_test.go | 46 ++++++++++++++++ codersdk/templates.go | 20 ++++++- 10 files changed, 226 insertions(+), 25 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 63c35bd1cbaa5..32f7be3ab6c4f 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -319,6 +319,7 @@ func New(options *Options) *API { r.Get("/", api.template) r.Delete("/", api.deleteTemplate) r.Patch("/", api.patchTemplateMeta) + r.Get("/user-roles", api.templateUserRoles) r.Route("/versions", func(r chi.Router) { r.Get("/", api.templateVersionsByTemplate) r.Patch("/", api.patchActiveTemplateVersion) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 9b96c24ca1244..b32c026bdf922 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -12,6 +12,7 @@ import ( "github.com/lib/pq" "golang.org/x/exp/maps" "golang.org/x/exp/slices" + "golang.org/x/xerrors" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/rbac" @@ -1244,6 +1245,45 @@ func (q *fakeQuerier) UpdateTemplateUserACLByID(_ context.Context, id uuid.UUID, return sql.ErrNoRows } +func (q *fakeQuerier) GetTemplateUserRoles(ctx context.Context, id uuid.UUID) ([]database.TemplateUser, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + var template database.Template + for _, t := range q.templates { + if t.ID == id { + template = t + break + } + } + + if template.ID == uuid.Nil { + return nil, sql.ErrNoRows + } + + acl := template.UserACL() + + users := make([]database.TemplateUser, 0, len(acl)) + for k, v := range acl { + user, err := q.GetUserByID(context.Background(), uuid.MustParse(k)) + if err != nil && xerrors.Is(err, sql.ErrNoRows) { + return nil, xerrors.Errorf("get user by ID: %w", err) + } + // We don't delete users from the map if they + // get deleted so just skip. + if xerrors.Is(err, sql.ErrNoRows) { + continue + } + + users = append(users, database.TemplateUser{ + User: user, + Role: v, + }) + } + + return users, nil +} + func (q *fakeQuerier) GetOrganizationMemberByUserID(_ context.Context, arg database.GetOrganizationMemberByUserIDParams) (database.OrganizationMember, error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/db.go b/coderd/database/db.go index 351dfc1e8897d..fa5af737ce554 100644 --- a/coderd/database/db.go +++ b/coderd/database/db.go @@ -31,13 +31,16 @@ type DBTX interface { PrepareContext(context.Context, string) (*sql.Stmt, error) QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) QueryRowContext(context.Context, string, ...interface{}) *sql.Row + SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error + GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error } // New creates a new database store using a SQL database connection. func New(sdb *sql.DB) Store { + dbx := sqlx.NewDb(sdb, "postgres") return &sqlQuerier{ - db: sdb, - sdb: sqlx.NewDb(sdb, "postgres"), + db: dbx, + sdb: dbx, } } @@ -66,7 +69,7 @@ func (q *sqlQuerier) InTx(function func(Store) error) error { return nil } - transaction, err := q.sdb.Begin() + transaction, err := q.sdb.BeginTxx(context.Background(), nil) if err != nil { return xerrors.Errorf("begin transaction: %w", err) } diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 6c8e8b5e2293e..8729c726a4fe3 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -17,6 +17,7 @@ type customQuerier interface { type templateQuerier interface { UpdateTemplateUserACLByID(ctx context.Context, id uuid.UUID, acl UserACL) error + GetTemplateUserRoles(ctx context.Context, id uuid.UUID) ([]TemplateUser, error) } type TemplateUser struct { @@ -45,3 +46,38 @@ WHERE return nil } + +func (q *sqlQuerier) GetTemplateUserRoles(ctx context.Context, id uuid.UUID) ([]TemplateUser, error) { + const query = ` + SELECT + perms.value as role, users.* + FROM + users + JOIN + ( + SELECT + * + FROM + jsonb_each_text( + ( + SELECT + templates.user_acl + FROM + templates + WHERE + id = $1 + ) + ) + ) AS perms + ON + users.id::text = perms.key; + ` + + var tus []TemplateUser + err := q.db.SelectContext(ctx, &tus, query, id.String()) + if err != nil { + return nil, xerrors.Errorf("select context: %w", err) + } + + return tus, nil +} diff --git a/coderd/database/models.go b/coderd/database/models.go index 2db93d0a84bf2..bcf26bec41488 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -11,6 +11,7 @@ import ( "time" "github.com/google/uuid" + "github.com/lib/pq" "github.com/tabbed/pqtype" ) @@ -545,7 +546,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 pq.StringArray `db:"rbac_roles" json:"rbac_roles"` LoginType LoginType `db:"login_type" json:"login_type"` AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"` Deleted bool `db:"deleted" json:"deleted"` diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index cc1718999b05c..a132b58443a32 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3045,7 +3045,7 @@ func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserBy &i.CreatedAt, &i.UpdatedAt, &i.Status, - pq.Array(&i.RBACRoles), + &i.RBACRoles, &i.LoginType, &i.AvatarURL, &i.Deleted, @@ -3075,7 +3075,7 @@ func (q *sqlQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (User, error &i.CreatedAt, &i.UpdatedAt, &i.Status, - pq.Array(&i.RBACRoles), + &i.RBACRoles, &i.LoginType, &i.AvatarURL, &i.Deleted, @@ -3193,7 +3193,7 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]User, &i.CreatedAt, &i.UpdatedAt, &i.Status, - pq.Array(&i.RBACRoles), + &i.RBACRoles, &i.LoginType, &i.AvatarURL, &i.Deleted, @@ -3237,7 +3237,7 @@ func (q *sqlQuerier) GetUsersByIDs(ctx context.Context, arg GetUsersByIDsParams) &i.CreatedAt, &i.UpdatedAt, &i.Status, - pq.Array(&i.RBACRoles), + &i.RBACRoles, &i.LoginType, &i.AvatarURL, &i.Deleted, @@ -3272,14 +3272,14 @@ VALUES ` type InsertUserParams struct { - 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"` - LoginType LoginType `db:"login_type" json:"login_type"` + 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 pq.StringArray `db:"rbac_roles" json:"rbac_roles"` + LoginType LoginType `db:"login_type" json:"login_type"` } func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) { @@ -3290,7 +3290,7 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User arg.HashedPassword, arg.CreatedAt, arg.UpdatedAt, - pq.Array(arg.RBACRoles), + arg.RBACRoles, arg.LoginType, ) var i User @@ -3302,7 +3302,7 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User &i.CreatedAt, &i.UpdatedAt, &i.Status, - pq.Array(&i.RBACRoles), + &i.RBACRoles, &i.LoginType, &i.AvatarURL, &i.Deleted, @@ -3385,7 +3385,7 @@ func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfil &i.CreatedAt, &i.UpdatedAt, &i.Status, - pq.Array(&i.RBACRoles), + &i.RBACRoles, &i.LoginType, &i.AvatarURL, &i.Deleted, @@ -3420,7 +3420,7 @@ func (q *sqlQuerier) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesPar &i.CreatedAt, &i.UpdatedAt, &i.Status, - pq.Array(&i.RBACRoles), + &i.RBACRoles, &i.LoginType, &i.AvatarURL, &i.Deleted, @@ -3455,7 +3455,7 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP &i.CreatedAt, &i.UpdatedAt, &i.Status, - pq.Array(&i.RBACRoles), + &i.RBACRoles, &i.LoginType, &i.AvatarURL, &i.Deleted, diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 1cfdbf499f75b..ed60336346049 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -16,6 +16,10 @@ packages: # deleted after generation. output_db_file_name: db_tmp.go +overrides: + - column: "users.rbac_roles" + go_type: "github.com/lib/pq.StringArray" + rename: api_key: APIKey api_key_scope: APIKeyScope diff --git a/coderd/templates.go b/coderd/templates.go index 4fb8539bbf444..14a976c3617c8 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -625,6 +625,47 @@ func (api *API) templateDAUs(rw http.ResponseWriter, r *http.Request) { httpapi.Write(rw, http.StatusOK, resp) } +func (api *API) templateUserRoles(rw http.ResponseWriter, r *http.Request) { + template := httpmw.TemplateParam(r) + if !api.Authorize(r, rbac.ActionRead, template) { + httpapi.ResourceNotFound(rw) + return + } + + users, err := api.Database.GetTemplateUserRoles(r.Context(), template.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + users, err = AuthorizeFilter(api.httpAuth, r, rbac.ActionRead, users) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching users.", + Detail: err.Error(), + }) + return + } + + userIDs := make([]uuid.UUID, 0, len(users)) + for _, user := range users { + userIDs = append(userIDs, user.ID) + } + + orgIDsByMemberIDsRows, err := api.Database.GetOrganizationIDsByMemberIDs(r.Context(), userIDs) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + organizationIDsByUserID := map[uuid.UUID][]uuid.UUID{} + for _, organizationIDsByMemberIDsRow := range orgIDsByMemberIDsRows { + organizationIDsByUserID[organizationIDsByMemberIDsRow.UserID] = organizationIDsByMemberIDsRow.OrganizationIDs + } + + httpapi.Write(rw, http.StatusOK, convertTemplateUsers(users, organizationIDsByUserID)) +} + type autoImportTemplateOpts struct { name string archive []byte @@ -828,8 +869,8 @@ func (api *API) convertTemplate( } } -func convertTemplateACL(acl database.UserACL) codersdk.TemplateUserACL { - userACL := make(codersdk.TemplateUserACL, len(acl)) +func convertTemplateACL(acl database.UserACL) map[string]codersdk.TemplateRole { + userACL := make(map[string]codersdk.TemplateRole, len(acl)) for k, v := range acl { userACL[k] = convertDatabaseTemplateRole(v) } @@ -871,3 +912,16 @@ func validateTemplateRole(role codersdk.TemplateRole) error { return nil } + +func convertTemplateUsers(tus []database.TemplateUser, orgIDsByUserIDs map[uuid.UUID][]uuid.UUID) []codersdk.TemplateUser { + users := make([]codersdk.TemplateUser, 0, len(tus)) + + for _, tu := range tus { + users = append(users, codersdk.TemplateUser{ + User: convertUser(tu.User, orgIDsByUserIDs[tu.User.ID]), + Role: codersdk.TemplateRole(tu.Role), + }) + } + + return users +} diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 87f81326b8b99..7b36b371e0053 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -897,6 +897,52 @@ func TestDeleteTemplate(t *testing.T) { }) } +func TestTemplateUserRoles(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, + func(r *codersdk.CreateTemplateRequest) { + r.IsPrivate = true + }, + ) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + UserPerms: map[string]codersdk.TemplateRole{ + user2.ID.String(): codersdk.TemplateRoleRead, + user3.ID.String(): codersdk.TemplateRoleWrite, + }, + }) + require.NoError(t, err) + + users, err := client.TemplateUserRoles(ctx, template.ID) + require.NoError(t, err) + + templateUser2 := codersdk.TemplateUser{ + User: user2, + Role: codersdk.TemplateRoleRead, + } + + templateUser3 := codersdk.TemplateUser{ + User: user3, + Role: codersdk.TemplateRoleWrite, + } + + require.Len(t, users, 2) + require.Contains(t, users, templateUser2) + require.Contains(t, users, templateUser3) + }) +} + func TestTemplateDAUs(t *testing.T) { t.Parallel() diff --git a/codersdk/templates.go b/codersdk/templates.go index 0fa761fdac155..f150b9d3209af 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -38,8 +38,6 @@ type UpdateActiveTemplateVersion struct { ID uuid.UUID `json:"id" validate:"required"` } -type TemplateUserACL map[string]TemplateRole - type TemplateRole string var ( @@ -49,6 +47,11 @@ var ( TemplateRoleDeleted TemplateRole = "" ) +type TemplateUser struct { + User + Role TemplateRole `json:"role"` +} + type UpdateTemplateMeta struct { Name string `json:"name,omitempty" validate:"omitempty,username"` Description string `json:"description,omitempty"` @@ -101,6 +104,19 @@ func (c *Client) UpdateTemplateMeta(ctx context.Context, templateID uuid.UUID, r return updated, json.NewDecoder(res.Body).Decode(&updated) } +func (c *Client) TemplateUserRoles(ctx context.Context, templateID uuid.UUID) ([]TemplateUser, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s/user-roles", templateID), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, readBodyAsError(res) + } + var users []TemplateUser + return users, json.NewDecoder(res.Body).Decode(&users) +} + // UpdateActiveTemplateVersion updates the active template version to the ID provided. // The template version must be attached to the template. func (c *Client) UpdateActiveTemplateVersion(ctx context.Context, template uuid.UUID, req UpdateActiveTemplateVersion) error { From 4fbd9be6046e609fbf5cf5e63b7186f4d0a0c075 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 20 Sep 2022 02:16:14 +0000 Subject: [PATCH 26/37] fix lint --- coderd/database/databasefake/databasefake.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index b32c026bdf922..38a21e09659a4 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -1245,7 +1245,7 @@ func (q *fakeQuerier) UpdateTemplateUserACLByID(_ context.Context, id uuid.UUID, return sql.ErrNoRows } -func (q *fakeQuerier) GetTemplateUserRoles(ctx context.Context, id uuid.UUID) ([]database.TemplateUser, error) { +func (q *fakeQuerier) GetTemplateUserRoles(_ context.Context, id uuid.UUID) ([]database.TemplateUser, error) { q.mutex.RLock() defer q.mutex.RUnlock() From c96a6ca9ca5954291feaa04b2ec11196a9e7c4d6 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 20 Sep 2022 02:42:45 +0000 Subject: [PATCH 27/37] fix audit --- enterprise/audit/diff_internal_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/enterprise/audit/diff_internal_test.go b/enterprise/audit/diff_internal_test.go index bdc4e87b7c30f..75af13fb086d5 100644 --- a/enterprise/audit/diff_internal_test.go +++ b/enterprise/audit/diff_internal_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/google/uuid" + "github.com/lib/pq" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/utils/pointer" @@ -328,7 +329,7 @@ func Test_diff(t *testing.T) { "username": audit.OldNew{Old: "", New: "colin"}, "hashed_password": audit.OldNew{Old: ([]byte)(nil), New: ([]byte)(nil), Secret: true}, "status": audit.OldNew{Old: database.UserStatus(""), New: database.UserStatusActive}, - "rbac_roles": audit.OldNew{Old: ([]string)(nil), New: []string{"omega admin"}}, + "rbac_roles": audit.OldNew{Old: (pq.StringArray)(nil), New: pq.StringArray{"omega admin"}}, }, }, }) From 57ba8b371502309d76e73fb0a526c3f862eb3e90 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 20 Sep 2022 04:21:14 +0000 Subject: [PATCH 28/37] make gen --- codersdk/templates.go | 2 +- site/src/api/typesGenerated.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/codersdk/templates.go b/codersdk/templates.go index f150b9d3209af..9fe99ddcc548d 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -40,7 +40,7 @@ type UpdateActiveTemplateVersion struct { type TemplateRole string -var ( +const ( TemplateRoleAdmin TemplateRole = "admin" TemplateRoleWrite TemplateRole = "write" TemplateRoleRead TemplateRole = "read" diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b668297544877..b7ba57ec916b7 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -159,6 +159,7 @@ export interface CreateTemplateRequest { readonly parameter_values?: CreateParameterRequest[] readonly max_ttl_ms?: number readonly min_autostart_interval_ms?: number + readonly is_private: boolean } // From codersdk/templateversions.go @@ -408,6 +409,8 @@ export interface Template { readonly min_autostart_interval_ms: number readonly created_by_id: string readonly created_by_name: string + readonly user_roles: Record + readonly is_private: boolean } // From codersdk/templates.go @@ -415,6 +418,11 @@ export interface TemplateDAUsResponse { readonly entries: DAUEntry[] } +// From codersdk/templates.go +export interface TemplateUser extends User { + readonly role: TemplateRole +} + // From codersdk/templateversions.go export interface TemplateVersion { readonly id: string @@ -451,6 +459,8 @@ export interface UpdateTemplateMeta { readonly icon?: string readonly max_ttl_ms?: number readonly min_autostart_interval_ms?: number + readonly user_perms?: Record + readonly is_private?: boolean } // From codersdk/users.go @@ -731,6 +741,9 @@ export type ResourceType = // From codersdk/sse.go export type ServerSentEventType = "data" | "error" | "ping" +// From codersdk/templates.go +export type TemplateRole = "" | "admin" | "read" | "write" + // From codersdk/users.go export type UserStatus = "active" | "suspended" From 0af367ace2e3dd593a9098f84674b9f1a2a12868 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 20 Sep 2022 04:28:39 +0000 Subject: [PATCH 29/37] fix merge woes --- coderd/templates.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/templates.go b/coderd/templates.go index 011fce18e0fd4..1a613bf0f111b 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -641,7 +641,7 @@ func (api *API) templateUserRoles(rw http.ResponseWriter, r *http.Request) { return } - users, err = AuthorizeFilter(api.httpAuth, r, rbac.ActionRead, users) + users, err = AuthorizeFilter(api.HTTPAuth, r, rbac.ActionRead, users) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching users.", From f6c3f512b6c6ddc127e4b227e065bc95c589f39a Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 20 Sep 2022 17:34:13 +0000 Subject: [PATCH 30/37] fix test template --- site/src/testHelpers/entities.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 598d37753a71f..dd85d20024d40 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -183,6 +183,8 @@ export const MockTemplate: TypesGen.Template = { created_by_id: "test-creator-id", created_by_name: "test_creator", icon: "/icon/code.svg", + user_roles: {}, + is_private: false } export const MockWorkspaceAutostartDisabled: TypesGen.UpdateWorkspaceAutostartRequest = { From 6e722869b01a5212f37d752b740bdf9763af2b7b Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 20 Sep 2022 17:48:27 +0000 Subject: [PATCH 31/37] fmt --- site/src/testHelpers/entities.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index dd85d20024d40..bbb344d97dbb5 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -184,7 +184,7 @@ export const MockTemplate: TypesGen.Template = { created_by_name: "test_creator", icon: "/icon/code.svg", user_roles: {}, - is_private: false + is_private: false, } export const MockWorkspaceAutostartDisabled: TypesGen.UpdateWorkspaceAutostartRequest = { From 44bcbde4486406442a5146940eb487c2033713cd Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 21 Sep 2022 14:14:36 +0000 Subject: [PATCH 32/37] Add base layout --- site/src/AppRouter.tsx | 18 +- .../TemplateLayout/TemplateLayout.tsx | 231 ++++++++++++++++++ .../TemplateCollaboratorsPage.tsx | 28 +++ .../TemplateCollaboratorsPageView.tsx | 27 ++ site/src/pages/TemplatePage/TemplatePage.tsx | 93 ------- .../pages/TemplatePage/TemplatePageView.tsx | 197 --------------- .../DAUChart.test.tsx | 0 .../{ => TemplateSummaryPage}/DAUChart.tsx | 5 +- .../TemplateSummaryPage.test.tsx} | 16 +- .../TemplateSummaryPage.tsx | 41 ++++ .../TemplateSummaryPageView.stories.tsx} | 12 +- .../TemplateSummaryPageView.tsx | 81 ++++++ site/src/testHelpers/entities.ts | 2 + .../xServices/template/templateXService.ts | 2 +- 14 files changed, 443 insertions(+), 310 deletions(-) create mode 100644 site/src/components/TemplateLayout/TemplateLayout.tsx create mode 100644 site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx create mode 100644 site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx delete mode 100644 site/src/pages/TemplatePage/TemplatePage.tsx delete mode 100644 site/src/pages/TemplatePage/TemplatePageView.tsx rename site/src/pages/TemplatePage/{ => TemplateSummaryPage}/DAUChart.test.tsx (100%) rename site/src/pages/TemplatePage/{ => TemplateSummaryPage}/DAUChart.tsx (98%) rename site/src/pages/TemplatePage/{TemplatePage.test.tsx => TemplateSummaryPage/TemplateSummaryPage.test.tsx} (87%) create mode 100644 site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx rename site/src/pages/TemplatePage/{TemplatePageView.stories.tsx => TemplateSummaryPage/TemplateSummaryPageView.stories.tsx} (78%) create mode 100644 site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 045f25aa3a9c3..6771495385265 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -1,7 +1,10 @@ import { useSelector } from "@xstate/react" import { FeatureNames } from "api/types" import { RequirePermission } from "components/RequirePermission/RequirePermission" +import { TemplateLayout } from "components/TemplateLayout/TemplateLayout" import { SetupPage } from "pages/SetupPage/SetupPage" +import TemplateCollaboratorsPage from "pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage" +import TemplateSummaryPage from "pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage" import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateSettingsPage" import { FC, lazy, Suspense, useContext } from "react" import { Route, Routes } from "react-router-dom" @@ -33,7 +36,6 @@ const TerminalPage = lazy(() => import("./pages/TerminalPage/TerminalPage")) const WorkspacesPage = lazy(() => import("./pages/WorkspacesPage/WorkspacesPage")) const CreateWorkspacePage = lazy(() => import("./pages/CreateWorkspacePage/CreateWorkspacePage")) const AuditPage = lazy(() => import("./pages/AuditPage/AuditPage")) -const TemplatePage = lazy(() => import("./pages/TemplatePage/TemplatePage")) export const AppRouter: FC = () => { const xServices = useContext(XServiceContext) @@ -87,12 +89,22 @@ export const AppRouter: FC = () => { - + } + > + } /> + } /> + + + + + } /> { + const { template } = useParams() + + if (!template) { + throw new Error("No template found in the URL") + } + + return template +} + +const Language = { + settingsButton: "Settings", + createButton: "Create workspace", + noDescription: "", +} + +export const TemplateLayout: FC = () => { + const styles = useStyles() + const organizationId = useOrganizationId() + const templateName = useTemplateName() + const { t } = useTranslation("templatePage") + const [templateState, templateSend] = useMachine(templateMachine, { + context: { + templateName, + organizationId, + }, + }) + const { template, activeTemplateVersion, templateResources, templateDAUs } = templateState.context + const xServices = useContext(XServiceContext) + const permissions = useSelector(xServices.authXService, selectPermissions) + const isLoading = + !template || !activeTemplateVersion || !templateResources || !permissions || !templateDAUs + + if (isLoading) { + return + } + + if (templateState.matches("deleted")) { + return + } + + const hasIcon = template.icon && template.icon !== "" + + const createWorkspaceButton = (className?: string) => ( + + + + ) + + const handleDeleteTemplate = () => { + templateSend("DELETE") + } + + return ( + <> + + + + + + + {permissions.deleteTemplates ? ( + , + }, + ]} + canCancel={false} + /> + ) : ( + createWorkspaceButton() + )} + + } + > + +
+ {hasIcon ? ( +
+ +
+ ) : ( + {firstLetter(template.name)} + )} +
+
+ {template.name} + + {template.description === "" ? Language.noDescription : template.description} + +
+
+
+
+ +
+ + + + combineClasses([styles.tabItem, isActive ? styles.tabItemActive : undefined]) + } + > + Summary + + + combineClasses([styles.tabItem, isActive ? styles.tabItemActive : undefined]) + } + > + Collaborators + + + +
+ + + + + + { + templateSend("CONFIRM_DELETE") + }} + onCancel={() => { + templateSend("CANCEL_DELETE") + }} + /> + + ) +} + +export const useStyles = makeStyles((theme) => { + return { + actionButton: { + border: "none", + borderRadius: `${theme.shape.borderRadius}px 0px 0px ${theme.shape.borderRadius}px`, + }, + pageTitle: { + alignItems: "center", + }, + avatar: { + width: theme.spacing(6), + height: theme.spacing(6), + fontSize: theme.spacing(3), + }, + iconWrapper: { + width: theme.spacing(6), + height: theme.spacing(6), + "& img": { + width: "100%", + }, + }, + + tabs: { + borderBottom: `1px solid ${theme.palette.divider}`, + marginBottom: theme.spacing(5), + }, + + tabItem: { + textDecoration: "none", + color: theme.palette.text.secondary, + fontSize: 14, + display: "block", + padding: theme.spacing(0, 2, 2), + + "&:hover": { + color: theme.palette.text.primary, + }, + }, + + tabItemActive: { + color: theme.palette.text.primary, + position: "relative", + + "&:before": { + content: `""`, + left: 0, + bottom: 0, + height: 2, + width: "100%", + background: theme.palette.secondary.dark, + position: "absolute", + }, + }, + } +}) diff --git a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx new file mode 100644 index 0000000000000..0f595935927a5 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx @@ -0,0 +1,28 @@ +import { FC } from "react" +import { Helmet } from "react-helmet-async" +import { useOutletContext } from "react-router-dom" +import { pageTitle } from "util/page" +import { TemplateContext } from "xServices/template/templateXService" +import { TemplateCollaboratorsPageView } from "./TemplateCollaboratorsPageView" + +export const TemplateCollaboratorsPage: FC> = () => { + const { template, activeTemplateVersion, templateResources, deleteTemplateError } = + useOutletContext() + + if (!template || !activeTemplateVersion || !templateResources) { + throw new Error( + "This page should not be displayed until template, activeTemplateVersion or templateResources being loaded.", + ) + } + + return ( + <> + + Codestin Search App + + + + ) +} + +export default TemplateCollaboratorsPage diff --git a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx new file mode 100644 index 0000000000000..8fda90e8af94d --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx @@ -0,0 +1,27 @@ +import { makeStyles } from "@material-ui/core/styles" +import { ErrorSummary } from "components/ErrorSummary/ErrorSummary" +import { Stack } from "components/Stack/Stack" +import { FC } from "react" + +export interface TemplateCollaboratorsPageViewProps { + deleteTemplateError: Error | unknown +} + +export const TemplateCollaboratorsPageView: FC< + React.PropsWithChildren +> = ({ deleteTemplateError }) => { + const deleteError = deleteTemplateError ? ( + + ) : null + + return ( + + {deleteError} +

Collaborators

+
+ ) +} + +export const useStyles = makeStyles(() => { + return {} +}) diff --git a/site/src/pages/TemplatePage/TemplatePage.tsx b/site/src/pages/TemplatePage/TemplatePage.tsx deleted file mode 100644 index 95141c53841f2..0000000000000 --- a/site/src/pages/TemplatePage/TemplatePage.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useMachine, useSelector } from "@xstate/react" -import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog" -import { FC, useContext } from "react" -import { Helmet } from "react-helmet-async" -import { useTranslation } from "react-i18next" -import { Navigate, useParams } from "react-router-dom" -import { selectPermissions } from "xServices/auth/authSelectors" -import { XServiceContext } from "xServices/StateContext" -import { Loader } from "../../components/Loader/Loader" -import { useOrganizationId } from "../../hooks/useOrganizationId" -import { pageTitle } from "../../util/page" -import { templateMachine } from "../../xServices/template/templateXService" -import { TemplatePageView } from "./TemplatePageView" - -const useTemplateName = () => { - const { template } = useParams() - - if (!template) { - throw new Error("No template found in the URL") - } - - return template -} - -export const TemplatePage: FC> = () => { - const organizationId = useOrganizationId() - const { t } = useTranslation("templatePage") - const templateName = useTemplateName() - const [templateState, templateSend] = useMachine(templateMachine, { - context: { - templateName, - organizationId, - }, - }) - - const { - template, - activeTemplateVersion, - templateResources, - templateVersions, - deleteTemplateError, - templateDAUs, - } = templateState.context - const xServices = useContext(XServiceContext) - const permissions = useSelector(xServices.authXService, selectPermissions) - const isLoading = - !template || !activeTemplateVersion || !templateResources || !permissions || !templateDAUs - - const handleDeleteTemplate = () => { - templateSend("DELETE") - } - - if (isLoading) { - return - } - - if (templateState.matches("deleted")) { - return - } - - return ( - <> - - Codestin Search App - - - - { - templateSend("CONFIRM_DELETE") - }} - onCancel={() => { - templateSend("CANCEL_DELETE") - }} - /> - - ) -} - -export default TemplatePage diff --git a/site/src/pages/TemplatePage/TemplatePageView.tsx b/site/src/pages/TemplatePage/TemplatePageView.tsx deleted file mode 100644 index 82414c32bba38..0000000000000 --- a/site/src/pages/TemplatePage/TemplatePageView.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import Avatar from "@material-ui/core/Avatar" -import Button from "@material-ui/core/Button" -import Link from "@material-ui/core/Link" -import { makeStyles } from "@material-ui/core/styles" -import AddCircleOutline from "@material-ui/icons/AddCircleOutline" -import SettingsOutlined from "@material-ui/icons/SettingsOutlined" -import { DeleteButton } from "components/DropdownButton/ActionCtas" -import { DropdownButton } from "components/DropdownButton/DropdownButton" -import { ErrorSummary } from "components/ErrorSummary/ErrorSummary" -import { Markdown } from "components/Markdown/Markdown" -import frontMatter from "front-matter" -import { FC } from "react" -import { Link as RouterLink } from "react-router-dom" -import { firstLetter } from "util/firstLetter" -import { - Template, - TemplateDAUsResponse, - TemplateVersion, - WorkspaceResource, -} from "../../api/typesGenerated" -import { Margins } from "../../components/Margins/Margins" -import { - PageHeader, - PageHeaderSubtitle, - PageHeaderTitle, -} from "../../components/PageHeader/PageHeader" -import { Stack } from "../../components/Stack/Stack" -import { TemplateResourcesTable } from "../../components/TemplateResourcesTable/TemplateResourcesTable" -import { TemplateStats } from "../../components/TemplateStats/TemplateStats" -import { VersionsTable } from "../../components/VersionsTable/VersionsTable" -import { WorkspaceSection } from "../../components/WorkspaceSection/WorkspaceSection" -import { DAUChart } from "./DAUChart" - -const Language = { - settingsButton: "Settings", - createButton: "Create workspace", - noDescription: "", - readmeTitle: "README", - resourcesTitle: "Resources", - versionsTitle: "Version history", -} - -export interface TemplatePageViewProps { - template: Template - activeTemplateVersion: TemplateVersion - templateResources: WorkspaceResource[] - templateVersions?: TemplateVersion[] - templateDAUs?: TemplateDAUsResponse - handleDeleteTemplate: (templateId: string) => void - deleteTemplateError: Error | unknown - canDeleteTemplate: boolean -} - -export const TemplatePageView: FC> = ({ - template, - activeTemplateVersion, - templateResources, - templateVersions, - templateDAUs, - handleDeleteTemplate, - deleteTemplateError, - canDeleteTemplate, -}) => { - const styles = useStyles() - const readme = frontMatter(activeTemplateVersion.readme) - const hasIcon = template.icon && template.icon !== "" - - const deleteError = deleteTemplateError ? ( - - ) : ( - <> - ) - - const getStartedResources = (resources: WorkspaceResource[]) => { - return resources.filter((resource) => resource.workspace_transition === "start") - } - - const createWorkspaceButton = (className?: string) => ( - - - - ) - - return ( - - <> - - - - - - {canDeleteTemplate ? ( - handleDeleteTemplate(template.id)} /> - ), - }, - ]} - canCancel={false} - /> - ) : ( - createWorkspaceButton() - )} - - } - > - -
- {hasIcon ? ( -
- -
- ) : ( - {firstLetter(template.name)} - )} -
-
- {template.name} - - {template.description === "" ? Language.noDescription : template.description} - -
-
-
- - - {deleteError} - {templateDAUs && } - - - -
- {readme.body} -
-
- - - -
- -
- ) -} - -export const useStyles = makeStyles((theme) => { - return { - actionButton: { - border: "none", - borderRadius: `${theme.shape.borderRadius}px 0px 0px ${theme.shape.borderRadius}px`, - }, - readmeContents: { - margin: 0, - }, - markdownWrapper: { - background: theme.palette.background.paper, - padding: theme.spacing(3, 4), - }, - versionsTableContents: { - margin: 0, - }, - pageTitle: { - alignItems: "center", - }, - avatar: { - width: theme.spacing(6), - height: theme.spacing(6), - fontSize: theme.spacing(3), - }, - iconWrapper: { - width: theme.spacing(6), - height: theme.spacing(6), - "& img": { - width: "100%", - }, - }, - } -}) diff --git a/site/src/pages/TemplatePage/DAUChart.test.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/DAUChart.test.tsx similarity index 100% rename from site/src/pages/TemplatePage/DAUChart.test.tsx rename to site/src/pages/TemplatePage/TemplateSummaryPage/DAUChart.test.tsx diff --git a/site/src/pages/TemplatePage/DAUChart.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/DAUChart.tsx similarity index 98% rename from site/src/pages/TemplatePage/DAUChart.tsx rename to site/src/pages/TemplatePage/TemplateSummaryPage/DAUChart.tsx index 5e12717b75d79..3578fd9defc0e 100644 --- a/site/src/pages/TemplatePage/DAUChart.tsx +++ b/site/src/pages/TemplatePage/TemplateSummaryPage/DAUChart.tsx @@ -1,6 +1,6 @@ -import useTheme from "@material-ui/styles/useTheme" - import { Theme } from "@material-ui/core/styles" +import useTheme from "@material-ui/styles/useTheme" +import * as TypesGen from "api/typesGenerated" import { BarElement, CategoryScale, @@ -20,7 +20,6 @@ import { WorkspaceSection } from "components/WorkspaceSection/WorkspaceSection" import dayjs from "dayjs" import { FC } from "react" import { Line } from "react-chartjs-2" -import * as TypesGen from "../../api/typesGenerated" ChartJS.register( CategoryScale, diff --git a/site/src/pages/TemplatePage/TemplatePage.test.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.test.tsx similarity index 87% rename from site/src/pages/TemplatePage/TemplatePage.test.tsx rename to site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.test.tsx index 27252bdd7bdfd..98abe02c60b67 100644 --- a/site/src/pages/TemplatePage/TemplatePage.test.tsx +++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.test.tsx @@ -1,8 +1,6 @@ import { fireEvent, screen } from "@testing-library/react" import { rest } from "msw" import { ResizeObserver } from "resize-observer" -import { server } from "testHelpers/server" -import * as CreateDayString from "util/createDayString" import { MockMemberPermissions, MockTemplate, @@ -10,8 +8,10 @@ import { MockUser, MockWorkspaceResource, renderWithAuth, -} from "../../testHelpers/renderHelpers" -import { TemplatePage } from "./TemplatePage" +} from "testHelpers/renderHelpers" +import { server } from "testHelpers/server" +import * as CreateDayString from "util/createDayString" +import { TemplateSummaryPage } from "./TemplateSummaryPage" jest.mock("remark-gfm", () => jest.fn()) @@ -19,13 +19,13 @@ Object.defineProperty(window, "ResizeObserver", { value: ResizeObserver, }) -describe("TemplatePage", () => { +describe("TemplateSummaryPage", () => { it("shows the template name, readme and resources", async () => { // Mocking the dayjs module within the createDayString file const mock = jest.spyOn(CreateDayString, "createDayString") mock.mockImplementation(() => "a minute ago") - renderWithAuth(, { + renderWithAuth(, { route: `/templates/${MockTemplate.id}`, path: "/templates/:template", }) @@ -35,7 +35,7 @@ describe("TemplatePage", () => { screen.queryAllByText(`${MockTemplateVersion.name}`).length }) it("allows an admin to delete a template", async () => { - renderWithAuth(, { + renderWithAuth(, { route: `/templates/${MockTemplate.id}`, path: "/templates/:template", }) @@ -51,7 +51,7 @@ describe("TemplatePage", () => { return res(ctx.status(200), ctx.json(MockMemberPermissions)) }), ) - renderWithAuth(, { + renderWithAuth(, { route: `/templates/${MockTemplate.id}`, path: "/templates/:template", }) diff --git a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx new file mode 100644 index 0000000000000..7f1ba1e918933 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx @@ -0,0 +1,41 @@ +import { FC } from "react" +import { Helmet } from "react-helmet-async" +import { useOutletContext } from "react-router-dom" +import { pageTitle } from "util/page" +import { TemplateContext } from "xServices/template/templateXService" +import { TemplateSummaryPageView } from "./TemplateSummaryPageView" + +export const TemplateSummaryPage: FC> = () => { + const { + template, + activeTemplateVersion, + templateResources, + templateVersions, + deleteTemplateError, + templateDAUs, + } = useOutletContext() + + if (!template || !activeTemplateVersion || !templateResources) { + throw new Error( + "This page should not be displayed until template, activeTemplateVersion or templateResources being loaded.", + ) + } + + return ( + <> + + Codestin Search App + + + + ) +} + +export default TemplateSummaryPage diff --git a/site/src/pages/TemplatePage/TemplatePageView.stories.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.stories.tsx similarity index 78% rename from site/src/pages/TemplatePage/TemplatePageView.stories.tsx rename to site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.stories.tsx index 10a12b701a672..78f91fe7cbeee 100644 --- a/site/src/pages/TemplatePage/TemplatePageView.stories.tsx +++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.stories.tsx @@ -1,13 +1,15 @@ import { Story } from "@storybook/react" -import * as Mocks from "../../testHelpers/renderHelpers" -import { TemplatePageView, TemplatePageViewProps } from "./TemplatePageView" +import * as Mocks from "testHelpers/renderHelpers" +import { TemplateSummaryPageView, TemplateSummaryPageViewProps } from "./TemplateSummaryPageView" export default { - title: "pages/TemplatePageView", - component: TemplatePageView, + title: "pages/TemplateSummaryPageView", + component: TemplateSummaryPageView, } -const Template: Story = (args) => +const Template: Story = (args) => ( + +) export const Example = Template.bind({}) Example.args = { diff --git a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx new file mode 100644 index 0000000000000..54a9ddad2cff2 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx @@ -0,0 +1,81 @@ +import { makeStyles } from "@material-ui/core/styles" +import { + Template, + TemplateDAUsResponse, + TemplateVersion, + WorkspaceResource, +} from "api/typesGenerated" +import { ErrorSummary } from "components/ErrorSummary/ErrorSummary" +import { Markdown } from "components/Markdown/Markdown" +import { Stack } from "components/Stack/Stack" +import { TemplateResourcesTable } from "components/TemplateResourcesTable/TemplateResourcesTable" +import { TemplateStats } from "components/TemplateStats/TemplateStats" +import { VersionsTable } from "components/VersionsTable/VersionsTable" +import { WorkspaceSection } from "components/WorkspaceSection/WorkspaceSection" +import frontMatter from "front-matter" +import { FC } from "react" +import { DAUChart } from "./DAUChart" + +const Language = { + readmeTitle: "README", + resourcesTitle: "Resources", +} + +export interface TemplateSummaryPageViewProps { + template: Template + activeTemplateVersion: TemplateVersion + templateResources: WorkspaceResource[] + templateVersions?: TemplateVersion[] + templateDAUs?: TemplateDAUsResponse + deleteTemplateError: Error | unknown +} + +export const TemplateSummaryPageView: FC> = ({ + template, + activeTemplateVersion, + templateResources, + templateVersions, + templateDAUs, + deleteTemplateError, +}) => { + const styles = useStyles() + const readme = frontMatter(activeTemplateVersion.readme) + + const deleteError = deleteTemplateError ? ( + + ) : null + + const getStartedResources = (resources: WorkspaceResource[]) => { + return resources.filter((resource) => resource.workspace_transition === "start") + } + + return ( + + {deleteError} + {templateDAUs && } + + + +
+ {readme.body} +
+
+ +
+ ) +} + +export const useStyles = makeStyles((theme) => { + return { + readmeContents: { + margin: 0, + }, + markdownWrapper: { + background: theme.palette.background.paper, + padding: theme.spacing(3, 4), + }, + } +}) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 598d37753a71f..bbb344d97dbb5 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -183,6 +183,8 @@ export const MockTemplate: TypesGen.Template = { created_by_id: "test-creator-id", created_by_name: "test_creator", icon: "/icon/code.svg", + user_roles: {}, + is_private: false, } export const MockWorkspaceAutostartDisabled: TypesGen.UpdateWorkspaceAutostartRequest = { diff --git a/site/src/xServices/template/templateXService.ts b/site/src/xServices/template/templateXService.ts index 671cb9cf2d643..c8fba0eace2fc 100644 --- a/site/src/xServices/template/templateXService.ts +++ b/site/src/xServices/template/templateXService.ts @@ -16,7 +16,7 @@ import { WorkspaceResource, } from "../../api/typesGenerated" -interface TemplateContext { +export interface TemplateContext { organizationId: string templateName: string template?: Template From 0f80beb4a215d2f053b569fcaa2f79e577d38a2e Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 21 Sep 2022 16:19:09 +0000 Subject: [PATCH 33/37] Add table --- site/src/api/api.ts | 7 + .../TemplateCollaboratorsPage.tsx | 10 +- .../TemplateCollaboratorsPageView.tsx | 136 +++++++++++++++++- .../template/templateUsersXService.ts | 46 ++++++ 4 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 site/src/xServices/template/templateUsersXService.ts diff --git a/site/src/api/api.ts b/site/src/api/api.ts index d3dae5a17c765..d0766f954052d 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -486,3 +486,10 @@ export const getTemplateDAUs = async ( const response = await axios.get(`/api/v2/templates/${templateId}/daus`) return response.data } + +export const getTemplateUserRoles = async ( + templateId: string, +): Promise => { + const response = await axios.get(`/api/v2/templates/${templateId}/user-roles`) + return response.data +} diff --git a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx index 0f595935927a5..ef71ebb35d8ab 100644 --- a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx +++ b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx @@ -1,7 +1,9 @@ +import { useMachine } from "@xstate/react" import { FC } from "react" import { Helmet } from "react-helmet-async" import { useOutletContext } from "react-router-dom" import { pageTitle } from "util/page" +import { templateUsersMachine } from "xServices/template/templateUsersXService" import { TemplateContext } from "xServices/template/templateXService" import { TemplateCollaboratorsPageView } from "./TemplateCollaboratorsPageView" @@ -15,12 +17,18 @@ export const TemplateCollaboratorsPage: FC> = ( ) } + const [state] = useMachine(templateUsersMachine, { context: { templateId: template.id } }) + const { templateUsers } = state.context + return ( <> Codestin Search App - + ) } diff --git a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx index 8fda90e8af94d..0f20032083178 100644 --- a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx +++ b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx @@ -1,15 +1,37 @@ +import Button from "@material-ui/core/Button" +import CircularProgress from "@material-ui/core/CircularProgress" +import MenuItem from "@material-ui/core/MenuItem" +import Select from "@material-ui/core/Select" import { makeStyles } from "@material-ui/core/styles" +import Table from "@material-ui/core/Table" +import TableBody from "@material-ui/core/TableBody" +import TableCell from "@material-ui/core/TableCell" +import TableContainer from "@material-ui/core/TableContainer" +import TableHead from "@material-ui/core/TableHead" +import TableRow from "@material-ui/core/TableRow" +import TextField from "@material-ui/core/TextField" +import PersonAdd from "@material-ui/icons/PersonAdd" +import Autocomplete from "@material-ui/lab/Autocomplete" +import { TemplateUser } from "api/typesGenerated" +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" +import { EmptyState } from "components/EmptyState/EmptyState" import { ErrorSummary } from "components/ErrorSummary/ErrorSummary" import { Stack } from "components/Stack/Stack" -import { FC } from "react" +import { TableLoader } from "components/TableLoader/TableLoader" +import { FC, useState } from "react" export interface TemplateCollaboratorsPageViewProps { deleteTemplateError: Error | unknown + templateUsers: TemplateUser[] | undefined } export const TemplateCollaboratorsPageView: FC< React.PropsWithChildren -> = ({ deleteTemplateError }) => { +> = ({ deleteTemplateError, templateUsers }) => { + const styles = useStyles() + const [open, setOpen] = useState(false) + const [options, setOptions] = useState([]) + const isLoading = false const deleteError = deleteTemplateError ? ( ) : null @@ -17,11 +39,115 @@ export const TemplateCollaboratorsPageView: FC< return ( {deleteError} -

Collaborators

+ + { + setOpen(true) + }} + onClose={() => { + setOpen(false) + }} + getOptionSelected={(option: any, value: any) => option.name === value.name} + getOptionLabel={(option) => option.name} + options={options} + loading={isLoading} + className={styles.autocomplete} + renderInput={(params) => ( + + {isLoading ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + /> + + + + + + + + + + + User + Role + + + + + + + + + + + + + + + 0)}> + + Kyle + Admin + + + + +
+
) } -export const useStyles = makeStyles(() => { - return {} +export const useStyles = makeStyles((theme) => { + return { + autocomplete: { + "& .MuiInputBase-root": { + width: 300, + // Match button small height + height: 36, + }, + + "& input": { + fontSize: 14, + padding: `${theme.spacing(0, 0.5, 0, 0.5)} !important`, + }, + }, + + select: { + // Match button small height + height: 36, + fontSize: 14, + width: 100, + }, + } }) diff --git a/site/src/xServices/template/templateUsersXService.ts b/site/src/xServices/template/templateUsersXService.ts new file mode 100644 index 0000000000000..c4f9451747df6 --- /dev/null +++ b/site/src/xServices/template/templateUsersXService.ts @@ -0,0 +1,46 @@ +import { getTemplateUserRoles } from "api/api" +import { TemplateUser } from "api/typesGenerated" +import { assign, createMachine } from "xstate" + +export const templateUsersMachine = createMachine( + { + schema: { + context: {} as { + templateId: string + templateUsers?: TemplateUser[] + }, + services: {} as { + loadTemplateUsers: { + data: TemplateUser[] + } + }, + }, + tsTypes: {} as import("./templateUsersXService.typegen").Typegen0, + id: "templateUserRoles", + initial: "loading", + states: { + loading: { + invoke: { + src: "loadTemplateUsers", + onDone: { + actions: ["assignTemplateUsers"], + target: "success", + }, + }, + }, + success: { + type: "final", + }, + }, + }, + { + services: { + loadTemplateUsers: ({ templateId }) => getTemplateUserRoles(templateId), + }, + actions: { + assignTemplateUsers: assign({ + templateUsers: (_, { data }) => data, + }), + }, + }, +) From d274d62449038654efb9007ff14c25ae9667f634 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 21 Sep 2022 16:51:55 +0000 Subject: [PATCH 34/37] Add search user --- .../TemplateCollaboratorsPageView.tsx | 57 ++++++++++++++----- .../src/xServices/users/searchUserXService.ts | 55 ++++++++++++++++++ 2 files changed, 99 insertions(+), 13 deletions(-) create mode 100644 site/src/xServices/users/searchUserXService.ts diff --git a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx index 0f20032083178..e794924e1cd1f 100644 --- a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx +++ b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx @@ -12,13 +12,17 @@ import TableRow from "@material-ui/core/TableRow" import TextField from "@material-ui/core/TextField" import PersonAdd from "@material-ui/icons/PersonAdd" import Autocomplete from "@material-ui/lab/Autocomplete" -import { TemplateUser } from "api/typesGenerated" +import { useMachine } from "@xstate/react" +import { TemplateUser, User } from "api/typesGenerated" +import { AvatarData } from "components/AvatarData/AvatarData" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" import { EmptyState } from "components/EmptyState/EmptyState" import { ErrorSummary } from "components/ErrorSummary/ErrorSummary" import { Stack } from "components/Stack/Stack" import { TableLoader } from "components/TableLoader/TableLoader" -import { FC, useState } from "react" +import debounce from "just-debounce-it" +import { ChangeEvent, FC, useState } from "react" +import { searchUserMachine } from "xServices/users/searchUserXService" export interface TemplateCollaboratorsPageViewProps { deleteTemplateError: Error | unknown @@ -29,13 +33,17 @@ export const TemplateCollaboratorsPageView: FC< React.PropsWithChildren > = ({ deleteTemplateError, templateUsers }) => { const styles = useStyles() - const [open, setOpen] = useState(false) - const [options, setOptions] = useState([]) - const isLoading = false + const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false) + const [searchState, sendSearch] = useMachine(searchUserMachine) + const { searchResults } = searchState.context const deleteError = deleteTemplateError ? ( ) : null + const handleFilterChange = debounce((event: ChangeEvent) => { + sendSearch("SEARCH", { query: event.target.value }) + }, 1000) + return ( {deleteError} @@ -43,17 +51,33 @@ export const TemplateCollaboratorsPageView: FC< { - setOpen(true) + setIsAutocompleteOpen(true) }} onClose={() => { - setOpen(false) + setIsAutocompleteOpen(false) }} - getOptionSelected={(option: any, value: any) => option.name === value.name} - getOptionLabel={(option) => option.name} - options={options} - loading={isLoading} + getOptionSelected={(option: User, value: User) => option.username === value.username} + getOptionLabel={(option) => option.email} + renderOption={(option: User) => ( + + ) : null + } + /> + )} + options={searchResults} + loading={searchState.matches("searching")} className={styles.autocomplete} renderInput={(params) => ( - {isLoading ? : null} + {searchState.matches("searching") ? : null} {params.InputProps.endAdornment} ), @@ -149,5 +174,11 @@ export const useStyles = makeStyles((theme) => { fontSize: 14, width: 100, }, + + avatar: { + width: theme.spacing(4.5), + height: theme.spacing(4.5), + borderRadius: "100%", + }, } }) diff --git a/site/src/xServices/users/searchUserXService.ts b/site/src/xServices/users/searchUserXService.ts new file mode 100644 index 0000000000000..6d7f31d23da91 --- /dev/null +++ b/site/src/xServices/users/searchUserXService.ts @@ -0,0 +1,55 @@ +import { getUsers } from "api/api" +import { User } from "api/typesGenerated" +import { queryToFilter } from "util/filters" +import { assign, createMachine } from "xstate" + +export const searchUserMachine = createMachine( + { + id: "searchUserMachine", + schema: { + context: {} as { + searchResults: User[] + }, + events: {} as { + type: "SEARCH" + query: string + }, + services: {} as { + searchUsers: { + data: User[] + } + }, + }, + context: { + searchResults: [], + }, + tsTypes: {} as import("./searchUserXService.typegen").Typegen0, + initial: "idle", + states: { + idle: { + on: { + SEARCH: "searching", + }, + }, + searching: { + invoke: { + src: "searchUsers", + onDone: { + target: "idle", + actions: ["assignSearchResults"], + }, + }, + }, + }, + }, + { + services: { + searchUsers: (_, { query }) => getUsers(queryToFilter(query)), + }, + actions: { + assignSearchResults: assign({ + searchResults: (_, { data }) => data, + }), + }, + }, +) From 943c76bc16a831b5276c871aaeb4c6c6220eb7cd Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 21 Sep 2022 17:40:43 +0000 Subject: [PATCH 35/37] Add user role --- .../TemplateCollaboratorsPage.tsx | 6 +- .../TemplateCollaboratorsPageView.tsx | 109 ++++++++++++++---- .../template/templateUsersXService.ts | 55 ++++++++- 3 files changed, 142 insertions(+), 28 deletions(-) diff --git a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx index ef71ebb35d8ab..685bf09821df2 100644 --- a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx +++ b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx @@ -17,7 +17,7 @@ export const TemplateCollaboratorsPage: FC> = ( ) } - const [state] = useMachine(templateUsersMachine, { context: { templateId: template.id } }) + const [state, send] = useMachine(templateUsersMachine, { context: { templateId: template.id } }) const { templateUsers } = state.context return ( @@ -28,6 +28,10 @@ export const TemplateCollaboratorsPage: FC> = ( { + send("ADD_USER", { user, role, onDone: reset }) + }} + isAddingUser={state.matches("addingUser")} /> ) diff --git a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx index e794924e1cd1f..e4d48901225ca 100644 --- a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx +++ b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx @@ -1,4 +1,3 @@ -import Button from "@material-ui/core/Button" import CircularProgress from "@material-ui/core/CircularProgress" import MenuItem from "@material-ui/core/MenuItem" import Select from "@material-ui/core/Select" @@ -13,42 +12,52 @@ import TextField from "@material-ui/core/TextField" import PersonAdd from "@material-ui/icons/PersonAdd" import Autocomplete from "@material-ui/lab/Autocomplete" import { useMachine } from "@xstate/react" -import { TemplateUser, User } from "api/typesGenerated" +import { TemplateRole, TemplateUser, User } from "api/typesGenerated" import { AvatarData } from "components/AvatarData/AvatarData" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" import { EmptyState } from "components/EmptyState/EmptyState" import { ErrorSummary } from "components/ErrorSummary/ErrorSummary" +import { LoadingButton } from "components/LoadingButton/LoadingButton" import { Stack } from "components/Stack/Stack" import { TableLoader } from "components/TableLoader/TableLoader" import debounce from "just-debounce-it" import { ChangeEvent, FC, useState } from "react" import { searchUserMachine } from "xServices/users/searchUserXService" -export interface TemplateCollaboratorsPageViewProps { - deleteTemplateError: Error | unknown - templateUsers: TemplateUser[] | undefined -} - -export const TemplateCollaboratorsPageView: FC< - React.PropsWithChildren -> = ({ deleteTemplateError, templateUsers }) => { +const AddTemplateUser: React.FC<{ + isLoading: boolean + onSubmit: (user: User, role: TemplateRole, reset: () => void) => void +}> = ({ isLoading, onSubmit }) => { const styles = useStyles() const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false) const [searchState, sendSearch] = useMachine(searchUserMachine) const { searchResults } = searchState.context - const deleteError = deleteTemplateError ? ( - - ) : null + const [selectedUser, setSelectedUser] = useState(null) + const [selectedRole, setSelectedRole] = useState("read") const handleFilterChange = debounce((event: ChangeEvent) => { sendSearch("SEARCH", { query: event.target.value }) }, 1000) + const resetValues = () => { + setSelectedUser(null) + setSelectedRole("read") + } + return ( - - {deleteError} +
{ + e.preventDefault() + + if (selectedUser && selectedRole) { + onSubmit(selectedUser, selectedRole, resetValues) + } + }} + > { setIsAutocompleteOpen(false) }} + onChange={(event, newValue) => { + setSelectedUser(newValue) + }} getOptionSelected={(option: User, value: User) => option.username === value.username} getOptionLabel={(option) => option.email} renderOption={(option: User) => ( @@ -99,7 +111,15 @@ export const TemplateCollaboratorsPageView: FC< )} /> - { + setSelectedRole(event.target.value as TemplateRole) + }} + > Read @@ -111,11 +131,39 @@ export const TemplateCollaboratorsPageView: FC< - + +
+ ) +} + +export interface TemplateCollaboratorsPageViewProps { + deleteTemplateError: Error | unknown + templateUsers: TemplateUser[] | undefined + onAddUser: (user: User, role: TemplateRole, reset: () => void) => void + isAddingUser: boolean +} + +export const TemplateCollaboratorsPageView: FC< + React.PropsWithChildren +> = ({ deleteTemplateError, templateUsers, onAddUser, isAddingUser }) => { + const styles = useStyles() + const deleteError = deleteTemplateError ? ( + + ) : null + return ( + + {deleteError} + @@ -140,10 +188,27 @@ export const TemplateCollaboratorsPageView: FC< 0)}> - - Kyle - Admin - + {templateUsers?.map((user) => ( + + + + ) : null + } + /> + + {user.role} + + ))} diff --git a/site/src/xServices/template/templateUsersXService.ts b/site/src/xServices/template/templateUsersXService.ts index c4f9451747df6..48d6d1c7d49c4 100644 --- a/site/src/xServices/template/templateUsersXService.ts +++ b/site/src/xServices/template/templateUsersXService.ts @@ -1,5 +1,5 @@ -import { getTemplateUserRoles } from "api/api" -import { TemplateUser } from "api/typesGenerated" +import { getTemplateUserRoles, updateTemplateMeta } from "api/api" +import { TemplateRole, TemplateUser, User } from "api/typesGenerated" import { assign, createMachine } from "xstate" export const templateUsersMachine = createMachine( @@ -8,11 +8,22 @@ export const templateUsersMachine = createMachine( context: {} as { templateId: string templateUsers?: TemplateUser[] + userToBeAdded?: TemplateUser + addUserCallback?: () => void }, services: {} as { loadTemplateUsers: { data: TemplateUser[] } + addUser: { + data: unknown + } + }, + events: {} as { + type: "ADD_USER" + user: User + role: TemplateRole + onDone: () => void }, }, tsTypes: {} as import("./templateUsersXService.typegen").Typegen0, @@ -24,23 +35,57 @@ export const templateUsersMachine = createMachine( src: "loadTemplateUsers", onDone: { actions: ["assignTemplateUsers"], - target: "success", + target: "idle", }, }, }, - success: { - type: "final", + idle: { + on: { + ADD_USER: { target: "addingUser", actions: ["assignUserToBeAdded"] }, + }, + }, + addingUser: { + invoke: { + src: "addUser", + onDone: { + target: "idle", + actions: ["addUserToTemplateUsers", "runCallback"], + }, + }, }, }, }, { services: { loadTemplateUsers: ({ templateId }) => getTemplateUserRoles(templateId), + addUser: ({ templateId }, { user, role }) => + updateTemplateMeta(templateId, { + user_perms: { + [user.id]: role, + }, + }), }, actions: { assignTemplateUsers: assign({ templateUsers: (_, { data }) => data, }), + assignUserToBeAdded: assign({ + userToBeAdded: (_, { user, role }) => ({ ...user, role }), + addUserCallback: (_, { onDone }) => onDone, + }), + addUserToTemplateUsers: assign({ + templateUsers: ({ templateUsers = [], userToBeAdded }) => { + if (!userToBeAdded) { + throw new Error("No user to be added") + } + return [...templateUsers, userToBeAdded] + }, + }), + runCallback: ({ addUserCallback }) => { + if (addUserCallback) { + addUserCallback() + } + }, }, }, ) From 7f7f1d3dba71e4cb2a96248514ab08b13119c481 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 21 Sep 2022 19:04:48 +0000 Subject: [PATCH 36/37] Add update and delete --- .../TemplateLayout/TemplateLayout.tsx | 2 +- site/src/hooks/useMe.ts | 16 +++ .../TemplateCollaboratorsPage.tsx | 22 ++++- .../TemplateCollaboratorsPageView.tsx | 73 +++++++++++++- site/src/xServices/auth/authXService.ts | 2 +- .../template/templateUsersXService.ts | 99 +++++++++++++++++-- 6 files changed, 198 insertions(+), 16 deletions(-) create mode 100644 site/src/hooks/useMe.ts diff --git a/site/src/components/TemplateLayout/TemplateLayout.tsx b/site/src/components/TemplateLayout/TemplateLayout.tsx index de1ea42041654..0b5ce9e83c775 100644 --- a/site/src/components/TemplateLayout/TemplateLayout.tsx +++ b/site/src/components/TemplateLayout/TemplateLayout.tsx @@ -155,7 +155,7 @@ export const TemplateLayout: FC = () => { - + { + const xServices = useContext(XServiceContext) + const me = useSelector(xServices.authXService, selectUser) + + if (!me) { + throw new Error("User not found.") + } + + return me +} diff --git a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx index 685bf09821df2..6eccf9c4487af 100644 --- a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx +++ b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx @@ -1,15 +1,21 @@ import { useMachine } from "@xstate/react" +import { useMe } from "hooks/useMe" import { FC } from "react" import { Helmet } from "react-helmet-async" import { useOutletContext } from "react-router-dom" import { pageTitle } from "util/page" +import { Permissions } from "xServices/auth/authXService" import { templateUsersMachine } from "xServices/template/templateUsersXService" import { TemplateContext } from "xServices/template/templateXService" import { TemplateCollaboratorsPageView } from "./TemplateCollaboratorsPageView" export const TemplateCollaboratorsPage: FC> = () => { + const { templateContext, permissions } = useOutletContext<{ + templateContext: TemplateContext + permissions: Permissions + }>() const { template, activeTemplateVersion, templateResources, deleteTemplateError } = - useOutletContext() + templateContext if (!template || !activeTemplateVersion || !templateResources) { throw new Error( @@ -18,7 +24,11 @@ export const TemplateCollaboratorsPage: FC> = ( } const [state, send] = useMachine(templateUsersMachine, { context: { templateId: template.id } }) - const { templateUsers } = state.context + const { templateUsers, userToBeUpdated } = state.context + const me = useMe() + const userTemplateRole = template.user_roles[me.id] + const canUpdatesUsers = + permissions.deleteTemplates || userTemplateRole === "admin" || template.created_by_id === me.id return ( <> @@ -26,12 +36,20 @@ export const TemplateCollaboratorsPage: FC> = ( Codestin Search App { send("ADD_USER", { user, role, onDone: reset }) }} isAddingUser={state.matches("addingUser")} + onUpdateUser={(user, role) => { + send("UPDATE_USER_ROLE", { user, role }) + }} + updatingUser={userToBeUpdated} + onRemoveUser={(user) => { + send("REMOVE_USER", { user }) + }} /> ) diff --git a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx index e4d48901225ca..01ab29594953c 100644 --- a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx +++ b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx @@ -20,6 +20,7 @@ import { ErrorSummary } from "components/ErrorSummary/ErrorSummary" import { LoadingButton } from "components/LoadingButton/LoadingButton" import { Stack } from "components/Stack/Stack" import { TableLoader } from "components/TableLoader/TableLoader" +import { TableRowMenu } from "components/TableRowMenu/TableRowMenu" import debounce from "just-debounce-it" import { ChangeEvent, FC, useState } from "react" import { searchUserMachine } from "xServices/users/searchUserXService" @@ -150,11 +151,24 @@ export interface TemplateCollaboratorsPageViewProps { templateUsers: TemplateUser[] | undefined onAddUser: (user: User, role: TemplateRole, reset: () => void) => void isAddingUser: boolean + canUpdateUsers: boolean + onUpdateUser: (user: User, role: TemplateRole) => void + updatingUser: TemplateUser | undefined + onRemoveUser: (user: User) => void } export const TemplateCollaboratorsPageView: FC< React.PropsWithChildren -> = ({ deleteTemplateError, templateUsers, onAddUser, isAddingUser }) => { +> = ({ + deleteTemplateError, + templateUsers, + onAddUser, + isAddingUser, + updatingUser, + onUpdateUser, + canUpdateUsers, + onRemoveUser, +}) => { const styles = useStyles() const deleteError = deleteTemplateError ? ( @@ -168,8 +182,9 @@ export const TemplateCollaboratorsPageView: FC<
- User - Role + User + Role + @@ -206,7 +221,45 @@ export const TemplateCollaboratorsPageView: FC< } /> - {user.role} + + {canUpdateUsers ? ( + + ) : ( + user.role + )} + + + {canUpdateUsers && ( + + onRemoveUser(user), + }, + ]} + /> + + )} ))} @@ -245,5 +298,17 @@ export const useStyles = makeStyles((theme) => { height: theme.spacing(4.5), borderRadius: "100%", }, + + updateSelect: { + margin: 0, + // Set a fixed width for the select. It avoids selects having different sizes + // depending on how many roles they have selected. + width: theme.spacing(25), + "& .MuiSelect-root": { + // Adjusting padding because it does not have label + paddingTop: theme.spacing(1.5), + paddingBottom: theme.spacing(1.5), + }, + }, } }) diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index 1fa3040e9fb96..54d687f435fe5 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -57,7 +57,7 @@ export const permissionsToCheck = { }, } as const -type Permissions = Record +export type Permissions = Record export interface AuthContext { getUserError?: Error | unknown diff --git a/site/src/xServices/template/templateUsersXService.ts b/site/src/xServices/template/templateUsersXService.ts index 48d6d1c7d49c4..c0a20c9592bdc 100644 --- a/site/src/xServices/template/templateUsersXService.ts +++ b/site/src/xServices/template/templateUsersXService.ts @@ -1,5 +1,6 @@ import { getTemplateUserRoles, updateTemplateMeta } from "api/api" import { TemplateRole, TemplateUser, User } from "api/typesGenerated" +import { displaySuccess } from "components/GlobalSnackbar/utils" import { assign, createMachine } from "xstate" export const templateUsersMachine = createMachine( @@ -9,6 +10,7 @@ export const templateUsersMachine = createMachine( templateId: string templateUsers?: TemplateUser[] userToBeAdded?: TemplateUser + userToBeUpdated?: TemplateUser addUserCallback?: () => void }, services: {} as { @@ -18,13 +20,26 @@ export const templateUsersMachine = createMachine( addUser: { data: unknown } + updateUser: { + data: unknown + } }, - events: {} as { - type: "ADD_USER" - user: User - role: TemplateRole - onDone: () => void - }, + events: {} as + | { + type: "ADD_USER" + user: User + role: TemplateRole + onDone: () => void + } + | { + type: "UPDATE_USER_ROLE" + user: User + role: TemplateRole + } + | { + type: "REMOVE_USER" + user: User + }, }, tsTypes: {} as import("./templateUsersXService.typegen").Typegen0, id: "templateUserRoles", @@ -42,6 +57,8 @@ export const templateUsersMachine = createMachine( idle: { on: { ADD_USER: { target: "addingUser", actions: ["assignUserToBeAdded"] }, + UPDATE_USER_ROLE: { target: "updatingUser", actions: ["assignUserToBeUpdated"] }, + REMOVE_USER: { target: "removingUser", actions: ["removeUserFromTemplateUsers"] }, }, }, addingUser: { @@ -49,7 +66,29 @@ export const templateUsersMachine = createMachine( src: "addUser", onDone: { target: "idle", - actions: ["addUserToTemplateUsers", "runCallback"], + actions: ["addUserToTemplateUsers", "runAddCallback"], + }, + }, + }, + updatingUser: { + invoke: { + src: "updateUser", + onDone: { + target: "idle", + actions: [ + "updateUserOnTemplateUsers", + "clearUserToBeUpdated", + "displayUpdateSuccessMessage", + ], + }, + }, + }, + removingUser: { + invoke: { + src: "removeUser", + onDone: { + target: "idle", + actions: ["displayRemoveSuccessMessage"], }, }, }, @@ -64,6 +103,18 @@ export const templateUsersMachine = createMachine( [user.id]: role, }, }), + updateUser: ({ templateId }, { user, role }) => + updateTemplateMeta(templateId, { + user_perms: { + [user.id]: role, + }, + }), + removeUser: ({ templateId }, { user }) => + updateTemplateMeta(templateId, { + user_perms: { + [user.id]: "", + }, + }), }, actions: { assignTemplateUsers: assign({ @@ -81,11 +132,43 @@ export const templateUsersMachine = createMachine( return [...templateUsers, userToBeAdded] }, }), - runCallback: ({ addUserCallback }) => { + runAddCallback: ({ addUserCallback }) => { if (addUserCallback) { addUserCallback() } }, + assignUserToBeUpdated: assign({ + userToBeUpdated: (_, { user, role }) => ({ ...user, role }), + }), + updateUserOnTemplateUsers: assign({ + templateUsers: ({ templateUsers, userToBeUpdated }) => { + if (!templateUsers || !userToBeUpdated) { + throw new Error("No user to be updated.") + } + return templateUsers.map((oldTemplateUser) => { + return oldTemplateUser.id === userToBeUpdated.id ? userToBeUpdated : oldTemplateUser + }) + }, + }), + clearUserToBeUpdated: assign({ + userToBeUpdated: (_) => undefined, + }), + displayUpdateSuccessMessage: () => { + displaySuccess("Collaborator role update successfully!") + }, + removeUserFromTemplateUsers: assign({ + templateUsers: ({ templateUsers }, { user }) => { + if (!templateUsers) { + throw new Error("No user to be removed.") + } + return templateUsers.filter((oldTemplateUser) => { + return oldTemplateUser.id !== user.id + }) + }, + }), + displayRemoveSuccessMessage: () => { + displaySuccess("Collaborator removed successfully!") + }, }, }, ) From 967a1a9a709724c16579d26a14f0f2fab554ca10 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 21 Sep 2022 19:07:26 +0000 Subject: [PATCH 37/37] Fix summary view --- .../TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx | 5 ++--- .../TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx | 3 ++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx index 6eccf9c4487af..94fbd73622200 100644 --- a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx +++ b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx @@ -14,10 +14,9 @@ export const TemplateCollaboratorsPage: FC> = ( templateContext: TemplateContext permissions: Permissions }>() - const { template, activeTemplateVersion, templateResources, deleteTemplateError } = - templateContext + const { template, deleteTemplateError } = templateContext - if (!template || !activeTemplateVersion || !templateResources) { + if (!template) { throw new Error( "This page should not be displayed until template, activeTemplateVersion or templateResources being loaded.", ) diff --git a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx index 7f1ba1e918933..cc7efe62a2bfc 100644 --- a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx +++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx @@ -6,6 +6,7 @@ import { TemplateContext } from "xServices/template/templateXService" import { TemplateSummaryPageView } from "./TemplateSummaryPageView" export const TemplateSummaryPage: FC> = () => { + const { templateContext } = useOutletContext<{ templateContext: TemplateContext }>() const { template, activeTemplateVersion, @@ -13,7 +14,7 @@ export const TemplateSummaryPage: FC> = () => { templateVersions, deleteTemplateError, templateDAUs, - } = useOutletContext() + } = templateContext if (!template || !activeTemplateVersion || !templateResources) { throw new Error(